Skip to content
ryuhzkPublic

About

An Android music client for a self-hosted Jellyfin server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Kotone

An Android music client for a self-hosted Jellyfin server. Playlists, songs, folders, artists, artwork and lyrics all come from Jellyfin directly.

A personal-use project: my own phone, my own Jellyfin, my own library.


What works

Library — paged list with pull to refresh; four tabs: Playlists (a two-column cover grid, the landing tab) / Songs / Folders (drill down arbitrarily deep) / Artists. Playlists are Jellyfin's own: make one in the web app, pull to refresh, it is there. Play a whole playlist from its tile, or open it to play / shuffle / download the lot. Search covers both titles and artists. Long-press a song for play-next, queue, download.

Playback — tap a song and the visible list becomes the queue. A persistent bottom bar and the full Now Playing screen share the same controls: one button cycles in-order → shuffle → repeat-one, another opens the queue. Draggable progress bar, swipe-to-remove queue, sleep timer with a 20-second fade, notification and lock-screen controls, background playback, and a home-screen widget.

Lyrics — synced, with per-character karaoke: the current line fills from ink to coral as it is sung. Sources are tried in order: Jellyfin's .lrc → LRCLIB → NetEase. Romanization (jyutping for Cantonese, romaji for Japanese) is attached line by line above the text. When matching picks the wrong song — common here, because many filenames are pinyin — Find lyrics searches by hand and remembers the choice per track.

Offline and settings — downloads one song or a whole playlist, with a progress strip above the player bar. "Offline" means the server is unreachable, not the phone has no signal: the library screen then lists what is on the device and plays it, and Shuffle shuffles those. Server setup is its own screen and opens itself on first launch. Four languages, dark/light, Wi-Fi-only downloads, cache limit.

Not done

  • Queue drag-to-reorder — the queue is often hundreds of rows; long-press → "move to next" covers what actually gets used.
  • Album browsing — this library's album metadata is broken (see Known issues), and folders reflect what is really there.
  • Translated lyrics — NetEase's tlyric is fetched and ignored; two languages at once needs a redesign of that screen.
  • iOS.

Choices

Choice Why
Expo SDK 57 + expo-router CNG (android/ is generated, not committed) removes native project maintenance.
New Architecture Not optional — react-native-nitro-player requires it.
@osuki-dev/ui Our own component library — this app is also where it gets exercised against real CJK typography and a real library.
react-native-nitro-player MIT, JSI-based, with playlists, downloads and media-session controls. react-native-track-player V5 moved to a commercial licence, which would push that uncertainty onto everyone downstream of a GPL project. Either way Expo Go cannot be used; a dev build is required.
ExoPlayer, not mpv On Android, mpv means compiling JNI and reimplementing MediaSession, audio focus and the notification shade. ExoPlayer gets all of that for free, and this library is FLAC and MP3, which it plays natively.
No state library, no react-query The APIs are read-only. src/use-async.ts is thirty lines; the one paginated list has its own hook.
expo-file-system for settings A dozen fields in one JSON file, no extra native module.
Reanimated 4 + gesture-handler Dragging, swiping and the artwork morph have to track the finger on the UI thread.
Lingui for i18n Messages are natural language in the source and get extracted; no key namespace to maintain.
bun One package manager, one lockfile.

Licence

GPL-3.0-or-later. Three files in the lyrics layer take something from Feishin (GPL-3.0):

File What came from there
src/lyrics/lrclib.ts The endpoint, the 5-second timeout, the response type and its field names, and the "prefer syncedLyrics, fall back to plainLyrics" rule. The rest was rewritten around this project's plumbing.
src/lyrics/index.ts The fallback order (bundled .lrc → LRCLIB → NetEase). Code written here.
src/lyrics/netease.ts Which two endpoints to use — search, then fetch by id. Feishin credits Sunamu for the same, and that credit is carried in NOTICE. Code written here.

Details in NOTICE. Everything else — the Jellyfin layer, playback, downloads, offline, karaoke, romanization alignment, widget, i18n and the whole interface — is written here.

Lyrics sources, and what that implies

Lyrics come from Jellyfin's own .lrc, from LRCLIB (an open API, queried with an identifying User-Agent), and from NetEase Cloud Music.

The NetEase source is off by default, because it uses an unofficial endpoint and the lyrics it returns belong to their rights holders. Turning it on in Settings → Lyrics is a deliberate choice, and whoever runs the app is responsible for their own use of it. With it off, lyrics come only from Jellyfin and LRCLIB — and there is no romanization, since only NetEase provides it.

Kotone stores no lyrics either way: a manual pick is remembered as a NetEase track id, and the text is fetched again each time.

