Project
Rizumu — Your Music, Wherever It Lives
A deep dive into Rizumu, an Android music player that treats your own Subsonic server as a first-class library, with a two-player crossfade engine, Automix analysis, offline downloads, and a Listen Together party server.
On this page
A note on this write-up. Written with AI assistance. The architecture, code, and decisions are mine; the prose was drafted with an AI editor and reviewed before publishing.
Welcome to Rizumu
Rizumu is an Android music player built around one sentence: your music, wherever it lives.
That sounds like a tagline, but it is also an architectural constraint. Most players pick a side. They are a streaming client for somebody else's catalogue, or a local-file player that happens to have a server browser bolted on. Rizumu tries to be one app over several libraries: YouTube Music, your own Subsonic-compatible server, JioSaavn, an addon server you host yourself, or a module source that speaks to another catalogue entirely. The queue, the player, the downloads and the scrobbling all sit above that layer.
The project is a fork of BitChord by Kushagra Singh. Most of the app — the player shell, the design, the playback engine, the sources layer — is his work. I maintain the fork that adds native Subsonic/OpenSubsonic support and a server-first mode that builds the app around your own library, with YouTube Music as a fallback rather than the centre. This write-up is about how the whole thing is put together, including the parts I did not write.

Rizumu's three main surfaces: the library home, the now-playing screen, and a server playlist. The fork's server-first mode removes Google account surfaces from the main path without removing YouTube Music as a fallback.
A Fork With a Point of View
A fork needs a reason to exist beyond "the upstream project is not mine." Rizumu's reason is the server-first mode.
The upstream app is a YouTube Music client with a polished player and a large feature surface. The fork keeps that, then adds a second identity: a Subsonic client that can own the app. On first run, Rizumu asks which library it is built around. Pick My music server and Home becomes your server's home — random tracks, newest releases, playlists, artists, starred items. Library becomes shelves of your own catalogue. There is no Google account button in the UI, no sign-in prompt, no save/subscribe action, no YouTube playlist or rating. Last.fm, ListenBrainz and Discord remain, because they are accounts of their own.
YouTube Music is still there, but as a fallback: it fills gaps in search results and plays anything your server does not hold. Switching back to YouTube from Settings → Primary library does not require a re-login; the existing Google session is kept, just hidden while the server is primary.
That inversion is more than a theme. It changes which sources are asked first, which pages the tabs open, which actions exist, and what "Liked songs" means. A YouTube track can be hearted through Google's account. A server track is starred on the server that holds it. The two states are deliberately separate objects, because signing out of Google must not forget a star on your own Navidrome instance.
One App, Many Libraries
The source layer is the most important abstraction in the app, and it is deliberately narrow.
A MusicSource answers two questions: search for a name, and turn an id into a stream. That is all a catalogue behind somebody else's API can always be asked. A server that holds your files is a different shape: it can list artists, page albums, return playlists and report plays. Instead of widening the base interface until every source has to implement methods it can only lie about, Rizumu adds capability interfaces:
ServerLibrary— browse artists, albums, genres, playlists and starred items, and write playlists back.PlaybackReporter— report now-playing and finished plays to the server that owns the track.
A source implements them only if it can. The screens ask instance is ServerLibrary before offering a way in. A module or addon source that cannot browse simply does not implement the interface, and no screen has to pretend otherwise.
The configured source list is ordered, and the order is the fallback policy. User-added addons and module indices come first, then a Subsonic server, then JioSaavn, then YouTube Music. A track is pinned to the source that produced it, but the pin is a starting point rather than a cage. SourceResolver asks the pinned source first, steps over a source that fails, and — when lossless is on — asks a higher-priority source whether it holds the same recording bit-exact before settling for a lossy copy.
That "same recording" question belongs to TrackMatcher, not to the resolver. It is harder than comparing titles. The app checks duration, artist and album evidence before it substitutes one source's stream for another, because a 163-second track matched to a 189-second cut of the same song is not a quality upgrade; it is a different recording. The stream format carries the source's claimed codec, bitrate, sample rate and bit depth, but the player compares those against the decoder's own numbers once the bytes arrive. A source claiming 24/192 while the sink runs at 16/48 is exactly the failure that a "Hi-Res" badge is most likely to hide.
Server pages travel through the same detail stack as YouTube pages, using namespaced ids such as srcb:{config}::{kind}::{id}. To the navigation stack, the back button, the long-press menus and the queue, a server album is the same kind of object as a YouTube album. That is what keeps the app from growing a second UI for every server feature.
Server-First Mode
The Subsonic implementation is where the fork spends most of its design effort.
A server is a SourceKind.SUBSONIC source with its own SubsonicClient and SubsonicSource. It speaks Subsonic API 1.16.1, which covers Navidrome, Airsonic, Gonic, Ampache and Jellyfin's endpoint. Requests share the app's single OkHttp client, so DNS, connection pooling and data-usage instrumentation stay in one place.
Authentication uses the Subsonic token scheme, t = md5(password + salt), so the password itself is not sent on every request. If a server refuses token auth, the client falls back to the legacy password parameter for that server, once, rather than making the user find a setting. Cover-art and stream URLs use a per-client salt that stays stable for the life of the client, because the player and image loader cache by URL; a fresh salt per call would re-fetch the same cover on every screen.
Credentials live in EncryptedSharedPreferences. If the device's Keystore cannot be initialised, the app still runs but refuses to write the password to the unencrypted fallback store. The server stays configured for the session and asks for the password again next launch, rather than leaving a reusable credential readable on disk. Plain HTTP is blocked unless the user explicitly allows it per server. That opt-in is deliberate: every Subsonic request carries the account name and a reusable credential in its query string, so an unencrypted request is readable and replayable by anything on the network. HTTPS-to-HTTP redirects are refused outright.
Stream quality is a standing per-server choice, not a per-network one. Original (raw) is the default and asks for the file exactly as stored, ignoring the mobile-data ceiling for that server — the note under the picker says so. Match network and the fixed bitrate modes ask for a transcode. A bitrate the app names is never exceeded, and a Lossless download always asks for the original file whatever the mode. The per-network ceiling still applies to YouTube, JioSaavn and addons.
The rest of the server integration is a set of small, honest capabilities:
- Browse — artists, newest albums, random tracks, playlists and starred items; artist and album pages.
- Playlists — read, play, create, add, remove, rename and delete. Rename and delete are only offered when the server's playlist
ownermatches the configured account, because Subsonic has no capability flag for it. - Stars — song stars, written back to the server that owns the track. Writes are serialized per track through
ServerStarQueue; the optimistic UI is corrected only while no newer tap has arrived, so a rapid star/unstar cannot leave the server and the screen disagreeing. - Play reporting — now-playing and finished plays go to the server as well as Last.fm and ListenBrainz. Navidrome can forward those to ListenBrainz itself, so server tracks reach a listening history without a token in Rizumu.
- Lyrics — OpenSubsonic structured lyrics with timings, falling back to the server's tag reader.
- Downloads and offline — the normal downloader, with lossless keeping the original file.
The gaps are documented rather than hidden. Album and artist stars are not supported; only songs can be starred. Play queue sync and OpenSubsonic reportPlayback are not implemented — the legacy scrobble call is used instead, because every server understands it. Server administration, scanning, shares, podcasts, internet radio and folder browsing are out of scope.
The Playback Engine
The player is a Media3 MediaLibraryService with a foreground media session, a notification, Android Auto browsing, and a home-screen widget. The queue and the player are the same object from the UI's point of view; the service owns the session, the audio focus and the playback state.
The most interesting piece is the crossfade. A single ExoPlayer renders one queue item at a time, so at a track boundary it can only give you full volume or silence. That is why the first version of the crossfade — a volume ramp on one player — dipped to silence at the join and climbed back out. It looked like a crossfade in the settings; it sounded like a gap.
Rizumu uses two full ExoPlayers as peers. At any instant one backs the MediaSession, holds audio focus and carries the notification; the other is idle. To arm a transition, the idle player is loaded with the incoming track, positioned at the planned cue point and started silently. When its first note sounds, the service swaps roles: the incoming player becomes the session player, and the outgoing one is faded out. Nothing is re-buffered, nothing is re-sought, and no audio is rendered twice.
That last point matters. An earlier design put the outgoing track on the second player and jumped the session player to the next song. Both players then rendered the same audio for a moment, and two ExoPlayers cannot be started sample-accurately against each other. The measured misalignment was 9–41 ms, heard as the last instant of the outgoing track playing twice at the head of every crossfade. Loading the incoming track instead removes the duplication structurally.
The gain curve is equal-power:
private fun riseGain(progress: Float): Float =
sin(progress.coerceIn(0f, 1f) * PI.toFloat() / 2f)
private fun fallGain(progress: Float): Float =
cos(progress.coerceIn(0f, 1f) * PI.toFloat() / 2f)
sin² + cos² = 1, so the two tracks hold constant power through the blend and the transition has no dip in the middle. The old sqrt curve did not.
Automix is the optional smart version. It analyses tracks offline with the Beat This! ONNX model for beat and downbeat tracking, plus an open-unmix vocal mask, then plans a transition from the stored features. The planner works in bars rather than seconds: four bars is a musical overlap, while the seconds values are rails for tempos where four bars would be absurd. It can beat-match and tempo-stretch, perform a bass swap, apply a filtered handoff when tempos are too far apart to blend flat, or fall back to a fixed equal-power crossfade when there is no analysis. Spoken-word and live material is excluded from mixing. It is labelled beta because it is: the fallback is deliberately indistinguishable from the old crossfade, so the app does not advertise a "smart mix" when it is doing nothing smart.
Around the transition engine sit the less visible pieces: a gapless path for album siblings, an audio cache that warms the next track, quality upgrades that can replace a playing stream without restarting the song, silence skipping, playback speed, a system equalizer, spatial audio, and a sleep timer. The player state is split so the playhead — which moves twice a second — does not invalidate the whole Compose tree. Only the scrubber reads it.
Listen Together
Listen Together is a small party server and an Android client that keeps up to five devices on the same playhead. It is the feature most likely to be underestimated: "just broadcast play" does not work.
The server does not send "play now." It holds a position and the server time at which that position was true:
positionMs where the party was
anchorMs when that was true on the server clock
isPlaying whether the clock is running
Each device measures its offset from the server clock with ping/pong samples and keeps the sample with the smallest round trip. It can then compute the playhead locally at any instant:
serverNow = deviceNow + clockOffset
playhead = positionMs + max(0, serverNow - anchorMs) // playing
playhead = positionMs // paused
A delayed frame carries an older anchor, so it still lands the device in the right place. Resuming is scheduled slightly ahead — 350 ms by default — so every device aims at one common instant and has time to buffer, instead of starting whenever its own packet arrived. The server clock advances monotonically after reading the wall clock once, so an NTP step on the host cannot rewrite the anchor under a party. Every five seconds the server re-states the truth unprompted; a phone coming back from doze or a lost frame does not have to ask.
The queue travels separately from the playback state. The state frame carries only queueSeq, queueIndex and queueLength; the list arrives in the join snapshot, on a setQueue control, or on demand when a client notices its queueSeq is stale. That split is the reason the heartbeat stays cheap: a 50-track queue in every state frame would be roughly twenty times the bytes of everything else combined, paid continuously on other people's mobile data.
The server is a FastAPI app with a WebSocket per device and no database. A party lives in the memory of the instance holding its sockets, so the deployment is deliberately single-instance. A dropped socket keeps its slot for 45 seconds; the host is not privileged and the role passes when they leave. Identity is a claim: the client sends a user id, device id and display name from the signed-in profile, and the server cannot verify them. What it can do is mint an unguessable token on join and require it on every call afterwards. A member can lie about who they are; they cannot act as a member they are not, or join a party whose code they were not given.
The Interface
Rizumu is Jetpack Compose and Material 3, with a deliberate Apple Music silhouette: SF Pro Display, heavy tight typography, a black-and-white palette, and artwork-driven colour. The now-playing screen extracts a palette from the album art and feeds a mesh gradient and a set of backdrop shaders. Haze supplies the frosted-glass bars; on API 31 and above the blur is a real RenderEffect, and below that it falls back to a translucent scrim. The minimum is API 26, so the fallback is a supported design, not an accident.
The visual layer is also performance-sensitive. The playhead is a stable object read only by the scrubber; the palette and blur layers are kept off the once-per-second position updates; the lyrics list reads one animation clock in the draw phase rather than recomposing on every frame. Those details do not show up in a screenshot, but they are the difference between a player that feels smooth and one that burns a battery redrawing a blur.
Lyrics, Downloads, and the Boring Parts
Lyrics are a larger subsystem than they look. Rizumu can show word-synced lyrics from multiple sources, with word/syllable highlighting, background-vocal placement and an Apple-Music-style animation. The full lyrics view can also translate the current lyrics into the app's selected language. The translation keeps the original timestamps and line activation, batches text slots up to 3,500 characters with private-use delimiters, runs at most two requests at a time, and discards a partial or malformed response rather than putting the wrong words against the right beat. Word-level progress in the target language is projected from the source timing, because word order changes; it is a reading guide, not semantic alignment. Translations are cached in memory and as GZIP-compressed JSON in cacheDir, with a 2 MiB LRU cap. The provider is isolated in one file, which is the right hedge: it currently uses Google's lightweight web translation endpoint, not a versioned API.
Downloads are similarly unglamorous and carefully built. The downloader keeps the original file when the source has it, embeds title, artist, album, lyrics and cover art, and writes tags into FLAC, MP4/M4A and WebM. The three taggers are shaped by their containers: FLAC is a metadata block chain followed by untouched frames; MP4 needs its stco/co64 offsets patched when the metadata grows; WebM needs its Segment size widened without changing the byte width. Each falls back to returning the input unchanged on anything it does not recognise, because a tagging failure should cost the tags, not the file. New downloads go to Music/Rizumu; existing files under Music/BitChord are adopted for duplicate detection, not moved.
Building and Shipping
Rizumu ships as a signed APK from GitHub Releases, not through an app store. The build has two product flavors — dev (com.dev.rizumu) and prod (com.music.rizumu) — so both can be installed side by side. ABI splits produce armeabi-v7a, arm64-v8a and x86_64 APKs plus a universal APK.
Signing details live outside the repository in keystore.properties, and the release build still runs without them: it produces an unsigned APK rather than failing, because only the key holder can make a shippable one. The GitHub Actions workflow goes further than most signing steps. It decodes the keystore, checks that the first bytes are the PKCS12 DER sequence marker 3082, runs keytool -list with the store password, and doubles backslashes in properties values so java.util.Properties does not eat them. A bad secret fails with a message about the secret, not a cryptic Gradle signing error.
The build also pins R8 to 8.13.23 over the version AGP 8.10.1 bundles. The bundled 8.10.x miscompiles the now-playing screen in debug mode: a register-allocation bug emits a constant over a register holding a MeshPalette, and ART's verifier rejects the class with a VerifyError when the player composes. The JVM bytecode is valid; the fault is in dex lowering. It is the kind of bug that makes you distrust a build until you can point at the exact instruction, and the comment in build.gradle.kts does.
The app is GPLv3, and the fork keeps the upstream credits visible. That is not just licence compliance. The player shell, the design language and a large part of the playback engine came from BitChord, and an honest fork says so.
What I Would Do Differently
The service is too big. PlaybackService.kt is about 6,400 lines; MainViewModel.kt is about 4,100; NowPlayingScreen.kt is close to 8,000. The comments are unusually good, but size is still a tax on every change. I would split the service into playback, sources, downloads, scrobbling and party sync modules with explicit interfaces, and treat the current file as the integration shell.
The fork needs a maintenance budget, not just a feature budget. Every upstream change can conflict with the server-first mode, and the app carries features — YouTube, JioSaavn, addons, modules, Automix, lyrics, parties, downloads, Android Auto — that each have their own edge cases. The review pass in reviews.md found real bugs in liked-list ordering, shelf completion, star writes and playback state. Those are normal; the lesson is that a feature surface this wide needs the same discipline as the protocol work: one write path, one source of truth per state, and tests around the transitions.
Lean further into the server-first mode. The strongest thing the fork does is treat a personal library as the primary product, not a plugin. I would make that path even more complete before adding more YouTube-only surfaces: OpenSubsonic reportPlayback, album and artist stars, queue sync, and a proper server health screen that explains the difference between "down" and "wrong password."
Listen Together's trust model is fine for a party and wrong for a product. The server cannot verify the identity a client claims; it only mints a token after join. That is a reasonable trade for a feature among friends, and the code says so plainly. A public deployment would need a short-lived proof from the account layer, plus a shared store instead of one in-memory instance. I would not pretend the current design is more than it is.
The translation endpoint is a dependency, not a foundation. Google's lightweight web translation endpoint is not a versioned public API. The provider is isolated, the cache is bounded, and a malformed response leaves the original lyrics visible, but a production app should be able to swap providers without touching the timing model. I would add that interface before the current endpoint changes under it.
Closing
Rizumu is a player that refuses to choose between the catalogue and your library. The queue, the source resolver, the server-first mode and the party server all come back to the same idea: the app should know where a track came from, what it is allowed to do there, and how to keep playing when that place is slow, offline or out of reach.
The fork adds the server half. The upstream app supplied the player, the design and most of the hard playback work. The interesting engineering is in the seam between them: a source abstraction narrow enough to be honest, a resolver that can substitute without lying about the recording, and a player that can crossfade two tracks without playing either of them twice.
Repository