The video half of the NoMercy player trio, for Kotlin Multiplatform.
  • Kotlin 92%
  • Swift 7.2%
  • Shell 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Stoney_Eagle efb6da2190 test(chrome): what the transport bar keeps at each width
Two candidates for a phone drawing three controls -- the drop rule is wrong, or
the width handed to it is far smaller than the pane -- and they need opposite
fixes. The rule is a pure function, so it can be asked directly.

At 800dp, a phone in landscape, it keeps 16. At 240dp it keeps PLAY and MUTE,
which is what was photographed. The rule is innocent.
2026-08-10 01:13:13 +02:00
.github/workflows ci(macos): the runner has the GNU tar the payload script needs 2026-08-09 21:27:31 +02:00
api fix(tv): Resume closes the start menu on Apple too, and the menu's verbs are one value 2026-08-09 18:51:49 +02:00
apple/NoMercyPlayer fix(gate): a red gate says why, on the machine that went red 2026-08-09 21:04:51 +02:00
config/detekt feat(video-ui): the drop-in view, and the desktop seam the plan called unproven 2026-07-26 04:17:43 +02:00
contract chore(contract): track the web trio at 2.0.4, proven by the scenario replay 2026-08-06 00:31:06 +02:00
docs feat(cast): one call assembles the whole of casting 2026-07-27 13:09:15 +02:00
gradle fix(deps): the core this library builds against is the core it ships with 2026-08-06 04:13:03 +02:00
scenarios chore(contract): track the web trio at 2.0.4, proven by the scenario replay 2026-08-06 00:31:06 +02:00
src fix(tv): Resume starts the film AND closes the menu 2026-08-09 17:44:12 +02:00
subtitles-libass fix(subtitles): a finished teardown stops overwriting a newer selection 2026-08-09 22:16:52 +02:00
tools fix(conformance): the Swift fixtures were a third copy nobody synced 2026-07-29 02:28:20 +02:00
ui-compose test(chrome): what the transport bar keeps at each width 2026-08-10 01:13:13 +02:00
.gitattributes fix(conformance): the fixtures are checked, and the check can actually fail 2026-07-28 13:58:28 +02:00
.gitignore feat: the video half of the trio, with its event registry 2026-07-26 00:17:29 +02:00
build.gradle.kts fix(deps): the core this library builds against is the core it ships with 2026-08-06 04:13:03 +02:00
contract.lock chore(contract): track the web trio at 2.0.4, proven by the scenario replay 2026-08-06 00:31:06 +02:00
gradle.properties chore(version): the trio is v0, not a release candidate for the web's v2 2026-08-06 02:15:03 +02:00
gradlew fix(ci): mark gradlew executable 2026-07-26 00:18:29 +02:00
gradlew.bat feat: the video half of the trio, with its event registry 2026-07-26 00:17:29 +02:00
LICENSE feat: the video half of the trio, with its event registry 2026-07-26 00:17:29 +02:00
README.md fix(libass): ask the payload before asking the machine 2026-08-01 18:56:53 +02:00
scenarios.lock chore(contract): track the web trio at 2.0.4, proven by the scenario replay 2026-08-06 00:31:06 +02:00
settings.gradle.kts feat(conformance): the Apple build is driven, not just inspected 2026-07-28 15:16:22 +02:00

nomercy-video-player-kmp

The video half of the NoMercy player trio, for Kotlin Multiplatform.

Everything a player does — transport, queue, volume, time, state, plugins, lifecycle — lives in nomercy-player-core-kmp. This library adds only what makes a player a video player: the video-domain surface (fullscreen, picture-in-picture, theater, aspect ratio, the video rect, segment skipping), the video backends, and the nineteen events in VideoEvents that only a video player has.

No core method is restated here. A listener for play uses CoreEvents.Play even on a video player: there is one bus and one set of names, and the split is about which library owns the declaration.

Building it

./gradlew build

The build depends on core by its published coordinate and substitutes a sibling ../nomercy-player-core-kmp checkout when there is one, so a change to core is picked up without publishing first. Without that, two libraries that ship together would drift apart between releases.

What is here

The library is three pieces, and a consumer takes only what it needs.

nomercy-video-player-kmp is the player itself: the video event registry and its payload types, NMVideoPlayer, the backend bridge, and the subtitle parsers. It has no UI and no native subtitle library.

