- Kotlin 92%
- Swift 7.2%
- Shell 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .github/workflows | ||
| api | ||
| apple/NoMercyPlayer | ||
| config/detekt | ||
| contract | ||
| docs | ||
| gradle | ||
| scenarios | ||
| src | ||
| subtitles-libass | ||
| tools | ||
| ui-compose | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| contract.lock | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| scenarios.lock | ||
| settings.gradle.kts | ||
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.