The music half of the NoMercy player trio, for Kotlin Multiplatform.
  • Kotlin 86.2%
  • Swift 12.2%
  • Shell 1.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Stoney_Eagle 4f771414ed fix(test): the ui-compose module gets the temp directory too
A root `tasks.withType<Test>()` configures the ROOT project's test tasks, and
this module's are its own. The root-only fix reported green once — on a run
whose tests were UP-TO-DATE from the self-hosted runner's persistent cache — and
failed the moment they actually ran.

Same Conscrypt extraction, same container /tmp that will not take an executable.
2026-08-09 19:30:31 +02:00
.github/workflows ci: the payload download has a token 2026-08-09 19:13:48 +02:00
api fix(scrobble): NoopScrobbler is what the reference calls it 2026-08-06 15:20:55 +02:00
apple/NoMercyMusicPlayer fix(apple-music): the framework exports core, and its surface is gated 2026-07-28 17:29:12 +02:00
config/detekt feat(music-chrome): the mini-player, and a Compose module to hold it 2026-07-28 00:16:42 +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
gradle fix(deps): the core this library builds against is the core it ships with 2026-08-06 04:13:11 +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 chore(fakes): the stand-in backends can be released 2026-08-09 06:00:32 +02:00
tools fix(conformance): the fixtures are checked, and the check can actually fail 2026-07-28 13:58:32 +02:00
ui-compose fix(test): the ui-compose module gets the temp directory too 2026-08-09 19:30:31 +02:00
.gitattributes fix(conformance): the fixtures are checked, and the check can actually fail 2026-07-28 13:58:32 +02:00
.gitignore feat: the music half of the trio, with its event registry 2026-07-26 00:24:45 +02:00
build.gradle.kts fix(test): the test JVM gets a temp directory it can execute from 2026-08-09 18:38:24 +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 feat: the music half of the trio, with its event registry 2026-07-26 00:24:45 +02:00
gradlew.bat feat: the music half of the trio, with its event registry 2026-07-26 00:24:45 +02:00
LICENSE feat: the music half of the trio, with its event registry 2026-07-26 00:24:45 +02:00
README.md feat(cast): the seam every command and every frame crosses 2026-07-27 15:55:02 +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 fix(conformance): the music runner carries no leftovers from the video one 2026-07-28 15:19:48 +02:00

nomercy-music-player-kmp

The music 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 music player, which at the event level is five keys and almost all of them are about crossfade.

That smallness is the point. A listener for play uses CoreEvents.Play on a music player exactly as it does on a video player: one bus, 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.

NoMercy Connect

Connect is music playing across several devices with one of them making sound. A phone, a desktop and a television can all show the same session, and whichever one the server names is the one you hear. The others follow: they show the track and a progress bar that moves, and they never open the stream.

The server decides who is playing. No device decides for itself, because two devices each deciding is how the same song ends up playing twice a fraction of a second apart.

The library owns the protocol and the application owns the wire. You implement MusicConnectChannel over whatever connection you already hold, hand it decoded frames, and the library never sees a hub method name or a JSON key. That split is what lets one plugin serve a phone, a desktop and a television whose transports have nothing in common.

val connect = connectMusic(player, myChannel, screenScope)

// What a device that is not playing should draw.
connect.mirror.collect { render(it.item, it.positionMs, it.durationMs) }

Transport calls stay the same wherever they come from. player.play() on the device that is playing tells the server and carries on, so there is no gap in the audio for the person holding it. The same call on a device that is not playing tells the server and stops there.

The device you hear Every other device
A press reported, then applied here reported, shown, applied when the server agrees
A frame reconciled against the engine drawn, never loaded
Volume its own remembered level its own remembered level

Volume is the one thing that is never about who is playing. A phone at thirty percent and a television at eighty are both correct, so each device applies its own remembered level from every frame.

The reference behaviour is the web musicConnectPlugin, and this follows it including the parts that look like corrections: the sequence gate that drops redelivered frames, the shield that holds a press against frames the server sent before it heard about it, and the clock measurement everything about "now" is judged against. The SignalR channel and the retirement of the two older adapters belong to the application and land with app adoption.

Status

The player, the audio backends, the crossfade engine, the event registry and Connect are here and tested. The application wiring that supplies a channel over SignalR is not part of this library.