A video player for Windows and macOS built on Tauri 2 and libmpv: local files, links, torrent streaming with a built-in catalog, casting to Chromecast and DLNA televisions, and watching together in sync — with an unusual amount of attention to detail.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Local files, links and magnet torrents in one window, on Tauri 2 + libmpv —
frame-accurate, instant, and unusually careful about the details.
Hovering the seekbar previews the frame you would land on, and names the
chapter it belongs to.
Decoding is libmpv, the same engine as mpv and IINA, so format coverage,
hardware acceleration and HDR are not compromises. mpv renders into a native
child view behind a transparent webview and the entire interface is HTML
composited on top, which is what lets it be a real interface rather than
whatever an OSC script can draw.檢視完整 README ↗Decoding is libmpv, the same engine as mpv and IINA, so format coverage, hardware acceleration and HDR are not compromises. mpv renders into a native child view behind a transparent webview and the entire interface is HTML composited on top, which is what lets it be a real interface rather than whatever an OSC script can draw.
Most of what follows exists somewhere else. What is unusual is how much of it is correct in the cases where other players are merely plausible.
Watch a torrent while it is still downloading. Paste a magnet link and a whole season becomes a queue you can start playing in seconds. Pieces are prioritized around the playhead, the seekbar shades what has already arrived so you can see whether a jump will land or wait, subtitles shipped inside the torrent are attached automatically, and the next episode is fetched ahead once the current one is complete. Nothing is downloaded until you actually ask for it, and seeding is off by default.
Watch it with somebody who is not in the room. A six-character code puts two players on one timeline: the same position, the same pauses, the same episode. Drift is corrected by nudging playback speed rather than by seeking — a seek to fix a tenth of a second costs a decode from the previous keyframe and lands you further out than you started — and a room pauses itself for anybody still buffering. If what you are watching is a torrent, everybody else fetches it from the same swarm and follows you through the season without pasting anything. Nothing but the timeline goes through the relay; no media ever does.
Send it to the television, without repacking it first. The player speaks Google Cast and DLNA — and on a Mac, AirPlay, through the system's own player — and picks per device and per file: where the set can take the release as it is — which for a DLNA renderer usually means a 4K HEVC HDR MKV with Dolby audio, untouched — that is what it gets, with seeking and surround intact. Where it cannot, the file is remuxed first, with the video stream-copied so a film is ready in seconds rather than in an hour. A torrent that is still downloading can be cast too, fed from the same piece-priority stream that feeds local playback. While it plays the window is a remote: the queue moves the session from episode to episode, chapters and skip buttons act on the television, and disconnecting hands playback back at the position it had.
Skip the intro. Chapter navigation, a chapter list, and a skip button for openings, recaps, "previously on", credits and ads. The button is matched on the whole chapter title rather than a substring hidden inside it — so "Ending the war for good" never turns into the closing credits, and the offer stays up long enough to act on without hanging over the picture for a whole minute.
Subtitles that are already in sync. Search OpenSubtitles by the file's own hash, which identifies the exact release rather than guessing which rip you have, with a title search as the fallback — and the panel tells you which of the two produced the list, because they are not the same kind of answer. Downloads land beside the video and are picked up by themselves next time.
Previews that show the frame you land on, not the keyframe before it. Measured on a 24-minute film, mapping the cursor to a preview the obvious way put a different scene under it 60% of the time; here the thumbnail sharpens to the exact frame the moment the cursor stops moving. Seeking is measured too: an exact seek costs 1.9 s on one file and 0.05 s on another and nothing at all on most, so the player times the first one and decides from the measurement instead of trading away precision everywhere to be safe.
Pick up where you left off, in anything. Files, links and individual torrent episodes all resume, behind a start screen of unfinished videos with their own poster frames. The dub you chose in episode 1 is found again in episode 2 by language, title and codec — never by track number, which moves between releases.
A mini player that stays where you put it. A small always-on-top window that snaps to the corners and, on macOS, floats over other applications' fullscreen spaces — the one thing an ordinary always-on-top window cannot do.
The interface is in English, Russian and Simplified Chinese, and every hotkey can be rebound.
Both platforms are built and published by CI on every version bump — take the files from the latest release:
| Platform | File | Requirements |
|---|---|---|
| Windows | FramePlayer_<version>_x64-setup.exe | Windows 10/11, x64 |
| Windows on ARM | FramePlayer_<version>_arm64-setup.exe | Windows 10/11, ARM64 |
| macOS | FramePlayer_<version>_aarch64.dmg | Apple Silicon |
On a Windows laptop with an ARM processor, take the ARM64 installer. The x64 one runs there too, under Windows' emulation, but slower — and it stays x64 through every update, since an update replaces a build with the same build. To switch, run the ARM64 installer: it offers to remove the x64 version first, and your settings and watch history stay.
macOS is also a brew install --cask away — see macOS below.
The macOS build is signed with an Apple Developer ID and notarised, so it opens like any other application. The Windows build is not signed, and stops the first launch with a SmartScreen warning — that is a fact about a certificate rather than about the binary, which is built in the open from this repository by the release workflow. Updates are a separate mechanism and are verified on both platforms: every package is signed with the project's own key and the player refuses one whose signature does not match.
Once installed, the player updates itself — it checks for a new version at startup and every six hours, and the update reopens the current video where it was.
FramePlayer_<version>_x64-setup.exe (or _arm64-setup.exe). SmartScreen shows a blue
"Windows protected your PC" dialog with only a Don't run button.SmartScreen is judging the file's reputation as much as its signature, and a new version is a new file, so expect the warning again after an update installed by hand. Updates applied from inside the player do not go through it.
Download FramePlayer_<version>_aarch64.dmg from
the latest release,
open it and drag Frame Player to Applications.
With Homebrew instead:
brew install --cask risenxxx/tap/frame-player
The fully qualified name taps the repository on the way past, so that is the
whole installation; afterwards the cask answers to frame-player alone.
The cask is in a tap of its own
rather than in homebrew-cask, whose casks have to clear a popularity bar this
project has not reached; it is bumped by the release workflow, so it names the
current version within a minute of one being published. It declares the player
as self-updating, which means brew upgrade deliberately leaves it alone: the
player fetches its own signed updates, and Homebrew is the way in and the way
out rather than the update channel. It also declares the build as Apple Silicon
only, so an Intel machine is refused with a reason instead of receiving an
application it cannot run.
brew uninstall --cask frame-player removes the player and leaves watch
positions, remembered tracks and the thumbnail cache where they are; adding
--zap removes those too.
A build you produce yourself from this repository is not signed the way the releases are: with no certificate it is signed ad-hoc, which seals the bundle but certifies nothing. macOS then refuses the first launch with an Open Anyway button in System Settings → Privacy & Security — the path that has a way out, as opposed to the "is damaged" refusal an unsealed binary gets, which has none.
Playback
vo=gpu-next., / . in both directions with no freeze.Sources
Television
Interface
alang/slang instead of picking something wrong.Platform
A room holds a timeline — what is playing, whether it is paused, where in it and at what speed. Everyone fetches the film themselves, so no media passes between the players and none passes through the relay.
Open the context menu → Watch together. Create a room and
you get a six-character code and a link; either one gets somebody else in. A
frameplayer://join/ABC123 link opens the player straight into the join dialog
with the code filled in — it is offered rather than obeyed, because a custom
scheme is a surface any web page can aim at.
What is shared, and what is not. Position, pause, speed and which file is playing are the room's. Volume, subtitle appearance, zoom, delays and window state are yours. Track choices sit in between and are a rule of the room, set by the host beside "only the host controls playback": the audio track is shared by default (a room is watching one film and listening to one soundtrack) and subtitles are not (one viewer needs them and another does not — sharing that choice would turn them off for somebody who cannot follow the film without them). A track travels as a description rather than a track number, so it still resolves correctly when two people have different rips.
What everybody else opens depends on where your film came from:
| source | what happens on the other machines |
|---|---|
| torrent | the magnet and the file index travel; everyone opens the same episode themselves, and switching episode carries the room with it |
| link | the URL travels and each player resolves it |
| local file | nothing can be sent, so the room shows the name, length and release hash — open your own copy and the player says whether it is the same release, a different rip, or a different film |
| a file in a hidden folder | the timeline still syncs and the name does not leave your machine |
Anybody can control playback by default, and the host can turn that off. A room pauses itself while any member is still opening a file or buffering, and says who it is waiting for; a member who never reports stops holding it up after 45 seconds.
The relay is a small Go server in services/relay/ — no database,
nothing written to disk, and a room ceases to exist a few minutes after the last
person leaves. Builds point at a default instance; the address is a field in
Settings → General, so running your own is a setting rather than a fork.
See services/relay/README.md to deploy one and
docs/watch-together.md for the design.
The player reads an mpv.conf of its own, created with a commented template on
first run:
| Platform | Path |
|---|---|
| Windows | %APPDATA%\app.frameplayer\mpv.conf |
| macOS | ~/Library/Application Support/app.frameplayer/mpv.conf |
The format and options are mpv's own (option=value,
full list); values are applied on top of
the player's defaults at startup. Useful examples from the template:
target-colorspace-hint=auto (true HDR output on an HDR display),
tone-mapping, audio-spdif (bitstream to an AV receiver), hwdec=no.
A settings dialog (right-click → Settings) covers the common options and writes to the same file surgically — manual edits, comments and unknown options are preserved, and changes apply live. The bottom of the dialog links to the file itself, and reports which decoder is actually in use, so a silent fallback to software decoding is visible.
Three addresses are settings rather than build-time constants: the watch-together relay and the catalog's metadata proxy, each with a sensible default that an empty field restores, and the catalog's release search server, which has none. Self-hosting any of them is a setting, not a fork — see services/.
Bound to physical keys, so they work in any keyboard layout, and all of them can be reassigned in the settings dialog. macOS defaults differ where the system or the menu bar owns the combination.
| Key | Action |
|---|---|
Space / K | Play / pause |
, / . | Frame back / forward |
← / → | −5 s / +5 s (Shift — ±1 s, exact) |
J / L | −10 s / +10 s |
Ctrl+← / → | Previous / next chapter (⌃⌘ on macOS) |
Home / End | Start / end of file |
0–9 | Jump to 0–90 % of the file |
↑ / ↓ / wheel | Volume ±5 |
M | Mute |
[ / ] | Slower / faster |
Shift+L | Repeat mode |
A / Shift+A | Mark / clear an A–B loop |
PgUp / PgDn | Previous / next item in the queue |
Z / Shift+Z | Subtitle delay ∓ |
Ctrl+- / Ctrl+= | Audio delay ∓ |
F / double click | Fullscreen |
P | Mini player |
I | Media info |
O | Open files |
Ctrl+L | Open a link (⌘L on macOS) |
S / Shift+S | Save the frame / with subtitles |
Ctrl+C | Copy the frame (⌘C on macOS) |
Ctrl + wheel |
Requirements: proto (it installs the Node.js and
bun versions pinned in .prototools on first use), Rust (stable), and Windows 10/11 (x64 or ARM64) or macOS on
Apple Silicon (macOS release builds are aarch64-only; an Intel build is a second CI
entry away but is not currently produced). Binary SDKs are not committed — they are fetched into src-tauri/{lib,ffmpeg,tools}
by the scripts below, and the build paths are wired in
.cargo/config.toml.
bun install
bun run fetch-libs # libmpv (LGPL) + FFmpeg SDK (LGPL, for the sidecar) + libclang (for bindgen)
bun run tauri dev # development run
bun run tauri build # NSIS installer
On an ARM64 machine, fetch the ARM64 SDKs instead — the build itself is the same commands:
powershell -ExecutionPolicy Bypass -File scripts/fetch-libs.ps1 -Arch arm64
macOS needs a patched libmpv: mpv's macOS backend does not implement --wid
at all, so stock libmpv opens a window of its own instead of rendering into
ours. The patch is in patches/ (+162/−14 across four files), beside
a backport of an upstream fix that no mpv release carries yet.
The libraries are prebuilt and fetched, the same way Windows fetches its SDKs — no Homebrew and no compiler needed to build the app itself:
bun install
scripts/fetch-macos-libs.sh # libmpv (patched, LGPL) + FFmpeg + the dylib closure
bun run tauri:macos # development run
bun run tauri:macos:build # .app bundle
bun run tauri:macos:dmg # disk image
To rebuild that set — when changing the patch or a pinned version — run
scripts/build-macos-libs.sh (Xcode Command Line Tools and Homebrew required;
it compiles FFmpeg and mpv, twenty minutes or so) and publish it through the
macOS libs workflow, which re-checks the licenses before uploading.
FFmpeg is built here rather than taken from Homebrew because Homebrew's is a
GPL-3 build: it enables the GPL parts and links x264 and x265, and mpv built
against it is GPL too. The player needs none of that — it never encodes video
(-c:v copy everywhere; only audio is transcoded, to AAC or E-AC-3, both
native) — so --disable-gpl plus -Dgpl=false costs no functionality and keeps
the distributed application LGPL, matching Windows.
The sidecar decode paths have smoke tests. Generate a fixture with the bundled
ffmpeg first (the LGPL build has no GPL encoders, hence mpeg4 rather than
libx264):
src-tauri/ffmpeg/bin/ffmpeg.exe -f lavfi -i testsrc2=duration=60:size=1280x720:rate=30 -c:v mpeg4 -q:v 5 test.mp4
cd src-tauri && FP_TEST_VIDEO=/path/to/test.mp4 cargo test --lib -- --nocapture
On macOS a test binary cannot find the bundled dylibs on its own, so add
DYLD_FALLBACK_LIBRARY_PATH=$PWD/lib.
The watch-together relay is its own tree and its own run — the Node gates do not
reach it, and shared/sync-protocol.txt is a contract only one half of which
they check:
# Both Go services, from the repository root. The pattern is `frameplayer/...`
# and not `./...`: the root is not itself a module, so a path pattern rooted
# there matches nothing and complains as though the workspace were broken.
go vet frameplayer/... && go test -race frameplayer/...
Testing a room needs two players, and the player is single-instance, so a
second bun run tauri dev signals the first rather than starting one.
cmd/probe is the other end of a room — it joins, follows the timeline and
prints where it thinks playback is, so the real player can be driven by hand and
watched from a terminal:
go run ./services/relay &
go run ./services/relay/cmd/probe -room ABC123 -drive
go run ./services/relay/cmd/probe -room ABC123 -skew 300ms # a clock that is wrong on purpose
go run ./services/relay/cmd/probe -room ABC123 -hold 20s # hold the room, on purpose
| Path | What lives there |
|---|---|
src/routes/+page.svelte | The player UI: markup, styles, gestures, hotkeys, menus |
src/lib/*.svelte.ts | State modules — mpv mirrors, watch history, window prefs, thumbnails, zoom, torrents, i18n, the hotkey table |
src/routes/veil/+page.svelte | The black shutter window used to mask fullscreen transitions |
src-tauri/src/lib.rs | Plugin registration, window and app lifecycle, commands |
src-tauri/src/thumb_service.rs | Seekbar thumbnails: decode, background storyboard, disk cache |
src-tauri/src/step_engine.rs | FFmpeg sidecar frame-stepper (kept working, disabled by default) |
src-tauri/src/torrent/ | Torrent session and the loopback HTTP server mpv opens |
src-tauri/src/cast/ · dlna/ | Casting: the Cast client, the LAN file server, the UPnP transport |
src-tauri/src/opensubtitles.rs | Subtitle search and download |
src-tauri/src/catalog.rs | The catalog: TMDB through our proxy, and the release lookup |
src/lib/sync/ | Watching together: the wire, the clock estimate, content identity, drift correction |
services/ | The Go services, deployed apart and developed together behind the root go.work |
services/relay/ | The watch-together relay, and cmd/probe, a headless peer for testing it alone |
services/tmdb/ | The metadata proxy: holds the TMDB key, caches responses and posters |
src-tauri/src/macos_*.rs | Native window chrome and menu bar on macOS |
src-tauri/lua/zoompan.lua | Atomic zoom+pan applied on mpv's core thread |
patches/ | What the macOS libmpv is built with on top of upstream: the --wid embedding patch, and backports of fixes not released yet |
scripts/ | SDK fetching, the macOS libmpv build, dylib bundling, DMG layout |
CLAUDE.md is the index of the engineering state of record, and
docs/rules/ is the record itself: how each subsystem works
and which invariants must not be broken, a chapter per area, each with a
one-paragraph-per-rule short form in .claude/rules/. docs/ is the reasoning
behind it — the measurements, the alternatives that were tried, and the dead
ends worth not repeating. Code comments and build scripts cite those documents
by name (architecture.md, macos.md, a numbered ROADMAP item).
Pushing a version bump in src-tauri/tauri.conf.json to main triggers CI:
Windows and macOS artifacts are built, signed with the updater key, uploaded
together with latest.json to Cloudflare R2 and published as a GitHub Release.
Installed players pick the update up automatically.
The macOS bundle and its disk image are signed with an Apple Developer ID and notarised as part of that run. The Windows installer is not code-signed yet, so it still shows a SmartScreen warning on first run; Install has the way past it. The updater signature is a separate thing and is always verified, on both platforms.
The last step of the run bumps the Homebrew cask to the release that has just been published. It is last because a stale tap hands out the previous version while a missing release is an artifact nobody can obtain, and a step must not stand in front of something more important than itself. The cask points at the GitHub Release asset rather than at R2, which keeps only the five newest versions — a download that 404s is worse than a version behind.
The application is GPL-3.0-or-later licensed: you may use, study, change and redistribute it, and anything you distribute that is built from it has to come with the same freedoms and its source.
That is a deliberate choice rather than a default. The bundled libraries are kept strictly LGPL (see below), which is what leaves the option of a closed or paid build open — and a permissive license on this code would have handed that same option to a closed fork while the original kept doing the maintenance.
Distributed builds link against libmpv and FFmpeg under the LGPL
v2.1+, on both platforms. They are separate dynamic libraries, so the right the
LGPL reserves for you — to replace one with your own build and keep the player
working — is a matter of swapping a file in Contents/Resources/lib (macOS) or
beside the executable (Windows).
Their sources: FFmpeg and mpv upstream at the versions pinned in
scripts/build-macos-libs.sh and
scripts/fetch-libs.ps1, plus the modifications this
project makes to mpv on macOS, all of them in patches/ — the --wid
embedding patch, which is why libmpv is built here rather than taken as a
binary, and a backport of an upstream fix. Neither build enables
the GPL parts: no x264, no x265, no librubberband, and -Dgpl=false for mpv.
scripts/check-macos-licenses.sh is what
enforces that, and it runs before anything is published.
The bundle also includes libass, libplacebo, MoltenVK, LuaJIT, dav1d and others, each under its own license. Torrent streaming uses librqbit (Apache-2.0).
Every one of them, with the license each is used under and its full text, is in
THIRD-PARTY-NOTICES.md — which ships inside the
application as well, reachable from the bottom of the settings dialog. It is
generated by bun run notices from licenses/, and bun run gates
fails if a shipped library has no entry there or if the committed file is stale.
Frame Player is not affiliated with the mpv, FFmpeg or OpenSubtitles projects.
| Zoom, anchored at the cursor |
Ctrl+0 | Reset zoom |
| Drag on video | Moves the window; pans when zoomed |
| Horizontal wheel | Scrub |
shared/ | Contracts two languages have to keep, read by both test suites |
docs/ | Design notes: why the architecture, the transports and the shipping story look like this |
external-issues-backlog/ | Upstream bugs found here, written up for filing |