:ui-compose is the drop-in view for Android and the desktop — a media surface with one play/pause control bound to the player's state. Compose is a dependency you should be able to decline, which is why it is a separate module. Apple gets SwiftUI instead: apple/NoMercyPlayer is an SPM package with the same view for iOS and tvOS. A Compose surface on iOS would fight the app it was embedded in.

:subtitles-libass renders styled ASS subtitles through libass, on all three platforms and through three different bindings: the published JNI binding on Android, JNA to a system library on the desktop, and Kotlin/Native cinterop to a prebuilt static framework on Apple. One contract, three implementations answering the same gates the same way.

Where a platform has no libass it says so rather than failing at the point of use — ask AssRenderers.whyUnavailable() and you get a sentence, not a stack trace.

What is proven, and where

Nothing below says "it compiles". Each row is a behaviour, the command that measures it, and where that command can run.

Behaviour Android Desktop iOS tvOS
The control toggles playback testAndroidHostTest jvmTest xcodebuild test xcodebuild test
The engine's own state redraws the control testAndroidHostTest jvmTest xcodebuild test xcodebuild test
The select key toggles playback with nothing to aim at testAndroidHostTest jvmTest n/a xcodebuild test
Video paints connectedAndroidDeviceTest jvmTest device QA device QA
The control draws over the video jvmTest device QA device QA
One ASS cue rasterizes visible pixels connectedAndroidDeviceTest jvmTest, where libass is installed iosSimulatorArm64Test iosSimulatorArm64Test
Fonts are attached before the track loads jvmTest jvmTest jvmTest jvmTest

CI runs everything except the device rows. Those need hardware attached, and they run from a developer machine — the matrix does not pretend to cover them. Video painting on Android is proven on a phone and on an Android TV box.

What libass needs from the machine

On Apple nothing is needed. libass, freetype, fribidi and harfbuzz are built once from upstream and published as a release asset, and the build fetches them. Cross compiling them needs an autotools toolchain and a long wait, which is not something a Gradle build should ask of a fresh machine. The archive is pinned by tag and checked against a digest.

The desktop is moving to the same arrangement. AssRenderers on the JVM now asks NativeRuntimes for a libass payload first and only falls back to a system copy underneath it, so the loader is shared with libVLC rather than being a second mechanism. That ordering is already live; the payloads themselves are not published yet, so today a desktop machine still resolves the system copy.

Until they are, Linux and macOS are a package away, apt install libass9 and brew install libass, and the renderer loads whichever is installed including from Homebrew's directories, which a JVM does not search by default. Windows has no system libass to find at all: the only builds in circulation are statically linked inside VLC and mpv and cannot be loaded from outside them, so a Windows caller gets a sentence from AssRenderers.whyUnavailable() and falls back to plain text.

What the subtitle gate proves, and where

The claim being made is not that libass initialises. It is that a real anime track renders correctly: the Rail Wars! opening, with the Negotiate Free face it attaches, fetched through the same authenticated path a player uses. The same assertions run on every surface from one shared source file, so a binding that drifts is a red test rather than a difference nobody looks for.

surface binding where it runs in CI
Android phone JNI, io.github.peerless2012:ass hardware no
Android TV JNI, same binding hardware no
iOS cinterop, ios-arm64 slice simulator yes
tvOS cinterop, tvos-arm64 slice simulator yes
Linux desktop JNA, system libass9 runner yes
macOS desktop JNA, Homebrew libass runner yes
Windows desktop none runner skips, loudly

Two of those rows are worth reading twice.

Android is not in CI and is not pretending to be. The runners have no device, and an emulator running a subtitle renderer under software rendering measures the emulator. It runs on a Galaxy A13, a Nokia Streaming Box 8010 and an 8000 before anything ships, and the memory tiers exist because of what the 8000 does to a 128MB libass cache.

Windows skips, and a skip is not a pass. A host that installs libass on purpose sets NOMERCY_REQUIRE_LIBASS=1, and the gate then fails with the reason libass gave rather than printing a line nobody reads — because the Linux job had installed the package for a while and nothing would have noticed if the install stopped working.

What is still open

The font manifest is a single point of failure on the server side. A missing fonts.json degrades to the system face and reports plugin:subtitle/fonts-manifest-failed, which is the right behaviour and not a substitute for the file being there; there is no client-side fallback that could supply a face the track was authored against.

The Apple libass archive is built by us rather than shipped by upstream, from pinned sources, by subtitles-libass/apple/libass-build/build-apple.sh. Nobody upstream publishes tvOS slices, which is why this exists.