diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fa1f89..21e4865 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,3 +199,98 @@ jobs: "$OPENPLAYER_LINUX_APPIMAGE" \ "$OPENPLAYER_LINUX_APPIMAGE_CHECKSUM" \ --clobber + + macos-packages: + name: macOS unsigned packages (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + - runner: macos-15-intel + arch: x64 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install macOS dependencies + run: | + brew install mpv pkg-config + echo "PKG_CONFIG_PATH=$(brew --prefix mpv)/lib/pkgconfig:${PKG_CONFIG_PATH:-}" >> "$GITHUB_ENV" + + - name: Install frontend dependencies + working-directory: apps/desktop + run: npm ci + + - name: Verify release metadata + working-directory: apps/desktop + run: npm run verify:release -- --tag=$RELEASE_TAG + + - name: Verify shell architecture + working-directory: apps/desktop + run: npm run verify:shell + + - name: Build macOS app bundle + working-directory: apps/desktop + run: npm run tauri:build -- --config src-tauri/tauri.macos.conf.json --bundles app + + - name: Bundle macOS libmpv dylibs + working-directory: apps/desktop + run: node scripts/bundle-macos-libmpv.mjs + + - name: Verify macOS dylib references + run: | + app="target/release/bundle/macos/OpenPlayer.app" + unresolved=0 + exe="$(find "$app/Contents/MacOS" -maxdepth 1 -type f | head -1)" + if otool -L "$exe" | grep -E "/opt/homebrew|/usr/local|/Cellar"; then + unresolved=1 + fi + while IFS= read -r binary; do + if otool -L "$binary" 2>/dev/null | grep -E "/opt/homebrew|/usr/local|/Cellar"; then + echo "unrewritten dependency in $binary" + unresolved=1 + fi + done < <(find "$app/Contents/Frameworks" -type f \( -name "*.dylib" -o -name "*.so" -o -perm +111 \)) + exit "$unresolved" + + - name: Ad-hoc sign macOS app + run: | + app="target/release/bundle/macos/OpenPlayer.app" + codesign --force --deep --sign - "$app" + codesign --verify --deep --strict --verbose=2 "$app" + + - name: Prepare unsigned DMG checksum + run: | + version="$(node -e "const fs=require('fs'); console.log(JSON.parse(fs.readFileSync('apps/desktop/package.json','utf8')).version)")" + app="$(realpath target/release/bundle/macos/OpenPlayer.app)" + dmg_dir="target/release/bundle/dmg" + mkdir -p "$dmg_dir" + dmg="$PWD/$dmg_dir/OpenPlayer_${version}_${{ matrix.arch }}.dmg" + rm -f "$dmg" "$dmg.sha256" + hdiutil create -volname OpenPlayer -srcfolder "$app" -ov -format UDZO "$dmg" + shasum -a 256 "$dmg" > "$dmg.sha256" + + - name: Upload unsigned macOS package artifact + uses: actions/upload-artifact@v6 + with: + name: openplayer-macos-${{ matrix.arch }}-unsigned + path: | + target/release/bundle/dmg/*.dmg + target/release/bundle/dmg/*.sha256 diff --git a/Cargo.lock b/Cargo.lock index 62b7c2c..eba448d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2061,9 +2061,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "openplayer-desktop" version = "1.1.0" dependencies = [ + "cc", "libc", "libmpv2", "libmpv2-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", "raw-window-handle", "redb", "serde", diff --git a/apps/desktop/scripts/bundle-macos-libmpv.mjs b/apps/desktop/scripts/bundle-macos-libmpv.mjs new file mode 100644 index 0000000..a04b138 --- /dev/null +++ b/apps/desktop/scripts/bundle-macos-libmpv.mjs @@ -0,0 +1,300 @@ +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + readdirSync, + rmSync, + statSync, + symlinkSync, +} from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("../../../", import.meta.url))); +const defaultApp = join(root, "target/release/bundle/macos/OpenPlayer.app"); +const appBundle = resolve(process.argv[2] ?? defaultApp); +const frameworksDir = join(appBundle, "Contents/Frameworks"); +const executableDir = join(appBundle, "Contents/MacOS"); + +function fail(message) { + throw new Error(`macOS libmpv bundling failed: ${message}`); +} + +function command(name, args) { + return execFileSync(name, args, { encoding: "utf8" }); +} + +function appExecutable() { + if (!existsSync(executableDir)) { + fail(`missing executable directory: ${executableDir}`); + } + + const candidates = readdirSync(executableDir) + .map((entry) => join(executableDir, entry)) + .filter((path) => !path.endsWith(".dSYM")); + if (candidates.length !== 1) { + fail(`expected one app executable in ${executableDir}, found ${candidates.length}`); + } + + return candidates[0]; +} + +function dylibDependencies(binary) { + let output = ""; + try { + output = command("otool", ["-L", binary]); + } catch { + return []; + } + + return output + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim().split(/\s+/)[0]) + .filter(Boolean); +} + +function inspectableBinaries(root) { + const stat = statSync(root); + if (stat.isFile()) { + return isInspectableBinary(root, stat) ? [root] : []; + } + + const binaries = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const child = join(root, entry.name); + if (entry.isDirectory()) { + binaries.push(...inspectableBinaries(child)); + } else if (entry.isFile()) { + const childStat = statSync(child); + if (isInspectableBinary(child, childStat)) { + binaries.push(child); + } + } + } + + return binaries; +} + +function isInspectableBinary(path, stat) { + return path.endsWith(".dylib") || path.endsWith(".so") || Boolean(stat.mode & 0o111); +} + +function isInstallNameTarget(path) { + return path.endsWith(".dylib") || path.includes(".framework/"); +} + +function forceSymlink(target, linkPath) { + rmSync(linkPath, { force: true, recursive: true }); + symlinkSync(target, linkPath); +} + +function removeCodeSignatureDirectories(root) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const child = join(root, entry.name); + if (entry.name === "_CodeSignature") { + rmSync(child, { force: true, recursive: true }); + } else if (entry.isDirectory()) { + removeCodeSignatureDirectories(child); + } + } +} + +function rewriteFrameworkSymlinks(root, sourceRoot, targetRoot) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const child = join(root, entry.name); + const childStat = lstatSync(child); + if (childStat.isSymbolicLink()) { + const target = readlinkSync(child); + if (target.startsWith(sourceRoot)) { + const localTarget = join(targetRoot, target.slice(sourceRoot.length + 1)); + forceSymlink(relative(dirname(child), localTarget), child); + } + } else if (entry.isDirectory()) { + rewriteFrameworkSymlinks(child, sourceRoot, targetRoot); + } + } +} + +function normalizeFrameworkBundle(sourceRoot, targetRoot) { + rewriteFrameworkSymlinks(targetRoot, sourceRoot, targetRoot); + removeCodeSignatureDirectories(targetRoot); + + const versionsDir = join(targetRoot, "Versions"); + if (!existsSync(versionsDir)) { + return; + } + + const versions = readdirSync(versionsDir) + .filter((entry) => entry !== "Current") + .filter((entry) => lstatSync(join(versionsDir, entry)).isDirectory()) + .sort(); + const version = versions.at(-1); + if (!version) { + return; + } + + const versionRoot = join(versionsDir, version); + const frameworkBinary = basename(targetRoot, ".framework"); + if (existsSync(join(versionRoot, frameworkBinary))) { + forceSymlink(version, join(versionsDir, "Current")); + forceSymlink(`Versions/Current/${frameworkBinary}`, join(targetRoot, frameworkBinary)); + } + if (existsSync(join(versionRoot, "Headers"))) { + forceSymlink("Versions/Current/Headers", join(targetRoot, "Headers")); + } + if (existsSync(join(versionRoot, "Resources"))) { + forceSymlink("Versions/Current/Resources", join(targetRoot, "Resources")); + } + + if (frameworkBinary === "Python") { + const pythonHeaders = join(versionRoot, "include", `python${version}`); + if (existsSync(pythonHeaders)) { + forceSymlink(`include/python${version}`, join(versionRoot, "Headers")); + } + + const sitePackages = join(versionRoot, "lib", `python${version}`, "site-packages"); + if (existsSync(sitePackages) && lstatSync(sitePackages).isSymbolicLink()) { + rmSync(sitePackages, { force: true }); + mkdirSync(sitePackages, { recursive: true }); + } + } +} + +function hasBundledLibmpv() { + return readdirSync(frameworksDir).some( + (entry) => entry.startsWith("libmpv") && entry.endsWith(".dylib"), + ); +} + +function shouldBundleDependency(path) { + return ( + path.startsWith("/opt/homebrew/") || path.startsWith("/usr/local/") || path.includes("/Cellar/") + ) && (path.endsWith(".dylib") || path.includes(".framework/")); +} + +function bundledDependency(path) { + const frameworkMarker = ".framework/"; + const frameworkIndex = path.indexOf(frameworkMarker); + if (frameworkIndex !== -1) { + const frameworkRoot = path.slice(0, frameworkIndex + ".framework".length); + const frameworkName = basename(frameworkRoot); + const relativeBinary = path.slice(frameworkRoot.length + 1); + const copyTarget = join(frameworksDir, frameworkName); + return { + copySource: frameworkRoot, + copyTarget, + targetBinary: join(copyTarget, relativeBinary), + reference: `@executable_path/../Frameworks/${frameworkName}/${relativeBinary}`, + }; + } + + const name = basename(path); + const target = join(frameworksDir, name); + return { + copySource: path, + copyTarget: target, + targetBinary: target, + reference: `@executable_path/../Frameworks/${name}`, + }; +} + +function queueBinary(path, queue, queuedTargets) { + if (queuedTargets.has(path)) { + return; + } + + queuedTargets.add(path); + queue.push(path); +} + +function copyDependency(path, queue, bundled, queuedTargets) { + const dependency = bundledDependency(path); + if (!existsSync(dependency.copyTarget)) { + if (dependency.copySource.endsWith(".framework")) { + cpSync(dependency.copySource, dependency.copyTarget, { + recursive: true, + preserveTimestamps: true, + }); + } else { + copyFileSync(dependency.copySource, dependency.copyTarget); + } + } + + if (dependency.copySource.endsWith(".framework")) { + normalizeFrameworkBundle(dependency.copySource, dependency.copyTarget); + } + + chmodSync(dependency.targetBinary, 0o755); + + if (!bundled.has(path)) { + bundled.set(path, dependency); + } + + for (const binary of inspectableBinaries(dependency.copyTarget)) { + queueBinary(binary, queue, queuedTargets); + } + + return dependency; +} + +function rewriteDependencyReferences(binary, replacements) { + const dependencies = new Set(dylibDependencies(binary)); + for (const [original, dependency] of replacements) { + if (!dependencies.has(original)) { + continue; + } + + command("install_name_tool", ["-change", original, dependency.reference, binary]); + } +} + +if (!existsSync(appBundle)) { + fail(`missing app bundle: ${appBundle}`); +} + +mkdirSync(frameworksDir, { recursive: true }); + +const executable = appExecutable(); +const bundled = new Map(); +const queue = []; +const queuedTargets = new Set(); + +queueBinary(executable, queue, queuedTargets); +for (const binary of inspectableBinaries(frameworksDir)) { + queueBinary(binary, queue, queuedTargets); +} + +for (const dependency of dylibDependencies(executable).filter(shouldBundleDependency)) { + copyDependency(dependency, queue, bundled, queuedTargets); +} + +for (let index = 0; index < queue.length; index += 1) { + const binary = queue[index]; + const dependencies = dylibDependencies(binary).filter(shouldBundleDependency); + + for (const dependency of dependencies) { + copyDependency(dependency, queue, bundled, queuedTargets); + } +} + +if (![...bundled.keys()].some((path) => basename(path).startsWith("libmpv")) && !hasBundledLibmpv()) { + fail(`no Homebrew libmpv dependency found in ${executable}`); +} + +for (const [source, copied] of bundled) { + if (isInstallNameTarget(copied.targetBinary)) { + command("install_name_tool", ["-id", copied.reference, copied.targetBinary]); + } + + console.log(`bundled ${source} -> ${copied.targetBinary}`); +} + +for (const binary of queuedTargets) { + rewriteDependencyReferences(binary, bundled); +} diff --git a/apps/desktop/scripts/verify-shell.mjs b/apps/desktop/scripts/verify-shell.mjs index ca57d1f..6136294 100644 --- a/apps/desktop/scripts/verify-shell.mjs +++ b/apps/desktop/scripts/verify-shell.mjs @@ -7,6 +7,8 @@ const windowsConfigUrl = new URL("../src-tauri/tauri.windows.conf.json", import. const windowsConfig = existsSync(windowsConfigUrl) ? JSON.parse(await readFile(windowsConfigUrl, "utf8")) : {}; const linuxConfigUrl = new URL("../src-tauri/tauri.linux.conf.json", import.meta.url); const linuxConfig = existsSync(linuxConfigUrl) ? JSON.parse(await readFile(linuxConfigUrl, "utf8")) : {}; +const macosConfigUrl = new URL("../src-tauri/tauri.macos.conf.json", import.meta.url); +const macosConfig = existsSync(macosConfigUrl) ? JSON.parse(await readFile(macosConfigUrl, "utf8")) : {}; const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); const indexHtml = await readFile(new URL("../index.html", import.meta.url), "utf8"); const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8"); @@ -29,6 +31,8 @@ const ciWorkflow = await readFile(new URL("../../../.github/workflows/ci.yml", i const releaseWorkflowUrl = new URL("../../../.github/workflows/release.yml", import.meta.url); const releaseWorkflow = existsSync(releaseWorkflowUrl) ? await readFile(releaseWorkflowUrl, "utf8") : ""; const releaseVerifyScriptUrl = new URL("./verify-release.mjs", import.meta.url); +const macosBundleScriptUrl = new URL("./bundle-macos-libmpv.mjs", import.meta.url); +const macosBundleScriptSource = existsSync(macosBundleScriptUrl) ? await readFile(macosBundleScriptUrl, "utf8") : ""; const releaseMpvManifestUrl = new URL("../../../docs/native-deps/mpv-windows-x64.json", import.meta.url); const workspaceToml = await readFile(new URL("../../../Cargo.toml", import.meta.url), "utf8"); const tauriCargoToml = await readFile(new URL("../src-tauri/Cargo.toml", import.meta.url), "utf8"); @@ -37,6 +41,8 @@ const nsisHooksUrl = new URL("../src-tauri/nsis-hooks.nsh", import.meta.url); const nsisHooksSource = existsSync(nsisHooksUrl) ? await readFile(nsisHooksUrl, "utf8") : ""; const mpvEmbedUrl = new URL("../src-tauri/src/mpv_embed.rs", import.meta.url); const mpvEmbedSource = existsSync(mpvEmbedUrl) ? await readFile(mpvEmbedUrl, "utf8") : ""; +const macosGlViewUrl = new URL("../src-tauri/src/macos_mpv_gl_view.m", import.meta.url); +const macosGlViewSource = existsSync(macosGlViewUrl) ? await readFile(macosGlViewUrl, "utf8") : ""; const mpvRenderFiles = [ new URL("../src-tauri/src/mpv_render.rs", import.meta.url), new URL("../src-tauri/src/mpv_render/sys.rs", import.meta.url), @@ -46,6 +52,7 @@ const rootLogoUrl = new URL("../../../openplayer_logo_10001000.png", import.meta const uiLogoUrl = new URL("../src/assets/openplayer-logo.png", import.meta.url); const tauriIconPngUrl = new URL("../src-tauri/icons/icon.png", import.meta.url); const tauriIconIcoUrl = new URL("../src-tauri/icons/icon.ico", import.meta.url); +const tauriIconIcnsUrl = new URL("../src-tauri/icons/icon.icns", import.meta.url); const embedCommandPattern = /mpv_embed_open_path|mpv_embed_play|mpv_embed_pause|mpv_embed_seek|mpv_embed_set_volume|mpv_embed_set_speed|mpv_embed_select_track|mpv_embed_add_subtitle|mpv_embed_snapshot|mpv_embed_stop/; function extractCfgFunction(source, cfgPattern, fnPattern) { @@ -110,6 +117,23 @@ const windowToggleFullscreenMatch = /#\[tauri::command\]\s*fn\s+window_toggle_fu const windowToggleFullscreenSource = windowToggleFullscreenMatch ? extractFunctionAt(tauriLibSource, windowToggleFullscreenMatch.index + windowToggleFullscreenMatch[0].lastIndexOf("fn")) : ""; +const windowApplyResizeDeltaMatch = /#\[tauri::command\]\s*fn\s+window_apply_resize_delta\s*\(/.exec(tauriLibSource); +const windowApplyResizeDeltaSource = windowApplyResizeDeltaMatch + ? extractFunctionAt(tauriLibSource, windowApplyResizeDeltaMatch.index + windowApplyResizeDeltaMatch[0].lastIndexOf("fn")) + : ""; +const mpvSnapshotMatch = /fn\s+snapshot\s*\(/.exec(mpvEmbedSource); +const mpvSnapshotSource = mpvSnapshotMatch ? extractFunctionAt(mpvEmbedSource, mpvSnapshotMatch.index) : ""; +const dragRegionSources = [ + "handleDragRegionPointerDown", + "handleDragRegionPointerMove", + "handleDragRegionPointerEnd", + "handleDragRegionDoubleClick", +] + .map((name) => { + const match = new RegExp(`function\\s+${name}\\s*\\(`).exec(appSource); + return match ? extractFunctionAt(appSource, match.index) : ""; + }) + .join("\n"); const [mainWindow] = config.app.windows; @@ -144,6 +168,17 @@ assert.deepEqual(linuxConfig.bundle?.targets, ["deb", "appimage"], "Linux deskto assert.ok(linuxConfig.bundle?.icon?.includes("icons/128x128.png"), "Linux AppImage bundle must declare a square PNG icon"); assert.ok(linuxConfig.bundle?.linux?.deb?.depends?.includes("libmpv2"), "Linux deb packages must declare the libmpv runtime dependency"); assert.ok(linuxConfig.bundle?.linux?.deb?.depends?.includes("libwebkit2gtk-4.1-0"), "Linux deb packages must declare the WebKitGTK runtime dependency"); +assert.ok(existsSync(macosConfigUrl), "macOS desktop release builds must have a platform-specific Tauri config"); +assert.equal(macosConfig.identifier, "dev.openplayer.desktop", "macOS bundle identifier must not end with the .app extension"); +assert.equal(macosConfig.bundle?.active, true, "macOS desktop release builds must produce native bundles by default"); +assert.deepEqual(macosConfig.bundle?.targets, ["app", "dmg"], "macOS desktop release build should target app and DMG bundles"); +assert.equal(config.app?.macOSPrivateApi, true, "macOS overlay builds must enable Tauri private APIs for transparent control windows"); +assert.match(tauriCargoToml, /tauri\s*=\s*\{[^\n]*macos-private-api/, "Tauri's macos-private-api feature must be enabled for transparent macOS overlay windows"); +assert.match(tauriCargoToml, /objc2-app-kit/, "macOS mpv embedding must use objc2 AppKit bindings"); +assert.match(tauriBuildScript, /pkg-config[\s\S]*--libs-only-L[\s\S]*mpv/, "macOS mpv linking must read Homebrew lib paths from pkg-config"); +assert.ok(existsSync(macosBundleScriptUrl), "macOS release builds must include a libmpv dylib bundling script"); +assert.match(macosBundleScriptSource, /Contents\/Frameworks[\s\S]*otool[\s\S]*install_name_tool/, "macOS libmpv bundling must copy dylibs into Contents/Frameworks and rewrite install names"); +assert.match(macosBundleScriptSource, /\.framework\/[\s\S]*cpSync/, "macOS libmpv bundling must include Homebrew framework dependencies such as Python.framework"); assert.match(config.build.devUrl, /23142$/, "Tauri dev URL must use the non-reserved Windows port"); assert.match(packageJson.scripts.dev, /23142$/, "Vite dev script must use the non-reserved Windows port"); assert.match(packageJson.scripts.preview, /23142$/, "Vite preview script must use the non-reserved Windows port"); @@ -169,6 +204,16 @@ assert.match(releaseWorkflow, /target\/release\/bundle\/deb\/\*\.deb/, "release assert.match(releaseWorkflow, /target\/release\/bundle\/appimage\/\*\.AppImage/, "release workflow must locate the Linux AppImage artifact"); assert.match(releaseWorkflow, /sha256sum[\s\S]*deb[\s\S]*sha256sum[\s\S]*appimage/, "release workflow must generate SHA256 checksums for Linux packages"); assert.match(releaseWorkflow, /gh release upload[\s\S]*OPENPLAYER_LINUX_DEB[\s\S]*OPENPLAYER_LINUX_APPIMAGE/, "release workflow must upload Linux packages and checksums to GitHub Releases"); +assert.match(releaseWorkflow, /macos-packages:/, "release workflow must build unsigned macOS packages for internal testing"); +assert.match(releaseWorkflow, /macos-15-intel/, "macOS release workflow must cover Intel runners explicitly"); +assert.match(releaseWorkflow, /macos-15/, "macOS release workflow must cover Apple Silicon runners explicitly"); +assert.match(releaseWorkflow, /brew install[\s\S]*mpv[\s\S]*pkg-config|brew install[\s\S]*pkg-config[\s\S]*mpv/, "macOS release job must install Homebrew mpv and pkg-config"); +assert.match(releaseWorkflow, /npm run tauri:build -- --config src-tauri\/tauri\.macos\.conf\.json/, "release workflow must build macOS app and DMG bundles"); +assert.match(releaseWorkflow, /bundle-macos-libmpv\.mjs/, "release workflow must bundle libmpv dylibs before packaging macOS DMGs"); +assert.match(releaseWorkflow, /otool -L[\s\S]*\/opt\/homebrew[\s\S]*\/Cellar/, "release workflow must reject unbundled Homebrew dylib references in macOS packages"); +assert.match(releaseWorkflow, /codesign --force --deep --sign -[\s\S]*OpenPlayer\.app/, "release workflow must re-sign the app bundle after rewriting macOS dylib references"); +assert.match(releaseWorkflow, /target\/release\/bundle\/dmg\/\*\.dmg/, "release workflow must locate the macOS DMG artifact"); +assert.match(releaseWorkflow, /shasum -a 256[\s\S]*dmg/, "release workflow must generate SHA256 checksums for macOS packages"); assert.match(indexHtml, /surface["')\s.]*={1,3}\s*"video"[\s\S]*surface-video/, "index.html must classify the main video surface before React mounts"); assert.match(indexHtml, /surface-overlay/, "index.html must classify non-video surfaces as transparent overlays before React mounts"); assert.match(indexHtml, /html\.surface-video[\s\S]*background:\s*#000/, "video surface must paint black before React and mpv finish loading"); @@ -177,6 +222,7 @@ assert.ok(!existsSync(rootLogoUrl), "source logo must live under app assets, not assert.ok(existsSync(uiLogoUrl), "frontend logo asset must exist"); assert.ok(existsSync(tauriIconPngUrl), "Tauri PNG icon must exist"); assert.ok(existsSync(tauriIconIcoUrl), "Windows ICO icon must exist"); +assert.ok(existsSync(tauriIconIcnsUrl), "macOS ICNS icon must exist"); assert.equal(packageJson.dependencies["movi-player"], undefined, "minimal branch must not ship WASM/software decoder dependency"); assert.ok(packageJson.dependencies["@tauri-apps/plugin-dialog"], "mpv path playback must use Tauri dialog to obtain real local paths"); @@ -189,6 +235,7 @@ assert.match(tauriCargoToml, /libmpv2-sys/, "desktop crate must keep libmpv2-sys assert.doesNotMatch(tauriBuildScript, /CARGO_FEATURE_MPV_RENDER/, "build script must not keep the removed mpv-render feature gate"); assert.match(tauriBuildScript, /CARGO_FEATURE_MPV_SMOKE[\s\S]*CARGO_FEATURE_MPV_EMBED/, "build script must only add mpv link paths when an mpv feature is enabled"); assert.match(tauriBuildScript, /vendor[\\/]native[\\/]mpv[\\/]windows-x64/, "build script must point at the vendored Windows mpv directory"); +assert.match(tauriBuildScript, /macos_mpv_gl_view\.m[\s\S]*framework=OpenGL/, "macOS mpv render API host must compile the AppKit OpenGL child view and link OpenGL"); assert.match(tauriLibSource, /#\[cfg\(feature = "mpv-smoke"\)\]\s*mod mpv_smoke;/, "desktop crate must keep libmpv2 smoke code feature-gated"); assert.match(tauriLibSource, /#\[cfg\(feature = "mpv-embed"\)\]\s*mod mpv_embed;/, "desktop crate must compile the stable mpv child HWND backend behind mpv-embed"); assert.match(tauriLibSource, /mod platform_support;/, "desktop backend must expose platform capability metadata"); @@ -307,6 +354,7 @@ assert.match(appSource, /mpv_embed_frame_back_step/, "backward-one-frame shortcu assert.doesNotMatch(appSource, /audioVisualizerColor/, "audio visualization must use app theme CSS variables instead of passing accent colors into mpv"); assert.match(appSource, /mpv_embed_set_speed/, "frontend must control mpv playback speed through a backend command"); assert.match(appSource, /mpv_embed_set_hwdec/, "frontend must control mpv hardware decoding mode through a backend command"); +assert.match(appSource, /mpv_embed_set_video_fill/, "frontend must expose mpv video fill as an explicit media option instead of changing it during fullscreen"); assert.match(appSource, /decode-toggle/, "control strip must replace the prominent settings button with the decode-mode toggle"); assert.doesNotMatch(appSource, /aria-label="Open settings"/, "settings must remain available through the context menu instead of the bottom control strip"); assert.match(appSource, /mpv_embed_select_track/, "frontend must switch audio, video, and subtitle tracks through a backend command"); @@ -327,6 +375,19 @@ assert.match(appSource, /onDoubleClick=\{handleDragRegionDoubleClick\}/, "non-co assert.doesNotMatch(appSource, /data-tauri-drag-region/, "full-surface video interaction layer must not use Tauri's automatic drag region because it swallows double-click playback toggles"); assert.match(appSource, /WINDOW_DRAG_START_DISTANCE_PX[\s\S]*handleDragRegionPointerMove[\s\S]*startMainWindowDrag/, "video-surface drag must start from pointer movement threshold instead of a timer so dragging remains responsive while double-click works"); assert.doesNotMatch(appSource, /dragStartTimerRef|setTimeout\(\(\) => \{\s*[^}]*startMainWindowDrag/, "video-surface drag must not wait on a double-click delay timer"); +assert.match(appSource, /window_apply_resize_delta/, "macOS overlay resize handles must use a manual resize delta fallback because tao does not support drag_resize_window on macOS"); +assert.match(appSource, /platformSupport\?\.os\s*===\s*"macos"[\s\S]*manualResizeDragRef/, "macOS resize handles must select the manual resize path only on macOS"); +assert.match(appSource, /setPointerCapture[\s\S]*manualResizeDragRef|manualResizeDragRef[\s\S]*setPointerCapture/, "manual macOS resize fallback must capture pointer movement until release"); +assert.match(appSource, /requestManualResizeFlush[\s\S]*resizeCommandInFlight/, "manual macOS resize fallback must coalesce pointer deltas and keep only one resize IPC in flight"); +assert.match(appSource, /flushManualResizeDelta[\s\S]*applyManualMainWindowResize/, "manual macOS resize fallback must flush coalesced deltas through the backend resize command"); +assert.match(appSource, /window_set_resize_cursor/, "resize handles must explicitly set native cursor icons instead of relying only on CSS over transparent macOS overlays"); +assert.match(appSource, /onPointerEnter=\{\(event\) => handleResizePointerEnter\(event, region\.direction\)\}/, "resize handles must set the matching cursor as soon as the pointer enters the hit area"); +assert.match(appSource, /onPointerLeave=\{handleResizePointerLeave\}/, "resize handles must restore the default cursor when the pointer leaves the hit area"); +assert.match(appSource, /resizeFeedback/, "resize handles must maintain visible in-app resize feedback state for macOS when native cursor icons are unavailable"); +assert.match(appSource, /resize-feedback--active/, "resize dragging must strengthen the in-app resize feedback"); +assert.match(appSource, /resize-feedback--/, "player shell must render a direction-specific resize feedback layer"); +assert.match(appSource, /handleResizePointerEnd[\s\S]*setNativeResizeCursor\(null\)[\s\S]*setResizeBoundaryFeedback\(null\)/, "resize feedback must disappear immediately when resize dragging ends instead of falling back to hover state"); +assert.match(appSource, /videoLayout[\s\S]*video-layout-options[\s\S]*setVideoFillMode/, "video fill/crop must live with the track and subtitle media options"); assert.match(appSource, /onWheel=\{handleShellWheel\}/, "player shell must support mouse wheel volume control"); assert.match(appSource, /volumeFeedback/, "volume changes from wheel and shortcuts must display a transient volume overlay"); assert.doesNotMatch(appSource, /volume-feedback-track/, "volume feedback overlay must stay compact and not render a progress track"); @@ -385,7 +446,7 @@ assert.match(appSource, /className="seek-control"[\s\S]*--progress/, "seek UI mu assert.match(appSource, /className="seek-progress"/, "seek UI must render an independent progress fill instead of relying only on native range background repainting"); assert.match(appSource, /--progress-ratio/, "seek UI must expose a unitless progress ratio for exact custom thumb positioning"); assert.match(appSource, /className="seek-thumb"/, "seek UI must render its own thumb instead of relying on WebView range pseudo-element alignment"); -assert.doesNotMatch(appSource, /setPointerCapture|releasePointerCapture|startDragging|DragIntent|continueWindowDragIntent|beginWindowDragIntent/, "custom chrome must not use pointer-capture startDragging loop that freezes render windows"); +assert.doesNotMatch(dragRegionSources, /setPointerCapture|releasePointerCapture|startDragging|DragIntent|continueWindowDragIntent|beginWindowDragIntent/, "custom chrome drag regions must not use a pointer-capture startDragging loop that freezes render windows"); assert.doesNotMatch(appSource, /titlebar-brand|titlebar-center|side-rail|status-line/, "confirmed baseline UI must not regress to the older chrome layout"); assert.match(styles, /\.window-shell[\s\S]*border:\s*0/, "window shell must not draw an outer border"); @@ -400,6 +461,10 @@ assert.match(styles, /\.history-item/, "styles must include playback history ite assert.match(styles, /\.drag-region\s*\{[\s\S]*inset:\s*0/, "drag region must cover the non-control player surface, not just the title strip"); assert.match(styles, /\.resize-region\s*\{[\s\S]*z-index:\s*95/, "overlay must keep resize hit areas above modal backdrops"); assert.match(styles, /\.resize-region--south-east/, "overlay must include corner resize hit areas"); +assert.match(styles, /\.resize-feedback\s*\{[\s\S]*pointer-events:\s*none/, "resize feedback must be visible without blocking media controls or resize hit areas"); +assert.match(styles, /\.resize-feedback\s*\{[\s\S]*z-index:\s*96/, "resize feedback must render above transparent resize hit areas so edge hints are complete"); +assert.match(styles, /\.resize-feedback--north[\s\S]*\.resize-feedback-line--north/, "resize feedback must show a top edge hint"); +assert.match(styles, /\.resize-feedback--south-east[\s\S]*\.resize-feedback-corner--south-east/, "resize feedback must show a corner hint"); assert.match(styles, /\.transport\s*\{[\s\S]*pointer-events:\s*auto/, "transport controls must remain interactive above the full-surface drag region"); assert.match(styles, /\.playlist-drawer--open\s*\{[\s\S]*pointer-events:\s*auto/, "open playlist drawer must remain interactive above the full-surface drag region"); assert.match(styles, /\.empty-open-logo/, "empty player state must style the OpenPlayer logo"); @@ -505,6 +570,22 @@ assert.match(mpvEmbedSource, /#\[cfg\(windows\)\][\s\S]*use windows_sys::Win32/, assert.match(mpvEmbedSource, /SetWindowPos[\s\S]*HWND_TOP[\s\S]*SWP_NOACTIVATE[\s\S]*SWP_SHOWWINDOW/, "Windows mpv child window resizes must reassert top child z-order over the black WebView video surface"); assert.match(mpvEmbedSource, /RawWindowHandle::Xlib[\s\S]*RawWindowHandle::Xcb/, "mpv native video host must support X11 window ids on Linux"); assert.match(mpvEmbedSource, /Wayland[\s\S]*not implemented yet/, "mpv native video host must explicitly reject Wayland until a separate host path exists"); +assert.match(mpvEmbedSource, /RawWindowHandle::AppKit[\s\S]*ns_view/, "mpv native video host must support AppKit NSView ids on macOS"); +assert.match(mpvEmbedSource, /openplayer_mpv_gl_view_create[\s\S]*create_macos_render_context/, "macOS mpv embedding must create an AppKit render view before wiring libmpv rendering"); +assert.match(mpvEmbedSource, /mpv_render_context_create[\s\S]*mpv_render_context_set_update_callback/, "macOS mpv embedding must use libmpv's render API instead of relying on unsupported --wid window nesting"); +assert.match(mpvEmbedSource, /openplayer_mpv_gl_view_resize/, "macOS mpv render view must resize with the Tauri video host"); +assert.match(mpvEmbedSource, /#\[cfg\(target_os = "macos"\)\]\s*fn platform_video_output_config[\s\S]*vo:\s*Some\("libmpv"\.to_string\(\)\)/, "macOS mpv embedding must use vo=libmpv so mpv does not create its own Cocoa window"); +assert.match(mpvEmbedSource, /#\[cfg\(target_os = "macos"\)\][\s\S]*video-timing-offset"[\s\S]*"0"/, "macOS libmpv render path must avoid blocking AppKit on mpv frame timing"); +assert.match(macosGlViewSource, /NSOpenGLView[\s\S]*MPV_RENDER_PARAM_OPENGL_FBO[\s\S]*mpv_render_context_render/, "macOS AppKit host must render libmpv frames into an OpenGL child view"); +assert.match(macosGlViewSource, /dispatch_async\(dispatch_get_main_queue\(\)/, "macOS mpv render updates must schedule drawing on the AppKit main thread"); +assert.doesNotMatch(macosGlViewSource, /\[[^\]]+\s+display(?:IfNeeded)?\]/, "macOS mpv render update callbacks must not synchronously render on the AppKit main thread"); +assert.match(macosGlViewSource, /renderScheduled[\s\S]*setNeedsDisplay:YES/, "macOS mpv render updates must be coalesced into asynchronous redraw requests"); +assert.match(mpvEmbedSource, /async fn mpv_embed_play[\s\S]*run_mpv_command/, "macOS mpv play commands must run libmpv calls away from the AppKit/WebKit main thread"); +assert.match(mpvEmbedSource, /async fn mpv_embed_set_loop_file[\s\S]*run_mpv_command/, "macOS mpv loop commands must run libmpv calls away from the AppKit/WebKit main thread"); +assert.match(mpvEmbedSource, /async fn mpv_embed_snapshot[\s\S]*run_mpv_command/, "periodic mpv snapshots must not block the AppKit/WebKit main thread"); +assert.match(mpvEmbedSource, /tauri::async_runtime::spawn_blocking/, "mpv command dispatch must use a blocking worker to avoid AppKit/libmpv main-thread deadlocks on macOS"); +assert.doesNotMatch(mpvSnapshotSource, /host\.resize/, "mpv snapshots must not resize the AppKit video host while holding the player mutex"); +assert.match(mpvEmbedSource, /mpv_embed_set_video_fill[\s\S]*set_property\("panscan"[\s\S]*1\.0[\s\S]*0\.0/, "mpv video fill must use panscan only when the explicit video layout option is enabled"); assert.match(mpvEmbedSource, /fn window_mpv_wid/, "mpv native video host must map raw platform handles to mpv wid values"); assert.match(mpvEmbedSource, /fn wid\(&self\) -> i64/, "mpv native video host must expose a platform-owned mpv wid boundary"); assert.match(mpvEmbedSource, /mpv_embed_snapshot[\s\S]*player\.snapshot\(0,\s*"playing"\)/, "periodic mpv snapshots must preserve playing status so smooth progress, frame labels, and Space pause keep working"); @@ -518,7 +599,11 @@ assert.match(mpvEmbedSource, /raw_paused\s*\|\|\s*pause_guard_active/, "snapshot assert.doesNotMatch(tauriLibSource, /mod playback;|mod storage|DesktopPlaybackState|DesktopStorageState|playback_command|storage_|openplayer_core|openplayer_shared/, "desktop backend must not restore removed playback or storage plumbing"); assert.match(tauriLibSource, /window_minimize/, "desktop backend must keep minimize command"); assert.match(tauriLibSource, /window_toggle_maximize/, "desktop backend must keep maximize command"); -assert.match(tauriLibSource, /window_toggle_fullscreen[\s\S]*main_window\(&app\)\?[\s\S]*set_fullscreen/, "desktop backend must toggle fullscreen on the main video window"); +assert.match(tauriLibSource, /window_toggle_fullscreen[\s\S]*main_window\(&app\)\?[\s\S]*set_main_window_fullscreen/, "desktop backend must toggle fullscreen on the main video window"); +assert.match(windowToggleFullscreenSource, /fullscreen_restore[\s\S]*is_some\(\)[\s\S]*is_fullscreen/, "fullscreen restore state must be treated as authoritative so one middle click exits macOS fullscreen transitions"); +assert.doesNotMatch(windowToggleFullscreenSource, /set_fullscreen_video_fill|mpv_embed_set_video_fill|panscan/, "fullscreen toggling must preserve mpv's current video layout instead of forcing fill/crop"); +assert.match(tauriLibSource, /#\[cfg\(target_os = "macos"\)\][\s\S]*fn set_main_window_fullscreen[\s\S]*prepare_macos_main_window_chrome[\s\S]*set_fullscreen/, "macOS fullscreen should keep native fullscreen but hide AppKit titlebar chrome before transitions"); +assert.match(macosGlViewSource, /openplayer_macos_prepare_main_window[\s\S]*titleVisibility[\s\S]*titlebarAppearsTransparent[\s\S]*standardWindowButton/, "macOS main window must hide native titlebar controls to avoid titlebar flashes during fullscreen transitions"); assert.match(tauriLibSource, /struct WindowPlacement/, "desktop backend must record window placement before entering fullscreen"); assert.match(tauriLibSource, /restore_window_after_fullscreen/, "desktop backend must restore the recorded placement after leaving fullscreen"); assert.match(tauriLibSource, /fn focus_overlay_window/, "desktop backend must provide a shared way to focus the overlay controls window"); @@ -584,12 +669,17 @@ assert.match(tauriLibSource, /GetModuleHandleW/, "Windows shortcut hook must pas assert.match(tauriLibSource, /GetForegroundWindow/, "Windows shortcut bridge must only dispatch shortcuts when OpenPlayer is the foreground app"); assert.match(tauriLibSource, /openplayer-native-shortcut/, "native shortcut bridge must emit actions to the overlay frontend"); assert.match(tauriLibSource, /sync_overlay_to_main[\s\S]*focus_overlay_window\(app\)/, "overlay sync must return keyboard focus to the controls window"); +assert.match(tauriLibSource, /openplayer_macos_prepare_overlay_window/, "macOS overlay window must be marked as a fullscreen auxiliary child of the video window"); +assert.match(macosGlViewSource, /NSWindowCollectionBehaviorFullScreenAuxiliary[\s\S]*addChildWindow/, "macOS overlay must join the main video fullscreen space instead of creating a separate fullscreen space"); assert.match(tauriLibSource, /WindowEvent::Focused\(true\)[\s\S]*focus_overlay_window\(&app_handle\)/, "clicking the video/main window must return keyboard focus to the overlay shortcut handler"); assert.match(tauriLibSource, /fn schedule_overlay_sync_to_main/, "desktop backend must schedule overlay sync after asynchronous fullscreen transitions"); assert.match(windowToggleFullscreenSource, /schedule_overlay_sync_to_main\(&app\)/, "fullscreen toggling must defer overlay sync until the main window has applied fullscreen bounds"); assert.doesNotMatch(windowToggleFullscreenSource, /sync_overlay_to_main\(&app\)/, "fullscreen toggling must not immediately sync the overlay using stale fullscreen transition bounds"); -assert.match(mpvEmbedRunSource, /mpv_embed_frame_step[\s\S]*mpv_embed_frame_back_step[\s\S]*mpv_embed_set_speed[\s\S]*mpv_embed_set_subtitle_delay[\s\S]*mpv_embed_select_track[\s\S]*mpv_embed_add_subtitle/, "desktop runtime must register frame, speed, subtitle delay, track, and subtitle mpv commands"); +assert.match(mpvEmbedRunSource, /mpv_embed_frame_step[\s\S]*mpv_embed_frame_back_step[\s\S]*mpv_embed_set_speed[\s\S]*mpv_embed_set_video_fill[\s\S]*mpv_embed_set_subtitle_delay[\s\S]*mpv_embed_select_track[\s\S]*mpv_embed_add_subtitle/, "desktop runtime must register frame, speed, video layout, subtitle delay, track, and subtitle mpv commands"); assert.match(tauriLibSource, /window_start_resize[\s\S]*start_resize_dragging/, "desktop backend must start resizing the main video window from overlay hit areas"); +assert.match(tauriLibSource, /fn window_set_resize_cursor[\s\S]*CursorIcon::NeResize[\s\S]*CursorIcon::Default/, "desktop backend must expose native resize cursor icons for overlay hit areas"); +assert.match(windowApplyResizeDeltaSource, /set_position[\s\S]*set_size/, "macOS manual resize fallback must resize the main video window"); +assert.doesNotMatch(windowApplyResizeDeltaSource, /sync_overlay_to_main|sync_mpv_video_host/, "macOS manual resize deltas must not run immediate duplicate overlay/mpv sync on every pointermove"); assert.match(tauriLibSource, /window_close/, "desktop backend must keep close command"); assert.match(tauriLibSource, /window_start_drag[\s\S]*main_window\(&app\)\?[\s\S]*start_dragging/, "backend must drag the main video window when overlay drag strip is used"); assert.match(mainSource, /windows_subsystem\s*=\s*"windows"/, "release Windows app must use GUI subsystem instead of opening a console"); diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 02be658..7b641a1 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -12,10 +12,18 @@ crate-type = ["staticlib", "cdylib", "rlib"] [features] default = ["mpv-embed"] mpv-smoke = ["dep:libmpv2"] -mpv-embed = ["dep:libmpv2", "dep:libmpv2-sys", "dep:raw-window-handle", "dep:windows-sys"] +mpv-embed = [ + "dep:libmpv2", + "dep:libmpv2-sys", + "dep:objc2", + "dep:objc2-app-kit", + "dep:objc2-foundation", + "dep:raw-window-handle", + "dep:windows-sys", +] [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["macos-private-api"] } tauri-runtime = "2" libc = "0.2" libmpv2 = { version = "6.0.0", optional = true, default-features = false } @@ -35,4 +43,16 @@ windows-sys = { version = "0.61", features = [ ], optional = true } [build-dependencies] +cc = "1" tauri-build = { version = "2", features = [] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6.4", optional = true } +objc2-app-kit = { version = "0.3.2", optional = true, default-features = false, features = [ + "NSResponder", + "NSView", +] } +objc2-foundation = { version = "0.3.2", optional = true, default-features = false, features = [ + "NSGeometry", + "objc2-core-foundation", +] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index d6cd1b6..0ec8613 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -42,10 +42,116 @@ fn configure_mpv_linking() { println!("cargo:rerun-if-changed={}", import_library.display()); println!("cargo:rerun-if-changed={}", runtime_library.display()); } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + println!("cargo:rerun-if-env-changed=PKG_CONFIG"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); + + for link_search in unix_mpv_link_search_paths() { + println!("cargo:rustc-link-search=native={}", link_search.display()); + } + } +} + +#[cfg(target_os = "macos")] +fn compile_macos_mpv_render_view() { + if std::env::var_os("CARGO_FEATURE_MPV_EMBED").is_none() { + return; + } + + let mut build = cc::Build::new(); + build + .file("src/macos_mpv_gl_view.m") + .flag("-fobjc-arc") + .flag("-fblocks") + .flag("-Wno-deprecated-declarations"); + + for include_path in pkg_config_include_paths("mpv") { + build.include(include_path); + } + + build.compile("openplayer_macos_mpv_gl_view"); + + println!("cargo:rerun-if-changed=src/macos_mpv_gl_view.m"); + println!("cargo:rustc-link-lib=framework=Cocoa"); + println!("cargo:rustc-link-lib=framework=OpenGL"); +} + +#[cfg(not(target_os = "macos"))] +fn compile_macos_mpv_render_view() {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn unix_mpv_link_search_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(mpv_dir) = std::env::var_os("OPENPLAYER_MPV_DIR") { + let mpv_dir = std::path::PathBuf::from(mpv_dir); + if mpv_dir.join("lib").is_dir() { + paths.push(mpv_dir.join("lib")); + } else { + paths.push(mpv_dir); + } + } + + if let Some(pkg_config_paths) = pkg_config_link_search_paths("mpv") { + paths.extend(pkg_config_paths); + } + + let mut unique = Vec::new(); + for path in paths { + if !unique.iter().any(|candidate| candidate == &path) { + unique.push(path); + } + } + + unique +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn pkg_config_link_search_paths(package: &str) -> Option> { + let pkg_config = std::env::var_os("PKG_CONFIG").unwrap_or_else(|| "pkg-config".into()); + let output = std::process::Command::new(pkg_config) + .args(["--libs-only-L", package]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Some( + stdout + .split_whitespace() + .filter_map(|flag| flag.strip_prefix("-L")) + .map(std::path::PathBuf::from) + .collect(), + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn pkg_config_include_paths(package: &str) -> Vec { + let pkg_config = std::env::var_os("PKG_CONFIG").unwrap_or_else(|| "pkg-config".into()); + let Ok(output) = std::process::Command::new(pkg_config) + .args(["--cflags-only-I", package]) + .output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .filter_map(|flag| flag.strip_prefix("-I")) + .map(std::path::PathBuf::from) + .collect() } fn main() { configure_mpv_linking(); + compile_macos_mpv_render_view(); #[cfg(windows)] { diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..ea6a8f0 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/src/appearance_store.rs b/apps/desktop/src-tauri/src/appearance_store.rs index 1e47737..5da57e2 100644 --- a/apps/desktop/src-tauri/src/appearance_store.rs +++ b/apps/desktop/src-tauri/src/appearance_store.rs @@ -893,11 +893,16 @@ pub fn appearance_reset(state: State<'_, AppearanceStoreState>) -> Result (AppearanceStore, PathBuf) { + let counter = TEMP_STORE_COUNTER.fetch_add(1, Ordering::Relaxed); let directory = std::env::temp_dir().join(format!( - "openplayer-appearance-{}-{}", + "openplayer-appearance-{}-{}-{}", std::process::id(), + counter, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system time should be after unix epoch") diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1d0d6cd..5dd0289 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ -#[cfg(all(feature = "mpv-embed", windows))] +#[cfg(all(feature = "mpv-embed", any(windows, target_os = "macos")))] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; -use std::{collections::HashMap, sync::Mutex, thread, time::Duration}; +use std::{collections::HashMap, ffi::c_void, sync::Mutex, thread, time::Duration}; #[cfg(windows)] use std::{ collections::HashSet, @@ -14,7 +14,8 @@ use tauri::Emitter; #[cfg(feature = "mpv-embed")] use tauri::utils::config::Color; use tauri::{ - AppHandle, Manager, PhysicalPosition, PhysicalSize, Position, Size, State, WebviewWindow, + AppHandle, CursorIcon, Manager, PhysicalPosition, PhysicalSize, Position, Size, State, + WebviewWindow, }; #[cfg(feature = "mpv-embed")] use tauri::{WebviewUrl, WebviewWindowBuilder}; @@ -65,7 +66,8 @@ use mpv_embed::{ MpvEmbedSnapshot, MpvEmbedState, mpv_embed_add_subtitle, mpv_embed_frame_back_step, mpv_embed_frame_step, mpv_embed_pause, mpv_embed_play, mpv_embed_seek, mpv_embed_select_track, mpv_embed_set_hwdec, mpv_embed_set_loop_file, mpv_embed_set_speed, - mpv_embed_set_subtitle_delay, mpv_embed_set_volume, mpv_embed_snapshot, mpv_embed_stop, + mpv_embed_set_subtitle_delay, mpv_embed_set_video_fill, mpv_embed_set_volume, + mpv_embed_snapshot, mpv_embed_stop, }; use platform_support::{platform_support, prepare_platform_runtime}; use playback_store::{ @@ -117,6 +119,15 @@ static NATIVE_SHORTCUT_STATE: OnceLock = OnceLock::new(); static MPV_VIDEO_HOST_SYNC_PENDING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +const MIN_MAIN_WINDOW_WIDTH: i32 = 960; +const MIN_MAIN_WINDOW_HEIGHT: i32 = 540; + +#[cfg(all(feature = "mpv-embed", target_os = "macos"))] +unsafe extern "C" { + fn openplayer_macos_prepare_main_window(main_view: *mut c_void); + fn openplayer_macos_prepare_overlay_window(main_view: *mut c_void, overlay_view: *mut c_void); +} + #[tauri::command] fn window_update_shortcuts(bindings: HashMap>) -> Result<(), String> { #[cfg(windows)] @@ -374,32 +385,26 @@ fn window_toggle_fullscreen( window_state: State<'_, WindowState>, ) -> Result<(), String> { let main = main_window(&app)?; - let is_fullscreen = main.is_fullscreen().map_err(|error| error.to_string())?; + let mut fullscreen_restore = window_state + .fullscreen_restore + .lock() + .map_err(|_| "window state lock failed".to_string())?; + let has_restore_placement = fullscreen_restore.is_some(); + let is_fullscreen = + has_restore_placement || main.is_fullscreen().map_err(|error| error.to_string())?; if is_fullscreen { - let placement = window_state - .fullscreen_restore - .lock() - .map_err(|_| "window state lock failed".to_string())? - .clone(); - - if let Some(placement) = placement { + if let Some(placement) = fullscreen_restore.take() { + drop(fullscreen_restore); restore_window_after_fullscreen(&main, placement)?; - *window_state - .fullscreen_restore - .lock() - .map_err(|_| "window state lock failed".to_string())? = None; } else { - main.set_fullscreen(false) - .map_err(|error| error.to_string())?; + drop(fullscreen_restore); + set_main_window_fullscreen(&main, false)?; } } else { let placement = capture_window_placement(&main)?; - main.set_fullscreen(true) - .map_err(|error| error.to_string())?; - *window_state - .fullscreen_restore - .lock() - .map_err(|_| "window state lock failed".to_string())? = Some(placement); + set_main_window_fullscreen(&main, true)?; + *fullscreen_restore = Some(placement); + drop(fullscreen_restore); } schedule_overlay_sync_to_main(&app); @@ -417,6 +422,14 @@ fn window_close(app: AppHandle) -> Result<(), String> { } fn sync_overlay_to_main(app: &AppHandle) { + sync_overlay_to_main_with_focus(app, true); +} + +fn sync_overlay_to_main_without_focus(app: &AppHandle) { + sync_overlay_to_main_with_focus(app, false); +} + +fn sync_overlay_to_main_with_focus(app: &AppHandle, focus_overlay: bool) { let Ok(main) = main_window(app) else { return; }; @@ -437,7 +450,9 @@ fn sync_overlay_to_main(app: &AppHandle) { width: size.width, height: size.height, })); - focus_overlay_window(app); + if focus_overlay { + focus_overlay_window(app); + } } fn focus_overlay_window(app: &AppHandle) { @@ -507,13 +522,26 @@ fn capture_window_placement(window: &WebviewWindow) -> Result Result<(), String> { + prepare_macos_main_window_chrome(window); + window + .set_fullscreen(fullscreen) + .map_err(|error| error.to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn set_main_window_fullscreen(window: &WebviewWindow, fullscreen: bool) -> Result<(), String> { + window + .set_fullscreen(fullscreen) + .map_err(|error| error.to_string()) +} + fn restore_window_after_fullscreen( window: &WebviewWindow, placement: WindowPlacement, ) -> Result<(), String> { - window - .set_fullscreen(false) - .map_err(|error| error.to_string())?; + set_main_window_fullscreen(window, false)?; if placement.maximized { window.maximize().map_err(|error| error.to_string())?; @@ -542,7 +570,37 @@ fn set_overlay_owner(main: &WebviewWindow, overlay: &WebviewWindow) { } } +#[cfg(all(feature = "mpv-embed", target_os = "macos"))] +fn prepare_macos_main_window_chrome(main: &WebviewWindow) { + let Ok(main_view) = window_appkit_ns_view(main) else { + return; + }; + unsafe { + openplayer_macos_prepare_main_window(main_view as *mut c_void); + } +} + +#[cfg(any(not(feature = "mpv-embed"), not(target_os = "macos")))] +fn prepare_macos_main_window_chrome(_main: &WebviewWindow) {} + +#[cfg(all(feature = "mpv-embed", target_os = "macos"))] +fn set_overlay_owner(main: &WebviewWindow, overlay: &WebviewWindow) { + let Ok(main_view) = window_appkit_ns_view(main) else { + return; + }; + let Ok(overlay_view) = window_appkit_ns_view(overlay) else { + return; + }; + unsafe { + openplayer_macos_prepare_overlay_window( + main_view as *mut c_void, + overlay_view as *mut c_void, + ); + } +} + #[cfg(all(feature = "mpv-embed", not(windows)))] +#[cfg(not(target_os = "macos"))] fn set_overlay_owner(_main: &WebviewWindow, _overlay: &WebviewWindow) {} #[cfg(all(feature = "mpv-embed", windows))] @@ -556,6 +614,17 @@ fn window_hwnd(window: &impl HasWindowHandle) -> Result { } } +#[cfg(all(feature = "mpv-embed", target_os = "macos"))] +fn window_appkit_ns_view(window: &impl HasWindowHandle) -> Result { + let handle = window + .window_handle() + .map_err(|error| format!("failed to read Tauri window handle: {error}"))?; + match handle.as_raw() { + RawWindowHandle::AppKit(handle) => Ok(handle.ns_view.as_ptr() as usize), + _ => Err("window operation is only wired for macOS AppKit NSView targets".to_string()), + } +} + #[cfg(feature = "mpv-embed")] #[tauri::command] fn window_start_drag(app: AppHandle) -> Result<(), String> { @@ -566,7 +635,44 @@ fn window_start_drag(app: AppHandle) -> Result<(), String> { #[tauri::command] fn window_start_resize(app: AppHandle, direction: String) -> Result<(), String> { - let direction = match direction.as_str() { + let direction = resize_direction_from_str(&direction)?; + + main_window(&app)? + .as_ref() + .window() + .start_resize_dragging(direction) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +fn window_set_resize_cursor(app: AppHandle, direction: Option) -> Result<(), String> { + let icon = match direction.as_deref() { + Some("East") => CursorIcon::EResize, + Some("North") => CursorIcon::NResize, + Some("NorthEast") => CursorIcon::NeResize, + Some("NorthWest") => CursorIcon::NwResize, + Some("South") => CursorIcon::SResize, + Some("SouthEast") => CursorIcon::SeResize, + Some("SouthWest") => CursorIcon::SwResize, + Some("West") => CursorIcon::WResize, + Some("Default") | None => CursorIcon::Default, + Some(direction) => return Err(format!("invalid resize cursor direction: {direction}")), + }; + + main_window(&app)? + .set_cursor_icon(icon) + .map_err(|error| error.to_string())?; + if let Some(overlay) = overlay_window(&app) { + overlay + .set_cursor_icon(icon) + .map_err(|error| error.to_string())?; + } + + Ok(()) +} + +fn resize_direction_from_str(direction: &str) -> Result { + Ok(match direction { "East" => ResizeDirection::East, "North" => ResizeDirection::North, "NorthEast" => ResizeDirection::NorthEast, @@ -576,13 +682,103 @@ fn window_start_resize(app: AppHandle, direction: String) -> Result<(), String> "SouthWest" => ResizeDirection::SouthWest, "West" => ResizeDirection::West, _ => return Err(format!("invalid resize direction: {direction}")), - }; + }) +} - main_window(&app)? - .as_ref() - .window() - .start_resize_dragging(direction) - .map_err(|error| error.to_string()) +#[tauri::command] +fn window_apply_resize_delta( + app: AppHandle, + direction: String, + delta_x: f64, + delta_y: f64, +) -> Result<(), String> { + if !delta_x.is_finite() || !delta_y.is_finite() { + return Err("invalid resize delta".to_string()); + } + + let direction = resize_direction_from_str(&direction)?; + let main = main_window(&app)?; + if main.is_fullscreen().map_err(|error| error.to_string())? + || main.is_maximized().map_err(|error| error.to_string())? + { + return Ok(()); + } + + let position = main.outer_position().map_err(|error| error.to_string())?; + let size = main.outer_size().map_err(|error| error.to_string())?; + let old_width = size.width as i32; + let old_height = size.height as i32; + let dx = delta_x.round() as i32; + let dy = delta_y.round() as i32; + let mut x = position.x; + let mut y = position.y; + let mut width = old_width; + let mut height = old_height; + + if resize_direction_has_west_edge(direction) { + x += dx; + width -= dx; + } + if resize_direction_has_east_edge(direction) { + width += dx; + } + if resize_direction_has_north_edge(direction) { + y += dy; + height -= dy; + } + if resize_direction_has_south_edge(direction) { + height += dy; + } + + if width < MIN_MAIN_WINDOW_WIDTH { + if resize_direction_has_west_edge(direction) { + x -= MIN_MAIN_WINDOW_WIDTH - width; + } + width = MIN_MAIN_WINDOW_WIDTH; + } + if height < MIN_MAIN_WINDOW_HEIGHT { + if resize_direction_has_north_edge(direction) { + y -= MIN_MAIN_WINDOW_HEIGHT - height; + } + height = MIN_MAIN_WINDOW_HEIGHT; + } + + main.set_position(Position::Physical(PhysicalPosition { x, y })) + .map_err(|error| error.to_string())?; + main.set_size(Size::Physical(PhysicalSize { + width: width as u32, + height: height as u32, + })) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn resize_direction_has_west_edge(direction: ResizeDirection) -> bool { + matches!( + direction, + ResizeDirection::West | ResizeDirection::NorthWest | ResizeDirection::SouthWest + ) +} + +fn resize_direction_has_east_edge(direction: ResizeDirection) -> bool { + matches!( + direction, + ResizeDirection::East | ResizeDirection::NorthEast | ResizeDirection::SouthEast + ) +} + +fn resize_direction_has_north_edge(direction: ResizeDirection) -> bool { + matches!( + direction, + ResizeDirection::North | ResizeDirection::NorthEast | ResizeDirection::NorthWest + ) +} + +fn resize_direction_has_south_edge(direction: ResizeDirection) -> bool { + matches!( + direction, + ResizeDirection::South | ResizeDirection::SouthEast | ResizeDirection::SouthWest + ) } #[cfg(feature = "mpv-embed")] @@ -592,8 +788,50 @@ fn mpv_overlay_open_path( state: tauri::State<'_, MpvEmbedState>, path: String, ) -> Result { - let main = main_window(&app)?; - sync_overlay_to_main(&app); + #[cfg(target_os = "macos")] + { + let _ = state; + return open_path_for_main_window_on_main_thread(app, path); + } + + #[cfg(not(target_os = "macos"))] + { + let main = main_window(&app)?; + sync_overlay_to_main(&app); + mpv_embed::open_path_for_window(&main, state.inner(), path) + } +} + +#[cfg(all(feature = "mpv-embed", target_os = "macos"))] +fn open_path_for_main_window_on_main_thread( + app: AppHandle, + path: String, +) -> Result { + if objc2::MainThreadMarker::new().is_some() { + return open_path_for_main_window_now(&app, path); + } + + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let app_for_open = app.clone(); + app.run_on_main_thread(move || { + let result = open_path_for_main_window_now(&app_for_open, path); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to schedule macOS mpv AppKit host setup: {error}"))?; + + receiver + .recv() + .map_err(|_| "macOS mpv AppKit host setup did not return a result".to_string())? +} + +#[cfg(all(feature = "mpv-embed", target_os = "macos"))] +fn open_path_for_main_window_now( + app: &AppHandle, + path: String, +) -> Result { + let main = main_window(app)?; + sync_overlay_to_main(app); + let state = app.state::(); mpv_embed::open_path_for_window(&main, state.inner(), path) } @@ -617,6 +855,8 @@ pub fn run() { window_toggle_fullscreen, window_focus_overlay, window_start_resize, + window_set_resize_cursor, + window_apply_resize_delta, window_close, media_files_in_directory, startup_media_paths, @@ -656,6 +896,7 @@ pub fn run() { app.manage(PlaybackStoreState::open(app.handle())); install_native_shortcut_hook(app.handle().clone()); if let Some(window) = app.get_webview_window("main") { + prepare_macos_main_window_chrome(&window); let overlay = WebviewWindowBuilder::new( app, "overlay", @@ -677,6 +918,7 @@ pub fn run() { let app_handle = app.handle().clone(); sync_overlay_to_main(&app_handle); let _ = overlay.show(); + set_overlay_owner(&window, &overlay); window.on_window_event(move |event| { if matches!( event, @@ -684,7 +926,7 @@ pub fn run() { | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } ) { - sync_overlay_to_main(&app_handle); + sync_overlay_to_main_without_focus(&app_handle); sync_mpv_video_host(&app_handle); schedule_mpv_video_host_sync(&app_handle); } @@ -706,6 +948,8 @@ pub fn run() { window_focus_overlay, window_start_drag, window_start_resize, + window_set_resize_cursor, + window_apply_resize_delta, mpv_overlay_open_path, mpv_embed_play, mpv_embed_pause, @@ -715,6 +959,7 @@ pub fn run() { mpv_embed_set_hwdec, mpv_embed_set_loop_file, mpv_embed_set_speed, + mpv_embed_set_video_fill, mpv_embed_set_subtitle_delay, mpv_embed_select_track, mpv_embed_add_subtitle, diff --git a/apps/desktop/src-tauri/src/macos_mpv_gl_view.m b/apps/desktop/src-tauri/src/macos_mpv_gl_view.m new file mode 100644 index 0000000..57b26d4 --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_mpv_gl_view.m @@ -0,0 +1,256 @@ +#import +#import +#import +#import +#import +#import + +@interface OpenPlayerMpvGLView : NSOpenGLView +@property(nonatomic, assign) mpv_render_context *mpvContext; +@property(atomic, assign) BOOL renderScheduled; +@end + +@implementation OpenPlayerMpvGLView + +- (instancetype)initWithFrame:(NSRect)frame { + NSOpenGLPixelFormatAttribute attributes[] = { + NSOpenGLPFAAccelerated, + NSOpenGLPFADoubleBuffer, + NSOpenGLPFAColorSize, + 24, + NSOpenGLPFAAlphaSize, + 8, + 0, + }; + NSOpenGLPixelFormat *format = + [[NSOpenGLPixelFormat alloc] initWithAttributes:attributes]; + self = [super initWithFrame:frame pixelFormat:format]; + if (self) { + self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.wantsBestResolutionOpenGLSurface = YES; + self.mpvContext = NULL; + self.renderScheduled = NO; + + [[self openGLContext] makeCurrentContext]; + GLint swapInterval = 1; + [[self openGLContext] setValues:&swapInterval + forParameter:NSOpenGLCPSwapInterval]; + } + return self; +} + +- (void)prepareOpenGL { + [super prepareOpenGL]; + [[self openGLContext] makeCurrentContext]; + glClearColor(0.0, 0.0, 0.0, 1.0); +} + +- (void)reshape { + [super reshape]; + [self setNeedsDisplay:YES]; +} + +- (void)drawRect:(NSRect)dirtyRect { + (void)dirtyRect; + [[self openGLContext] makeCurrentContext]; + + NSRect backingBounds = [self convertRectToBacking:self.bounds]; + int width = (int)backingBounds.size.width; + int height = (int)backingBounds.size.height; + if (width < 1) { + width = 1; + } + if (height < 1) { + height = 1; + } + + glViewport(0, 0, width, height); + if (self.mpvContext) { + mpv_opengl_fbo fbo = { + .fbo = 0, + .w = width, + .h = height, + .internal_format = 0, + }; + int flipY = 1; + mpv_render_param params[] = { + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flipY}, + {0, NULL}, + }; + mpv_render_context_render(self.mpvContext, params); + mpv_render_context_report_swap(self.mpvContext); + } else { + glClear(GL_COLOR_BUFFER_BIT); + } + + [[self openGLContext] flushBuffer]; +} + +@end + +static void run_on_main_sync(dispatch_block_t block) { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_sync(dispatch_get_main_queue(), block); + } +} + +void *openplayer_mpv_gl_view_create(void *parent_ptr) { + if (!parent_ptr) { + return NULL; + } + + __block OpenPlayerMpvGLView *created = nil; + run_on_main_sync(^{ + NSView *parent = (__bridge NSView *)parent_ptr; + OpenPlayerMpvGLView *view = + [[OpenPlayerMpvGLView alloc] initWithFrame:parent.bounds]; + [parent addSubview:view]; + created = view; + }); + + return created ? (void *)CFBridgingRetain(created) : NULL; +} + +void openplayer_mpv_gl_view_remove(void *view_ptr) { + if (!view_ptr) { + return; + } + + run_on_main_sync(^{ + OpenPlayerMpvGLView *view = CFBridgingRelease((CFTypeRef)view_ptr); + view.mpvContext = NULL; + [view removeFromSuperview]; + [view clearGLContext]; + }); +} + +void openplayer_mpv_gl_view_resize(void *view_ptr) { + if (!view_ptr) { + return; + } + + run_on_main_sync(^{ + OpenPlayerMpvGLView *view = (__bridge OpenPlayerMpvGLView *)view_ptr; + if (view.superview) { + view.frame = view.superview.bounds; + } + [view setNeedsDisplay:YES]; + }); +} + +void openplayer_mpv_gl_view_set_render_context(void *view_ptr, void *render_context) { + if (!view_ptr) { + return; + } + + run_on_main_sync(^{ + OpenPlayerMpvGLView *view = (__bridge OpenPlayerMpvGLView *)view_ptr; + view.mpvContext = (mpv_render_context *)render_context; + [view setNeedsDisplay:YES]; + }); +} + +void openplayer_mpv_gl_view_make_current(void *view_ptr) { + if (!view_ptr) { + return; + } + + run_on_main_sync(^{ + OpenPlayerMpvGLView *view = (__bridge OpenPlayerMpvGLView *)view_ptr; + [[view openGLContext] makeCurrentContext]; + }); +} + +void openplayer_mpv_gl_view_draw(void *view_ptr) { + if (!view_ptr) { + return; + } + + OpenPlayerMpvGLView *view = (__bridge OpenPlayerMpvGLView *)view_ptr; + if (view.renderScheduled) { + return; + } + + view.renderScheduled = YES; + id retainedView = CFBridgingRelease(CFRetain((__bridge CFTypeRef)view)); + dispatch_async(dispatch_get_main_queue(), ^{ + OpenPlayerMpvGLView *strongView = retainedView; + strongView.renderScheduled = NO; + if (strongView.window) { + [strongView setNeedsDisplay:YES]; + } + }); +} + +void *openplayer_mpv_gl_get_proc_address(const char *name) { + if (!name) { + return NULL; + } + + CFStringRef symbolName = + CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingASCII); + if (!symbolName) { + return NULL; + } + + CFBundleRef bundle = + CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl")); + void *address = bundle ? CFBundleGetFunctionPointerForName(bundle, symbolName) : NULL; + CFRelease(symbolName); + return address; +} + +void openplayer_macos_prepare_main_window(void *main_view_ptr) { + if (!main_view_ptr) { + return; + } + + run_on_main_sync(^{ + NSView *mainView = (__bridge NSView *)main_view_ptr; + NSWindow *mainWindow = mainView.window; + if (!mainWindow) { + return; + } + + mainWindow.titleVisibility = NSWindowTitleHidden; + mainWindow.titlebarAppearsTransparent = YES; + mainWindow.styleMask = mainWindow.styleMask | NSWindowStyleMaskFullSizeContentView; + + NSArray *buttonTypes = @[ + @(NSWindowCloseButton), + @(NSWindowMiniaturizeButton), + @(NSWindowZoomButton), + ]; + for (NSNumber *buttonType in buttonTypes) { + NSButton *button = [mainWindow standardWindowButton:buttonType.integerValue]; + button.hidden = YES; + button.enabled = NO; + } + }); +} + +void openplayer_macos_prepare_overlay_window(void *main_view_ptr, void *overlay_view_ptr) { + if (!main_view_ptr || !overlay_view_ptr) { + return; + } + + run_on_main_sync(^{ + NSView *mainView = (__bridge NSView *)main_view_ptr; + NSView *overlayView = (__bridge NSView *)overlay_view_ptr; + NSWindow *mainWindow = mainView.window; + NSWindow *overlayWindow = overlayView.window; + if (!mainWindow || !overlayWindow) { + return; + } + + overlayWindow.collectionBehavior = + overlayWindow.collectionBehavior | NSWindowCollectionBehaviorFullScreenAuxiliary; + if (overlayWindow.parentWindow != mainWindow) { + [overlayWindow.parentWindow removeChildWindow:overlayWindow]; + [mainWindow addChildWindow:overlayWindow ordered:NSWindowAbove]; + } + }); +} diff --git a/apps/desktop/src-tauri/src/mpv_embed.rs b/apps/desktop/src-tauri/src/mpv_embed.rs index 744fbd3..08d2ebc 100644 --- a/apps/desktop/src-tauri/src/mpv_embed.rs +++ b/apps/desktop/src-tauri/src/mpv_embed.rs @@ -1,16 +1,19 @@ use std::{ - ffi::CString, + ffi::{CStr, CString, c_char, c_void}, fs, path::{Path, PathBuf}, + ptr, sync::Mutex, thread, time::{Duration, Instant}, }; use libmpv2::{events::Event, mpv_end_file_reason}; +#[cfg(target_os = "macos")] +use objc2::MainThreadMarker; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use serde::Serialize; -use tauri::{State, Window}; +use tauri::{AppHandle, Manager, State, Window}; #[cfg(windows)] use windows_sys::Win32::{ Foundation::{HWND, RECT}, @@ -63,10 +66,13 @@ pub struct MpvEmbedState { } struct MpvEmbedPlayer { + #[cfg(target_os = "macos")] + _render_context: MacosMpvRenderContext, mpv: libmpv2::Mpv, host: MpvVideoHost, path: String, volume: f64, + video_fill: bool, ended: bool, force_paused_until: Option, } @@ -77,7 +83,29 @@ struct MpvVideoHost { hwnd: isize, } -#[cfg(not(windows))] +#[cfg(target_os = "macos")] +struct MpvVideoHost { + render_view: usize, +} + +#[cfg(target_os = "macos")] +struct MacosMpvRenderContext { + ctx: usize, + view: usize, +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn openplayer_mpv_gl_view_create(parent: *mut c_void) -> *mut c_void; + fn openplayer_mpv_gl_view_remove(view: *mut c_void); + fn openplayer_mpv_gl_view_resize(view: *mut c_void); + fn openplayer_mpv_gl_view_set_render_context(view: *mut c_void, render_context: *mut c_void); + fn openplayer_mpv_gl_view_make_current(view: *mut c_void); + fn openplayer_mpv_gl_view_draw(view: *mut c_void); + fn openplayer_mpv_gl_get_proc_address(name: *const c_char) -> *mut c_void; +} + +#[cfg(all(not(windows), not(target_os = "macos")))] struct MpvVideoHost { wid: i64, } @@ -112,6 +140,7 @@ pub struct MpvEmbedSnapshot { fps: f64, speed: f64, hwdec: String, + video_fill: bool, subtitle_delay: f64, volume: f64, tracks: Vec, @@ -148,6 +177,8 @@ pub fn open_path_for_window( let host = MpvVideoHost::new(window)?; let wid = host.wid(); let mpv = create_embed_player(wid)?; + #[cfg(target_os = "macos")] + let render_context = create_macos_render_context(&mpv, &host)?; let path_text = path.to_string_lossy().to_string(); configure_audio_visualizer(&mpv, &path); @@ -160,10 +191,13 @@ pub fn open_path_for_window( .lock() .map_err(|_| "mpv embed state lock failed".to_string())?; let mut next_player = MpvEmbedPlayer { + #[cfg(target_os = "macos")] + _render_context: render_context, mpv, host, path: path_text, volume: 82.0, + video_fill: false, ended: false, force_paused_until: None, }; @@ -174,150 +208,175 @@ pub fn open_path_for_window( } #[tauri::command] -pub fn mpv_embed_play(state: State<'_, MpvEmbedState>) -> Result { - with_player(&state, |player| { - player.force_paused_until = None; - player.ended = false; - player - .mpv - .set_property("pause", false) - .map_err(|error| format!("mpv play failed: {error}"))?; - Ok(player.snapshot(0, "playing")) +pub async fn mpv_embed_play(app: AppHandle) -> Result { + run_mpv_command(app, |state| { + with_player(state, |player| { + player.force_paused_until = None; + player.ended = false; + player + .mpv + .set_property("pause", false) + .map_err(|error| format!("mpv play failed: {error}"))?; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_pause(state: State<'_, MpvEmbedState>) -> Result { - with_player(&state, |player| { - player.force_paused_until = None; - player - .mpv - .set_property("pause", true) - .map_err(|error| format!("mpv pause failed: {error}"))?; - Ok(player.snapshot(0, "paused")) +pub async fn mpv_embed_pause(app: AppHandle) -> Result { + run_mpv_command(app, |state| { + with_player(state, |player| { + player.force_paused_until = None; + player + .mpv + .set_property("pause", true) + .map_err(|error| format!("mpv pause failed: {error}"))?; + Ok(player.snapshot(0, "paused")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_seek( - state: State<'_, MpvEmbedState>, - position: f64, -) -> Result { +pub async fn mpv_embed_seek(app: AppHandle, position: f64) -> Result { if !position.is_finite() || position < 0.0 { return Err("invalid mpv seek target".to_string()); } - with_player(&state, |player| { - player.force_paused_until = None; - player.ended = false; - player - .mpv - .command("seek", &[&position.to_string(), "absolute"]) - .map_err(|error| format!("mpv seek failed: {error}"))?; - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + player.force_paused_until = None; + player.ended = false; + player + .mpv + .command("seek", &[&position.to_string(), "absolute"]) + .map_err(|error| format!("mpv seek failed: {error}"))?; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_frame_step(state: State<'_, MpvEmbedState>) -> Result { - frame_step(&state, "frame-step") +pub async fn mpv_embed_frame_step(app: AppHandle) -> Result { + run_mpv_command(app, |state| frame_step(state, "frame-step")).await } #[tauri::command] -pub fn mpv_embed_frame_back_step( - state: State<'_, MpvEmbedState>, -) -> Result { - frame_step(&state, "frame-back-step") +pub async fn mpv_embed_frame_back_step(app: AppHandle) -> Result { + run_mpv_command(app, |state| frame_step(state, "frame-back-step")).await } #[tauri::command] -pub fn mpv_embed_set_volume( - state: State<'_, MpvEmbedState>, - volume: f64, -) -> Result { +pub async fn mpv_embed_set_volume(app: AppHandle, volume: f64) -> Result { if !volume.is_finite() { return Err("invalid mpv volume".to_string()); } let volume = volume.clamp(0.0, 100.0); - with_player(&state, |player| { - player - .mpv - .set_property("volume", volume) - .map_err(|error| format!("mpv volume failed: {error}"))?; - player.volume = volume; - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + player + .mpv + .set_property("volume", volume) + .map_err(|error| format!("mpv volume failed: {error}"))?; + player.volume = volume; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_set_speed( - state: State<'_, MpvEmbedState>, - speed: f64, -) -> Result { +pub async fn mpv_embed_set_speed(app: AppHandle, speed: f64) -> Result { let speed = normalize_playback_speed(speed)?; - with_player(&state, |player| { - player - .mpv - .set_property("speed", speed) - .map_err(|error| format!("mpv speed failed: {error}"))?; - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + player + .mpv + .set_property("speed", speed) + .map_err(|error| format!("mpv speed failed: {error}"))?; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_set_hwdec( - state: State<'_, MpvEmbedState>, - mode: String, -) -> Result { +pub async fn mpv_embed_set_hwdec(app: AppHandle, mode: String) -> Result { let hwdec = normalize_hwdec_mode(&mode)?; - with_player(&state, |player| { - player - .mpv - .set_property("hwdec", hwdec) - .map_err(|error| format!("mpv hardware decoding switch failed: {error}"))?; - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + player + .mpv + .set_property("hwdec", hwdec) + .map_err(|error| format!("mpv hardware decoding switch failed: {error}"))?; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_set_loop_file( - state: State<'_, MpvEmbedState>, +pub async fn mpv_embed_set_video_fill( + app: AppHandle, enabled: bool, ) -> Result { - with_player(&state, |player| { - player - .mpv - .set_property("loop-file", if enabled { "inf" } else { "no" }) - .map_err(|error| format!("mpv loop-file mode failed: {error}"))?; - if enabled { - player.ended = false; - } - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + set_video_fill_mode(&player.mpv, enabled)?; + player.video_fill = enabled; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_set_subtitle_delay( - state: State<'_, MpvEmbedState>, +pub async fn mpv_embed_set_loop_file( + app: AppHandle, + enabled: bool, +) -> Result { + run_mpv_command(app, move |state| { + with_player(state, |player| { + player + .mpv + .set_property("loop-file", if enabled { "inf" } else { "no" }) + .map_err(|error| format!("mpv loop-file mode failed: {error}"))?; + if enabled { + player.ended = false; + } + Ok(player.snapshot(0, "playing")) + }) + }) + .await +} + +#[tauri::command] +pub async fn mpv_embed_set_subtitle_delay( + app: AppHandle, delay: f64, ) -> Result { let delay = normalize_subtitle_delay(delay)?; - with_player(&state, |player| { - player - .mpv - .set_property("sub-delay", delay) - .map_err(|error| format!("mpv subtitle delay failed: {error}"))?; - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + player + .mpv + .set_property("sub-delay", delay) + .map_err(|error| format!("mpv subtitle delay failed: {error}"))?; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_select_track( - state: State<'_, MpvEmbedState>, +pub async fn mpv_embed_select_track( + app: AppHandle, kind: String, track_id: Option, ) -> Result { @@ -326,49 +385,56 @@ pub fn mpv_embed_select_track( return Err("invalid mpv track id".to_string()); } - with_player(&state, |player| { - if let Some(id) = track_id { - player - .mpv - .set_property(property, id) - .map_err(|error| format!("mpv track selection failed: {error}"))?; - } else { - player - .mpv - .set_property(property, "no") - .map_err(|error| format!("mpv track disable failed: {error}"))?; - } - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + if let Some(id) = track_id { + player + .mpv + .set_property(property, id) + .map_err(|error| format!("mpv track selection failed: {error}"))?; + } else { + player + .mpv + .set_property(property, "no") + .map_err(|error| format!("mpv track disable failed: {error}"))?; + } + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_add_subtitle( - state: State<'_, MpvEmbedState>, +pub async fn mpv_embed_add_subtitle( + app: AppHandle, path: String, ) -> Result { let path = validate_subtitle_path(&path)?; let path_text = path.to_string_lossy().to_string(); - with_player(&state, |player| { - player - .mpv - .command("sub-add", &[&path_text, "select"]) - .map_err(|error| format!("mpv subtitle load failed: {error}"))?; - Ok(player.snapshot(0, "playing")) + run_mpv_command(app, move |state| { + with_player(state, |player| { + player + .mpv + .command("sub-add", &[&path_text, "select"]) + .map_err(|error| format!("mpv subtitle load failed: {error}"))?; + Ok(player.snapshot(0, "playing")) + }) }) + .await } #[tauri::command] -pub fn mpv_embed_snapshot( - state: State<'_, MpvEmbedState>, -) -> Result, String> { - let mut player = state - .player - .lock() - .map_err(|_| "mpv embed state lock failed".to_string())?; +pub async fn mpv_embed_snapshot(app: AppHandle) -> Result, String> { + run_mpv_command(app, |state| { + let mut player = state + .player + .lock() + .map_err(|_| "mpv embed state lock failed".to_string())?; - Ok(player.as_mut().map(|player| player.snapshot(0, "playing"))) + Ok(player.as_mut().map(|player| player.snapshot(0, "playing"))) + }) + .await } fn with_player( @@ -386,6 +452,21 @@ fn with_player( callback(player) } +async fn run_mpv_command( + app: AppHandle, + callback: impl FnOnce(&MpvEmbedState) -> Result + Send + 'static, +) -> Result +where + T: Send + 'static, +{ + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + callback(state.inner()) + }) + .await + .map_err(|error| format!("mpv command task failed: {error}"))? +} + fn frame_step(state: &MpvEmbedState, command: &str) -> Result { with_player(state, |player| { player @@ -490,6 +571,12 @@ fn normalize_subtitle_delay(delay: f64) -> Result { Ok(delay.clamp(MIN_SUBTITLE_DELAY, MAX_SUBTITLE_DELAY)) } +fn set_video_fill_mode(mpv: &libmpv2::Mpv, enabled: bool) -> Result<(), String> { + let panscan = if enabled { 1.0 } else { 0.0 }; + mpv.set_property("panscan", panscan) + .map_err(|error| format!("mpv video layout failed: {error}")) +} + fn normalize_hwdec_mode(mode: &str) -> Result<&'static str, String> { match mode.trim().to_ascii_lowercase().as_str() { "hardware" | "auto" | "auto-safe" => Ok("auto-safe"), @@ -509,7 +596,6 @@ fn track_property_for_kind(kind: &str) -> Result<&'static str, String> { impl MpvEmbedPlayer { fn snapshot(&mut self, hwnd: i64, fallback_status: &str) -> MpvEmbedSnapshot { - let _ = self.host.resize(); self.drain_events(); let raw_paused = self.mpv.get_property::("pause").unwrap_or(false); let pause_guard_active = self @@ -564,6 +650,7 @@ impl MpvEmbedPlayer { fps, speed, hwdec, + video_fill: self.video_fill, subtitle_delay: if subtitle_delay.is_finite() { subtitle_delay } else { @@ -614,7 +701,34 @@ impl MpvEmbedState { } #[tauri::command] -pub fn mpv_embed_stop(state: State<'_, MpvEmbedState>) -> Result<(), String> { +pub fn mpv_embed_stop(window: Window, state: State<'_, MpvEmbedState>) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + if MainThreadMarker::new().is_none() { + let app = window.app_handle().clone(); + let app_for_stop = app.clone(); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || { + let state = app_for_stop.state::(); + let _ = sender.send(stop_player(state.inner())); + }) + .map_err(|error| { + format!("failed to schedule macOS mpv AppKit host teardown: {error}") + })?; + + return receiver.recv().map_err(|_| { + "macOS mpv AppKit host teardown did not return a result".to_string() + })?; + } + } + + #[cfg(not(target_os = "macos"))] + let _ = window; + + stop_player(state.inner()) +} + +fn stop_player(state: &MpvEmbedState) -> Result<(), String> { let mut player = state .player .lock() @@ -636,8 +750,13 @@ fn create_embed_player(hwnd: i64) -> Result { log_selected_mpv_video_output_config(&video_output_config); let mpv = libmpv2::Mpv::with_initializer(|initializer| { + #[cfg(not(target_os = "macos"))] initializer.set_option("wid", hwnd)?; + #[cfg(target_os = "macos")] + let _ = hwnd; configure_native_video_output(&initializer, &video_output_config)?; + #[cfg(target_os = "macos")] + initializer.set_option("video-timing-offset", "0")?; initializer.set_option("input-default-bindings", false)?; initializer.set_option("input-vo-keyboard", false)?; initializer.set_option("keep-open", true)?; @@ -652,6 +771,102 @@ fn create_embed_player(hwnd: i64) -> Result { Ok(mpv) } +#[cfg(target_os = "macos")] +fn create_macos_render_context( + mpv: &libmpv2::Mpv, + host: &MpvVideoHost, +) -> Result { + unsafe { + openplayer_mpv_gl_view_make_current(host.render_view_ptr()); + } + + let mut init_params = libmpv2_sys::mpv_opengl_init_params { + get_proc_address: Some(macos_mpv_get_proc_address), + get_proc_address_ctx: ptr::null_mut(), + }; + let mut render_params = [ + libmpv2_sys::mpv_render_param { + type_: libmpv2_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE, + data: libmpv2_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut c_void, + }, + libmpv2_sys::mpv_render_param { + type_: libmpv2_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, + data: (&mut init_params as *mut libmpv2_sys::mpv_opengl_init_params).cast(), + }, + libmpv2_sys::mpv_render_param { + type_: 0, + data: ptr::null_mut(), + }, + ]; + let mut context: *mut libmpv2_sys::mpv_render_context = ptr::null_mut(); + let result = unsafe { + libmpv2_sys::mpv_render_context_create( + &mut context, + mpv.ctx.as_ptr(), + render_params.as_mut_ptr(), + ) + }; + if result < 0 { + return Err(format!( + "mpv render context init failed: {}", + mpv_error_message(result) + )); + } + + unsafe { + openplayer_mpv_gl_view_set_render_context(host.render_view_ptr(), context.cast()); + libmpv2_sys::mpv_render_context_set_update_callback( + context, + Some(macos_mpv_render_update), + host.render_view_ptr(), + ); + } + + Ok(MacosMpvRenderContext { + ctx: context as usize, + view: host.render_view, + }) +} + +#[cfg(target_os = "macos")] +impl Drop for MacosMpvRenderContext { + fn drop(&mut self) { + let context = self.ctx as *mut libmpv2_sys::mpv_render_context; + unsafe { + libmpv2_sys::mpv_render_context_set_update_callback(context, None, ptr::null_mut()); + openplayer_mpv_gl_view_set_render_context(self.view as *mut c_void, ptr::null_mut()); + libmpv2_sys::mpv_render_context_free(context); + } + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" fn macos_mpv_get_proc_address( + _ctx: *mut c_void, + name: *const c_char, +) -> *mut c_void { + unsafe { openplayer_mpv_gl_get_proc_address(name) } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" fn macos_mpv_render_update(ctx: *mut c_void) { + unsafe { + openplayer_mpv_gl_view_draw(ctx); + } +} + +#[cfg(target_os = "macos")] +fn mpv_error_message(code: i32) -> String { + let message = unsafe { libmpv2_sys::mpv_error_string(code) }; + if message.is_null() { + return code.to_string(); + } + + unsafe { CStr::from_ptr(message) } + .to_string_lossy() + .into_owned() +} + fn configure_native_video_output( initializer: &libmpv2::MpvInitializer, config: &MpvVideoOutputConfig, @@ -739,7 +954,16 @@ fn platform_video_output_config() -> MpvVideoOutputConfig { }) } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "macos")] +fn platform_video_output_config() -> MpvVideoOutputConfig { + MpvVideoOutputConfig { + vo: Some("libmpv".to_string()), + gpu_context: None, + hwdec: "auto-safe".to_string(), + } +} + +#[cfg(all(not(target_os = "linux"), not(target_os = "macos")))] fn platform_video_output_config() -> MpvVideoOutputConfig { MpvVideoOutputConfig { vo: None, @@ -930,7 +1154,23 @@ impl MpvVideoHost { }) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + fn new(window: &impl HasWindowHandle) -> Result { + let parent_ns_view = window_appkit_ns_view(window)?; + let Some(_mtm) = MainThreadMarker::new() else { + return Err("macOS mpv video host must be created on the main thread".to_string()); + }; + + let render_view = + unsafe { openplayer_mpv_gl_view_create(parent_ns_view as *mut c_void) } as usize; + if render_view == 0 { + return Err("failed to create macOS mpv OpenGL render view".to_string()); + } + + Ok(Self { render_view }) + } + + #[cfg(all(not(windows), not(target_os = "macos")))] fn new(window: &impl HasWindowHandle) -> Result { Ok(Self { wid: window_mpv_wid(window)?, @@ -942,7 +1182,12 @@ impl MpvVideoHost { self.hwnd as i64 } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + fn wid(&self) -> i64 { + self.render_view as i64 + } + + #[cfg(all(not(windows), not(target_os = "macos")))] fn wid(&self) -> i64 { self.wid } @@ -959,7 +1204,20 @@ impl MpvVideoHost { position_video_host(self.hwnd as HWND, layout) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + fn resize(&self) -> Result<(), String> { + unsafe { + openplayer_mpv_gl_view_resize(self.render_view_ptr()); + } + Ok(()) + } + + #[cfg(target_os = "macos")] + fn render_view_ptr(&self) -> *mut c_void { + self.render_view as *mut c_void + } + + #[cfg(all(not(windows), not(target_os = "macos")))] fn resize(&self) -> Result<(), String> { Ok(()) } @@ -994,6 +1252,15 @@ impl Drop for MpvVideoHost { } } +#[cfg(target_os = "macos")] +impl Drop for MpvVideoHost { + fn drop(&mut self) { + unsafe { + openplayer_mpv_gl_view_remove(self.render_view_ptr()); + } + } +} + fn validate_media_path(path: &str) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { @@ -1111,6 +1378,7 @@ fn load_sidecar_subtitles(mpv: &libmpv2::Mpv, media_path: &Path) { } } +#[cfg_attr(target_os = "macos", allow(dead_code))] fn window_mpv_wid(window: &impl HasWindowHandle) -> Result { let handle = window .window_handle() @@ -1119,6 +1387,19 @@ fn window_mpv_wid(window: &impl HasWindowHandle) -> Result { mpv_wid_from_raw_window_handle(handle.as_raw()) } +#[cfg(target_os = "macos")] +fn window_appkit_ns_view(window: &impl HasWindowHandle) -> Result { + let handle = window + .window_handle() + .map_err(|error| format!("failed to read Tauri window handle: {error}"))?; + + match handle.as_raw() { + RawWindowHandle::AppKit(handle) => Ok(handle.ns_view.as_ptr() as usize), + _ => Err("window operation is only wired for macOS AppKit NSView targets".to_string()), + } +} + +#[cfg_attr(target_os = "macos", allow(dead_code))] fn mpv_wid_from_raw_window_handle(handle: RawWindowHandle) -> Result { match handle { RawWindowHandle::Win32(handle) => Ok(handle.hwnd.get() as i64), @@ -1128,12 +1409,9 @@ fn mpv_wid_from_raw_window_handle(handle: RawWindowHandle) -> Result Err( - "mpv embed playback currently supports Windows HWND and X11 window hosts; macOS AppKit video host support is not implemented yet" - .to_string(), - ), + RawWindowHandle::AppKit(handle) => Ok(handle.ns_view.as_ptr() as isize as i64), _ => Err(format!( - "mpv embed playback currently supports Windows HWND and X11 window hosts; {} video host support is not implemented yet", + "mpv embed playback currently supports Windows HWND, X11 window, and macOS AppKit NSView hosts; {} video host support is not implemented yet", std::env::consts::OS )), } @@ -1145,6 +1423,7 @@ fn xlib_window_to_mpv_wid(window: core::ffi::c_ulong) -> Result { } #[cfg(not(windows))] +#[cfg_attr(target_os = "macos", allow(dead_code))] fn xlib_window_to_mpv_wid(window: core::ffi::c_ulong) -> Result { if window > i64::MAX as core::ffi::c_ulong { Err("Xlib window id is too large for mpv wid".to_string()) @@ -1429,6 +1708,21 @@ mod tests { )); } + #[test] + #[cfg(target_os = "macos")] + fn macos_video_output_uses_libmpv_render_api_vo() { + let config = platform_video_output_config(); + + assert_eq!( + config, + MpvVideoOutputConfig { + vo: Some("libmpv".to_string()), + gpu_context: None, + hwdec: "auto-safe".to_string(), + } + ); + } + #[test] fn maps_x11_window_handles_to_mpv_wid_values() { let xlib = RawWindowHandle::Xlib(raw_window_handle::XlibWindowHandle::new(42)); diff --git a/apps/desktop/src-tauri/src/platform_support.rs b/apps/desktop/src-tauri/src/platform_support.rs index 2ec440e..11c1ccb 100644 --- a/apps/desktop/src-tauri/src/platform_support.rs +++ b/apps/desktop/src-tauri/src/platform_support.rs @@ -59,7 +59,7 @@ fn native_video_support_for_environment( match environment.os { "windows" => ("win32", true), "linux" => linux_video_support(environment), - "macos" => ("appkit", false), + "macos" => ("appkit", true), _ => ("unknown", false), } } @@ -211,4 +211,18 @@ mod tests { assert!(!should_default_linux_gdk_backend_to_x11(environment)); } + + #[test] + fn macos_appkit_session_supports_native_mpv_embedding() { + let environment = PlatformEnvironment { + os: "macos", + display: None, + wayland_display: None, + gdk_backend: None, + }; + let support = PlatformSupport::for_environment(environment); + + assert_eq!(support.display_server, "appkit"); + assert!(support.mpv_embed_video); + } } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 2329fd7..e7d2962 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "frontendDist": "../dist" }, "app": { + "macOSPrivateApi": true, "windows": [ { "title": "OpenPlayer", diff --git a/apps/desktop/src-tauri/tauri.macos.conf.json b/apps/desktop/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..e552220 --- /dev/null +++ b/apps/desktop/src-tauri/tauri.macos.conf.json @@ -0,0 +1,14 @@ +{ + "identifier": "dev.openplayer.desktop", + "bundle": { + "active": true, + "targets": ["app", "dmg"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.png" + ] + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4531f77..87abca8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -38,6 +38,7 @@ type MpvSnapshot = { fps: number; speed: number; hwdec: string; + videoFill: boolean; subtitleDelay: number; volume: number; tracks: MpvTrack[]; @@ -122,6 +123,18 @@ type PendingWindowDrag = { startY: number; }; +type ManualResizeDrag = { + pointerId: number; + direction: ResizeDirection; + lastX: number; + lastY: number; + pendingDeltaX: number; + pendingDeltaY: number; + animationFrameId: number | null; + resizeCommandInFlight: boolean; + finishing: boolean; +}; + type PlaybackClockAnchor = { position: number; startedAt: number; @@ -141,6 +154,10 @@ type VolumeFeedback = { }; type ResizeDirection = "East" | "North" | "NorthEast" | "NorthWest" | "South" | "SouthEast" | "SouthWest" | "West"; +type ResizeFeedback = { + direction: ResizeDirection; + active: boolean; +}; type WindowCommand = "window_minimize" | "window_toggle_maximize" | "window_toggle_fullscreen" | "window_close"; type ShortcutAction = @@ -621,12 +638,28 @@ function startMainWindowDrag() { }); } -function startMainWindowResize(direction: ResizeDirection) { +function startNativeMainWindowResize(direction: ResizeDirection) { invoke("window_start_resize", { direction }).catch((error: unknown) => { console.error(`Window resize failed: ${direction}`, error); }); } +function applyManualMainWindowResize(direction: ResizeDirection, deltaX: number, deltaY: number) { + return invoke("window_apply_resize_delta", { direction, deltaX, deltaY }).catch((error: unknown) => { + console.error(`Window resize failed: ${direction}`, error); + }); +} + +function applyResizeCursor(direction: ResizeDirection | null) { + return invoke("window_set_resize_cursor", { direction }).catch((error: unknown) => { + console.warn("Resize cursor update failed", error); + }); +} + +function resizeDirectionClassName(direction: ResizeDirection) { + return direction.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + function formatTimecode(value: number, totalDuration: number) { if (!Number.isFinite(value) || value <= 0) { return totalDuration > 3600 ? "0:00:00" : "00:00"; @@ -855,6 +888,7 @@ function App() { const [volumeLevel, setVolumeLevel] = useState(0.82); const [playbackSpeed, setPlaybackSpeedValue] = useState(1); const [hardwareDecodingMode, setHardwareDecodingModeValue] = useState("hardware"); + const [isVideoFillEnabled, setIsVideoFillEnabled] = useState(false); const [subtitleDelay, setSubtitleDelayValue] = useState(0); const [tracks, setTracks] = useState([]); const [loadedMediaPath, setLoadedMediaPath] = useState(null); @@ -873,6 +907,7 @@ function App() { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState("appearance"); const [mediaPanelMode, setMediaPanelMode] = useState(null); + const [resizeFeedback, setResizeFeedback] = useState(null); const [shellPreviewFormats, setShellPreviewFormats] = useState([]); const [selectedShellPreviewFormats, setSelectedShellPreviewFormats] = useState([]); const [shellPreviewRegistrationStatus, setShellPreviewRegistrationStatus] = useState(null); @@ -885,6 +920,8 @@ function App() { const chromeHideTimerRef = useRef(null); const volumeFeedbackTimerRef = useRef(null); const pendingWindowDragRef = useRef(null); + const manualResizeDragRef = useRef(null); + const resizeCursorDirectionRef = useRef(null); const handledEndedPathRef = useRef(null); const lastHistoryWriteRef = useRef(0); const hardwareDecodingModeRef = useRef("hardware"); @@ -1034,6 +1071,9 @@ function App() { return () => { clearChromeHideTimer(); clearPendingWindowDrag(); + clearManualResizeDrag(); + setNativeResizeCursor(null); + setResizeFeedback(null); }; }, [media?.id, isChromePinned]); @@ -1201,6 +1241,98 @@ function App() { pendingWindowDragRef.current = null; } + function clearManualResizeDrag() { + const pendingResize = manualResizeDragRef.current; + if (pendingResize?.animationFrameId != null) { + window.cancelAnimationFrame(pendingResize.animationFrameId); + } + manualResizeDragRef.current = null; + } + + function setNativeResizeCursor(direction: ResizeDirection | null) { + if (resizeCursorDirectionRef.current === direction) { + return; + } + + resizeCursorDirectionRef.current = direction; + void applyResizeCursor(direction); + } + + function setResizeBoundaryFeedback(direction: ResizeDirection | null, active = false) { + setResizeFeedback((feedback) => { + if (!direction) { + return feedback === null ? feedback : null; + } + + if (feedback?.direction === direction && feedback.active === active) { + return feedback; + } + + return { direction, active }; + }); + } + + function completeManualResizeIfIdle(pendingResize: ManualResizeDrag) { + if ( + pendingResize.finishing && + !pendingResize.resizeCommandInFlight && + pendingResize.animationFrameId === null && + Math.abs(pendingResize.pendingDeltaX) < 0.5 && + Math.abs(pendingResize.pendingDeltaY) < 0.5 && + manualResizeDragRef.current === pendingResize + ) { + manualResizeDragRef.current = null; + } + } + + function requestManualResizeFlush() { + const pendingResize = manualResizeDragRef.current; + if (!pendingResize || pendingResize.animationFrameId !== null || pendingResize.resizeCommandInFlight) { + return; + } + + pendingResize.animationFrameId = window.requestAnimationFrame(() => { + const activeResize = manualResizeDragRef.current; + if (!activeResize) { + return; + } + + activeResize.animationFrameId = null; + flushManualResizeDelta(); + }); + } + + function flushManualResizeDelta() { + const pendingResize = manualResizeDragRef.current; + if (!pendingResize || pendingResize.resizeCommandInFlight) { + return; + } + + const deltaX = pendingResize.pendingDeltaX; + const deltaY = pendingResize.pendingDeltaY; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) { + completeManualResizeIfIdle(pendingResize); + return; + } + + pendingResize.pendingDeltaX = 0; + pendingResize.pendingDeltaY = 0; + pendingResize.resizeCommandInFlight = true; + applyManualMainWindowResize(pendingResize.direction, deltaX, deltaY).finally(() => { + if (manualResizeDragRef.current !== pendingResize) { + return; + } + + pendingResize.resizeCommandInFlight = false; + if (Math.abs(pendingResize.pendingDeltaX) >= 0.5 || Math.abs(pendingResize.pendingDeltaY) >= 0.5) { + requestManualResizeFlush(); + return; + } + + completeManualResizeIfIdle(pendingResize); + }); + } + function scheduleChromeHide() { clearChromeHideTimer(); if (isChromePinned) { @@ -1260,6 +1392,7 @@ function App() { setPlaybackSpeedValue(snapshotSpeed); setHardwareDecodingModeValue(hwdecModeFromSnapshot(snapshot.hwdec)); hardwareDecodingModeRef.current = hwdecModeFromSnapshot(snapshot.hwdec); + setIsVideoFillEnabled(snapshot.videoFill === true); setSubtitleDelayValue(clampSubtitleDelay(snapshot.subtitleDelay)); setTracks(Array.isArray(snapshot.tracks) ? snapshot.tracks : []); setLoadedMediaPath(snapshot.path); @@ -1575,6 +1708,10 @@ function App() { function handleShellPointerLeave() { clearChromeHideTimer(); + if (!manualResizeDragRef.current) { + setNativeResizeCursor(null); + setResizeBoundaryFeedback(null); + } if (media && !isChromePinned) { setIsChromeVisible(false); } @@ -1969,6 +2106,44 @@ function App() { togglePlayback(); } + function startMainWindowResize(event: ReactPointerEvent, direction: ResizeDirection) { + setNativeResizeCursor(direction); + setResizeBoundaryFeedback(direction, true); + if (platformSupport?.os === "macos") { + event.currentTarget.setPointerCapture(event.pointerId); + manualResizeDragRef.current = { + pointerId: event.pointerId, + direction, + lastX: event.clientX, + lastY: event.clientY, + pendingDeltaX: 0, + pendingDeltaY: 0, + animationFrameId: null, + resizeCommandInFlight: false, + finishing: false, + }; + return; + } + + startNativeMainWindowResize(direction); + } + + function handleResizePointerEnter(event: ReactPointerEvent, direction: ResizeDirection) { + event.stopPropagation(); + setNativeResizeCursor(direction); + setResizeBoundaryFeedback(direction); + } + + function handleResizePointerLeave(event: ReactPointerEvent) { + if (manualResizeDragRef.current?.pointerId === event.pointerId) { + return; + } + + event.stopPropagation(); + setNativeResizeCursor(null); + setResizeBoundaryFeedback(null); + } + function handleResizePointerDown(event: ReactPointerEvent, direction: ResizeDirection) { if (event.button !== 0) { return; @@ -1977,7 +2152,47 @@ function App() { event.preventDefault(); event.stopPropagation(); recordUserActivity(); - startMainWindowResize(direction); + startMainWindowResize(event, direction); + } + + function handleResizePointerMove(event: ReactPointerEvent) { + const pendingResize = manualResizeDragRef.current; + if (!pendingResize || pendingResize.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + recordUserActivity(); + const scale = window.devicePixelRatio || 1; + const deltaX = (event.clientX - pendingResize.lastX) * scale; + const deltaY = (event.clientY - pendingResize.lastY) * scale; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) { + return; + } + pendingResize.lastX = event.clientX; + pendingResize.lastY = event.clientY; + pendingResize.pendingDeltaX += deltaX; + pendingResize.pendingDeltaY += deltaY; + requestManualResizeFlush(); + } + + function handleResizePointerEnd(event: ReactPointerEvent) { + const pendingResize = manualResizeDragRef.current; + if (pendingResize?.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + pendingResize.finishing = true; + requestManualResizeFlush(); + completeManualResizeIfIdle(pendingResize); + setNativeResizeCursor(null); + setResizeBoundaryFeedback(null); } function togglePlayback() { @@ -2077,6 +2292,22 @@ function App() { }); } + function setVideoFillMode(enabled: boolean) { + if (!media) { + return; + } + + const previousValue = isVideoFillEnabled; + setIsVideoFillEnabled(enabled); + invalidatePendingSnapshots(); + invoke("mpv_embed_set_video_fill", { enabled }) + .then(applyCommandSnapshot) + .catch((error: unknown) => { + setIsVideoFillEnabled(previousValue); + reportPlaybackError(error); + }); + } + function setSubtitleDelay(delay: number) { if (!media) { return; @@ -2214,6 +2445,39 @@ function App() { ); } + function renderVideoLayoutOptions() { + if (isAudioOnlyMedia) { + return null; + } + + return ( +
+
+

{t.media.videoLayout}

+ {isVideoFillEnabled ? t.media.videoFill : t.media.videoFit} +
+
+ + +
+
+ ); + } + function renderAppearanceSettings() { return (
@@ -2566,10 +2830,31 @@ function App() { key={region.direction} aria-hidden="true" className={`resize-region ${region.className}`} + onPointerEnter={(event) => handleResizePointerEnter(event, region.direction)} + onPointerLeave={handleResizePointerLeave} onPointerDown={(event) => handleResizePointerDown(event, region.direction)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerEnd} + onPointerCancel={handleResizePointerEnd} /> ))} + {resizeFeedback && ( + + )} +