If you hold rights here. The NetEase source is one file — src/lyrics/netease.ts — and removing it takes deleting that file and one line in src/lyrics/index.ts. If you represent NetEase Cloud Music, a label, or another rights holder and want it gone from this project, open an issue (or write to the address in the repository profile) and it will be removed promptly. The same goes for anything else here.

The app sends nothing anywhere else: no analytics, no telemetry, no accounts. Track titles and artists are sent to the lyrics services above only while looking up lyrics.

GPL-3.0 means distribution as an APK rather than through the iOS App Store. MIT dependencies such as @osuki-dev/ui are compatible and included as they are.


Design

The interface reads like a sheet of paper, not a machine.

Coral means "now", and nothing else — the mark beside the playing row, the top of the bottom bar, the progress bar, and the characters already sung in the current lyric line. Selection, primary buttons and links are all ink.

Type gets its character from size and spacing, not from a bundled typeface: system fonts cover CJK better than anything worth shipping.

Three kinds of motion and no more (src/motion.ts): press, settle, reveal — plus glide for time itself, which is linear, because any easing makes a progress bar lie. All carry reduceMotion: System.

On Now Playing the artwork is flush to the edges, and switching to lyrics flies it into a small sleeve beside the title rather than cross-fading, because it is the same picture moved aside.


Running it

Prerequisites: Android SDK, JDK 17, a device or emulator.

bun install
bun android          # prebuild, compile, install, launch
bun start            # Metro only
bun run lint         # tsc --noEmit
bun run i18n         # extract messages, compile catalogs

The first build takes a few minutes (CMake compiles Nitro's C++). Expo Go will not work.

Pointing it at a server: the app opens the server screen on first launch. Address, username, password — that goes through AuthenticateByName and returns a user-level token, revocable on its own from Jellyfin's Dashboard → Devices. An API key works too. Settings live in the app's private directory and take effect immediately.

.env is optional and only prefills the first launch:

EXPO_PUBLIC_JELLYFIN_URL=https://jellyfin.example.com
EXPO_PUBLIC_JELLYFIN_API_KEY=xxxxxxxx

⚠️ EXPO_PUBLIC_ variables are compiled into the APK and can be extracted. Leave them empty if you hand the APK to someone else.

⚠️ Release builds forbid cleartext HTTP. Use HTTPS, or allow it explicitly in app.json.

Prebuild note: expo prebuild --clean deletes android/, including app/debug.keystore — which is what release builds are signed with. Back it up and restore it, or the next install fails on a signature mismatch and the app has to be uninstalled first, losing settings and downloads.


Layout

app/                    expo-router routes
  _layout.tsx           providers, plus the docked player bar that outlives navigation
  index.tsx             library home: playlist grid, songs, folders, artists, offline fallback
  list.tsx              folder contents and artist songs
  player.tsx            now playing (the artwork ⇄ lyrics morph lives here)
  queue.tsx             the queue (swipe away / move / clear)
  downloads.tsx         downloads = the library when offline
  server.tsx            connect a server; opens itself when nothing is configured
  settings.tsx          appearance, language, downloads, lyrics, cache
  lyrics-search.tsx     find lyrics by hand
src/
  api/jellyfin.ts       Jellyfin access layer; sign-in and API-key validation
  artwork.ts            drops "folder covers" shared by unrelated albums
  i18n/                 locale negotiation, catalogs, provider
  player/               queue writes, play cache (LRU + pinning), sleep timer, volume
  lyrics/               lrclib, netease, LRC parsing, karaoke timing, manual overrides
  components/
    thread.tsx            ★ the thread: the only colour in the app, and it means "now"
    player-controls.tsx   one control row, used by both the player screen and the bar
    playlist-card.tsx, download-strip.tsx, offline-library.tsx, action-sheet.tsx, …
  widget/               home-screen widget (drawn headless: no theme, no i18n)
  motion.ts, theme.ts   three kinds of motion; colour and type scales

Known issues (server side)

  • Album metadata is unusable — Jellyfin treats each genre directory as one album, and most FLACs carry no embedded artwork, so a playlist ends up showing the same cover on every row. The app refuses to show a cover that provably belongs to a folder (src/artwork.ts); the real fix is embedding artwork in the files, or restructuring into artist/album/.
  • .lrc files are not indexed — /Items/{id}/Lyrics returns 404, so lyrics come from LRCLIB and NetEase.
  • The directory named Playlists is detected as a MusicArtist — harmless, and unrelated to Jellyfin's own playlists, which are what the Playlists tab reads.

Languages

简体中文 / 繁體中文 / English / 日本語, following the system language unless one is pinned in settings. bun run i18n extracts and compiles the catalogs.


Credits

About

An Android music client for a self-hosted Jellyfin server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages