A Flutter music player that streams from any OpenSubsonic-compatible server — Navidrome, Jellyfin, Gonic, Airsonic — rather than managing local files. Nothing is downloaded: playback streams straight from the server. What the app keeps locally is its own library — songs, lyrics and play history in one SQLite database, independent of whichever server happens to serve them.
- Window shell — frameless desktop chrome ported from Solian
(
lib/shell/window_frame.dart), a responsive shell built from floating chrome, and the same design tokens (spacing, radii, warm-paper color ramp) inlib/core/theme.dart. - Server layer —
lib/library/maps Subsonic and Jellyfin onto one provider-neutral model. Subsonic uses salted token auth (never the password); Jellyfin signs in with theAuthorization: MediaBrowser …header that 10.11+ requires and keeps the issued access token in that same header. - Playback — one
media_kitplayer for the app's lifetime (lib/player/player.dart), exposed through Riverpod. Shuffle reorders the visible queue instead of hiding randomization underneath. The media session isaudio_service(lib/player/session.dart): it publishes the current track, the queue and the transport state to the notification, the lock screen and headset buttons, and routes what they send back into the same transport the UI drives. The full player is also a keyboard surface — Space, Escape, arrows, Shift+arrows andL— bound on the page's own focus, so a focused control keeps its keys. - Local library —
lib/data/local/library_database.dart(Drift, SQLite) is where the app remembers songs, whatever server serves them. A track is canonical — title, artist, album, duration and a fingerprint of them — and each source's own id for it is a link row. Adoption happens as you browse: every read the aggregator performs (lib/library/aggregate.dart) is folded into the catalog, so no full sync is needed yet. Two servers carrying the same song end up as one track with two links (fingerprint inlib/library/track_identity.dartignores bracketed credits, case, punctuation and a couple of seconds of length), which is what makes the library independent of a playback source: removing a source drops its links and keeps the track, its lyrics and its play history, so re-adding it — or adding a different one with the same songs — picks them up again. Local files can join the same rows later.play_eventsis one row per play, written when a track opens and updated as it advances (lib/player/play_tracker.dart); play counts and "recently played" are queries over it, so no counter can drift. - Lyrics —
lib/library/lyrics.dartnormalizes OpenSubsonic structured lyrics (timed lines, with romanization and translation layers folded onto the words they annotate) and LRC text.lib/widgets/lyrics_view.dartdraws the Apple Music–style column: the sung line in full tone, the rest dimmed, the playhead parked a third of the way down, a tap on a line seeking to it.lib/library/lyrics_resolver.dartdecides where the words come from: the library first when it has timed lines, otherwise LRCLIB (lib/library/lrclib.dart, identified client, one request at a time,Retry-Afterhonoured), otherwise the library's plain block. Every answer is stored inlyrics_entries(lib/library/lyrics_store.dart) against the canonical track, so the second play — and any play offline — never asks the network, and a re-tagged or re-linked song keeps its words. A confirmed miss is cached for a day; a timeout never is. - Screens — Browse (search + random shelf + recently added), Albums, album detail, Playlists, playlist detail, Stats, full Player, Setup, Settings. The queue has no page of its own: it is a pane of the player and a sheet in the dock, because a third view of one list only made the transport compete with itself.
- Stats —
lib/screens/stats.dartreadsplay_eventsand shows what the history adds up to: plays, time listened, songs heard, how many plays ran to the end, the most played tracks and artists, and the last dozen plays. All of it is aggregate SQL (LibraryDatabase.playTotals,playCountsByTrack,playCountsByArtist,latestPlays) behind oneListeningStatsread, so the page never walks the play table in Dart. A track no connected source can serve still appears — it just is not tappable.
Two surface languages, deliberately kept apart:
- Liquid glass (
lib/core/glass.dart,GlassSurface) is used in exactly two places, both of which float over a scrolling page on the compact layout: the tab capsule and the now-playing dock. They refract the content underneath, share one backdrop capture viaBackdropGroup, and pages reserve clearance withsolBottomChromeInsetso content scrolls beneath them. Everything else — the wide rail, settings, panels, the player's transport — is tonal. A persistent navigation column or a panel holding live controls and artwork is where glass costs frames and legibility without adding anything. - Island cards (
IslandCardinlib/widgets/common.dart) are for content in the scrolling plane: settings groups, the setup panel, the wide player body. Opaque, tonal, no border, no blur — body text has to stay legible and a long list must not pay for a backdrop sample per card.
The dock's lyrics button lifts the capsule into a lyric sheet instead of a route, so glancing at the next line never loses the page; the player page keeps the cover beside the words on a wide window and swaps them on a phone.
The now-playing dock is the collapsed player: tapping anywhere on the capsule
that is not a control records its rect and opens /now-playing through a
container transform (lib/shell/dock_morph.dart), so the capsule grows into the
player instead of a page sliding over it.
The wide layout's rail follows Solian: a solid column beside the page with no
divider, collapsed by default, expanded in place by the page header's menu
button (RailScope, lib/shell/rail_scope.dart). There is exactly one
navigation toggle — the header's — not one in the rail as well.
Pages and the shell select only what they need from the transport. The
position changes once a second while playing; only the dock watches all of it.
No dividers anywhere: groups are separated by tone and space, and the theme
neutralises Divider for any stray third-party use.
Wide windows get one centered content column (ContentFrame,
solContentPadding) that stops growing at kContentMaxWidth; the player
switches to a side-by-side cover/controls layout at kWideScreenWidth.
Liquid glass is a real shader and needs Flutter GPU, enabled per platform in
macos/Runner/Info.plist, ios/Runner/Info.plist and
android/app/src/main/AndroidManifest.xml. Where the shader is unavailable the
renderer falls back to a frosted approximation automatically, and Settings has
a Liquid glass switch that paints the same geometry as flat surfaces.
The app opens on the setup screen until a server is connected. Enter the server
address (a bare host works — /rest is appended for Subsonic, the root for
Jellyfin), username and password, then Test connection or Connect.
flutter pub get
dart run build_runner build # routes + riverpod providers
flutter analyze
flutter test
flutter run -d macosGenerated files: lib/core/router.gr.dart, lib/core/app.g.dart,
lib/library/providers.g.dart, lib/library/store.g.dart,
lib/player/player.g.dart. Do not edit them.
lib/
core/ theme tokens, glass surface, routing, providers, responsive helpers
library/ adapters, models, aggregation, persistence
player/ playback transport and state
screens/ one file per route
shell/ window frame, tab shell, destinations
widgets/ shared cards, rows, artwork, empty states, the dock
Fonts: Nunito (SIL OFL 1.1, see assets/fonts/OFL.txt).