New to the code? Start here. This is the map — where things live and how they fit together — so you can find the file you need without reading all 250 of them first.
It's a companion to architecture.md, which explains the why (the layering, the golden rule, the extension points). This doc is the where. When a section gets deep, it hands off to the focused per-feature doc.
A quick orientation, then a "where does X live?" index, then a short tour of each area, and finally the rule that matters most: what must never leak.
Linthra is a layered Flutter app. Four directories, one rule:
lib/
app/ wiring: router, theme, design tokens
core/ the domain layer — framework-free. models, interfaces, source impls
data/ concrete storage: the Drift database, repository implementations
features/ one folder per screen (library, player, settings, …)
shared/ a few reusable widgets
The golden rule: features/ depend on interfaces in core/, never on a
concrete service or a database. That seam is why a new provider or a swapped
playback engine doesn't ripple into the UI.
Two data-flow ideas explain almost everything:
- Sources sync into a local catalog; the UI reads the catalog. A
MusicSource(local folder, Jellyfin, Subsonic) fetches tracks and writes them into the on-device SQLite cache (MusicLibraryRepository). Screens watch that repository — never a server directly. That's what makes the app instant and fully offline. - All playback goes through one controller. The now-playing screen,
mini-player, lock screen, and Android Auto all read a single
PlaybackStatefrom onePlaybackController. Whether sound comes from the phone or a Cast device, the UI follows that one seam.
Dependencies are wired with Riverpod providers. When
you want to know "what's the real implementation behind this interface?", find
the *_provider.dart (or *_providers.dart) file — that's where a concrete
class is bound to its interface, and where tests swap in fakes.
| Looking for… | Start at |
|---|---|
| Playback & the queue | lib/core/services/playback_controller.dart (interface), active_playback_controller.dart (router) |
| The actual audio engine | lib/core/services/just_audio_playback_controller.dart |
| Background playback / lock screen / Android Auto | lib/core/services/linthra_audio_handler.dart, media_browser_tree.dart |
| Turning a track into a playable URL | lib/core/services/playable_uri_resolver.dart + the resolvers beside it |
| Cast / Chromecast | lib/core/services/cast/ and lib/features/player/cast/ |
| Local-files provider | lib/core/sources/local/ |
| Jellyfin provider | lib/core/sources/jellyfin/ |
| Navidrome / Subsonic provider | lib/core/sources/subsonic/ |
| What each provider can do (capabilities) | lib/core/sources/music_provider.dart |
| Downloads & offline cache | lib/data/repositories/cache_download_repository.dart |
| Smart pre-cache | lib/core/services/smart_precache_service.dart |
| The local library the UI reads | lib/core/repositories/music_library_repository.dart + lib/data/database/ |
| Search | lib/features/library/library_search.dart |
| Albums / artists grouping + text folding | lib/core/catalog/library_grouping.dart, lib/core/catalog/text_folding.dart (shared by the Library UI and the Android Auto browse tree) |
| Playlists & favorites | lib/features/playlists/, lib/features/favorites/ |
| Smart mixes | lib/features/smart_mixes/, lib/core/services/smart_playlist_resolver.dart |
| Diagnostics & "Report a bug" | lib/core/diagnostics/, lib/features/settings/bug_report/ |
| Settings UI | lib/features/settings/settings_screen.dart + section folders beside it |
| Stored preferences vs. secrets | lib/data/repositories/shared_preferences_*.dart vs. secure_*_session_store.dart |
Everything that makes sound lives under lib/core/services/, and the UI only
ever talks to one thing: PlaybackController
(playback_controller.dart). It owns playback and the up-next queue —
playTracks, skipToNext, seek, shuffle, and repeat — and emits immutable
PlaybackState snapshots. The widgets hold no playback logic of their own.
Three layers sit behind that interface:
ActivePlaybackController(active_playback_controller.dart) is the router. It picks the active output — the phone or a Cast device — and merges the receiver's position/play-state onto the local queue while casting, so the UI follows whatever is actually playing. This is the seam that prevents Cast desync.JustAudioPlaybackController(just_audio_playback_controller.dart) is the real engine and the only file that knowsjust_audioexists. It owns thePlaybackQueue, the shuffle/repeat modes, volume normalization, and error recovery.LinthraAudioHandler(linthra_audio_handler.dart) bridges toaudio_service— the media notification, lock-screen controls, and the Android Auto browse tree (media_browser_tree.dart). It's a thin adapter, not a second engine: it listens to the controller's state and forwards platform button presses back.
The models are plain data: PlaybackState, PlaybackQueue (a pure value type
with shuffled()/unshuffled() transforms), and RepeatMode.
How a Track becomes something the engine can open is a small chain of
PlayableUriResolvers (playable_uri_resolver.dart), composed offline-first:
OfflineFirstPlayableUriResolver— if a downloaded copy exists, play the local file and stop here.StreamPreloadingResolver— otherwise reuse a pre-warmed stream URL if one is fresh.RoutingPlayableUriResolver— otherwise route to the per-source resolver (jellyfin_playable_uri_resolver.dart,subsonic_playable_uri_resolver.dart, or the local one), which mints the real URL.
Remote URL minting — and the credentials woven into those URLs — lives here,
never in the audio layer. The UI entry point that wires all of this is
lib/features/player/player_providers.dart. Deeper dives:
streaming.md, background-playback.md,
queue.md.
Cast lives in two places: the seam in lib/core/services/cast/ and the UI in
lib/features/player/cast/. As with playback, the UI only touches an
interface — CastService (cast_service.dart) — and renders a CastState.
DefaultCastService(default_cast_service.dart) owns discovery, session lifecycle, and the hand-off when the playing track changes.ChromecastCastTransport(chromecast_cast_transport.dart) is the thin layer that actually speaks the Cast protocol (mDNS discovery, the TLS session). It's pure Dart — no Google Play Services.CastMediaResolverturns a track into a castable media item.RoutingCastMediaResolverdispatches tojellyfin_cast_media_resolver.dartorsubsonic_cast_media_resolver.dart.CastLoadMessagebuilds the Cast v2LOADpayload.- On platforms without Cast,
UnavailableCastServiceis bound instead, so the rest of the app doesn't special-case it.
What's sent to the receiver: the authenticated stream URL plus display metadata (title, artist, album, and — for Jellyfin — a token-free artwork URL). That's it. The receiver never sees your queue, your other tracks, or app state.
Token rules (read this before touching Cast). The stream URL carries the
credential, and it is minted on demand at cast time and lives only on the
CastMedia object handed to the transport. It is never written to CastState,
never logged, never persisted. CastMedia.toString() deliberately redacts the
URL down to scheme://host/path so it's safe to interpolate into a log line.
Subsonic cover-art URLs embed the credential, so Subsonic artwork is
intentionally omitted from the cast payload rather than leaked. Full picture:
cast.md.
A "provider" is a music backend. They all implement one contract —
MusicSource (lib/core/services/music_source.dart) — and each declares what
it can actually do through a small capability model in
lib/core/sources/music_provider.dart (canStream, canCache, canCast,
canSyncFavorites, canLyrics, and friends). The UI reads those flags so it
only offers actions that work, instead of scattering if (jellyfin) checks
everywhere.
Three sources ship today, each in its own folder under lib/core/sources/:
- Local files —
local/.LocalMusicSourcescans a folder you pick. On Android the picker returns a Storage Access Framework tree URI, soaudio_file_scanner.dartwalks it through the content resolver (saf_document_lister.dart) with no broad storage permission. See the SAF section in architecture.md. - Jellyfin —
jellyfin/.JellyfinMusicSourceorchestrates an HTTP client (http_jellyfin_client.dartbehind theJellyfinClientinterface), with URL building injellyfin_endpoints.dart, wire-to-Trackconversion injellyfin_track_mapper.dart, and sign-in injellyfin_authenticator.dart. - Navidrome / Subsonic —
subsonic/. Same shape:SubsonicMusicSource,http_subsonic_client.dart,subsonic_endpoints.dart,subsonic_track_mapper.dart,subsonic_authenticator.dart.
Jellyfin and Subsonic look like near-mirror images on purpose — they're two
independent protocols, so the duplication is healthy, not accidental. If you're
adding WebDAV/NAS, copy that shape: implement MusicSource, declare your
capabilities, and let the rest of the app stay unchanged.
The sync seam: a source's job is to fetch tracks and hand them to
MusicLibraryRepository.upsertCatalog(...), which writes them into the local
SQLite cache (lib/data/database/, Drift). The library/sync controllers in
features/ drive that. The UI then reads the catalog, never the server. More:
providers.md, jellyfin.md,
streaming.md.
The whole download policy lives in one place:
lib/data/repositories/cache_download_repository.dart (the DownloadRepository
interface is in core/). Keeping it in one file is deliberate — there's a
single place to reason about when a download is allowed, whether it fits,
and what gets evicted.
- Manual "Keep offline." A user tap calls
requestDownload(track). Remote tracks get fetched and pinned; on-device tracks are just recorded (nothing to fetch). Bytes land in an app-private directory viafile_system_offline_file_store.dart; the track↔file mapping persists in shared preferences. The UI for this islib/features/downloads/. - Smart pre-cache.
lib/core/services/smart_precache_service.dartwatches playback and quietly warms the next few upcoming tracks. It's best-effort and invisible: pre-cached entries don't show up as downloads and are evicted before any track you explicitly kept. Settings live infeatures/settings/precache/. - Wi-Fi vs. mobile data. There is exactly one chokepoint — the
_networkDecision()check insideCacheDownloadRepository. Both manual downloads and pre-cache pass through it: Wi-Fi is always allowed, mobile data only with the explicit opt-in (features/settings/network/), offline never. Default is Wi-Fi only. - Half-written files. A download fetches its bytes fully, then commits under a serialized lock so concurrent downloads can't jointly overshoot the cache limit. If you remove or clear the track while its bytes are still in flight, a cancellation guard makes the commit a no-op — no file, no metadata, no status — so a partial fetch can never masquerade as a finished download. The cache file name is derived from the non-secret track id (sanitized so an odd id can't escape the directory), never from a token or URL.
Deeper dive (including the eviction policy): offline-cache.md.
The library is the browsing half of the app, in lib/features/library/. It
reads the local catalog through MusicLibraryRepository
(library_controller.dart watches it) and never touches a source directly.
- Search is client-side and instant: pure functions in
library_search.dartfilter the in-memory list as you type, normalizing case and accents so "Bjork" finds "Björk". - Albums & artists aren't stored as separate rows — they're derived from
the track list by
lib/core/catalog/library_grouping.dart, which is the one place grouping and stable IDs live so the list, the detail screens, and the Android Auto browse tree never disagree. - Playlists (
lib/features/playlists/) are user-authored and have their own lifecycle (PlaylistRepository), separate from the source-derived catalog; they sync to Jellyfin where supported. - Favorites (
lib/features/favorites/) are a hybrid: local for on-device tracks, synced with Jellyfin as the source of truth for remote ones, toggled optimistically and token-free. - Smart mixes (
lib/features/smart_mixes/) are automatic collections (recently added/played, most played, favorites, downloaded, random, never played) computed on-device by the pureSmartPlaylistResolver(core/services/smart_playlist_resolver.dart) from signals that stay on the device.
Reusable list rows (widgets/track_tile.dart, album_tile.dart,
artist_tile.dart, alphabet_track_list.dart) keep these screens consistent.
More: library.md, playlists-and-delete.md,
smart-mixes.md.
The friendliest way to report a bug is built into the app — Settings → Report a bug — and the whole point is that it's secret-free by construction.
lib/features/settings/bug_report/is the screen and form; it composes a Markdown report from a diagnostics snapshot plus a short event log, which you review and then copy or open as a prefilled GitHub issue. Nothing is ever auto-sent.lib/core/diagnostics/app_diagnostics.dartbuilds the snapshot. The data object can only hold report-safe values by design: server addresses are reduced to host (hostOnly), filesystem paths to…/basename(redactPath), and track ids are hashed, never raw.lib/core/diagnostics/safe_event_log.dartis a small ring buffer of recent events. Each event is a fixed category plus a structural detail (lifecycle,bg-playback,output,precache,error-by-kind) — there's no free-text field for a secret to slip into.StabilityDiagnostics(core/services/) is what writes to it.
Never included: tokens, passwords, authenticated/stream URLs, full server URLs, raw track ids, track titles, or local file paths. If you add a diagnostic, keep it on that side of the line. Details: reporting-bugs.md.
lib/features/settings/settings_screen.dart is just a host: it stacks one
section widget per concern, each in its own folder beside it — jellyfin/,
subsonic/, cache/, network/, precache/, playback/, bug_report/,
diagnostics/. Each section follows the same small pattern: a widget for the
UI and a controller that reads/writes persistence. The repetition is
intentional — each controller is a handful of trivial lines, and a generic
abstraction would hide more than it saved.
Persistence comes in two tiers, and the split is a security boundary:
- Plain preferences (non-secret scalars: "allow mobile data", cache size,
"normalize volume", pre-cache count) go in shared preferences —
lib/data/repositories/shared_preferences_*.dart. - Secrets (your Jellyfin / Subsonic session token) go in
flutter_secure_storage, encrypted at rest —secure_jellyfin_session_store.dart,secure_subsonic_session_store.dart. The settings state objects deliberately hold no token: only the URL, username, and server name needed to render the screen.
A single rule runs through playback, Cast, providers, downloads, and diagnostics — it's worth stating in one place:
- The password is used once, then discarded. Sign-in trades it for a session token; the password is never stored.
- Tokens are encrypted at rest (
flutter_secure_storage), never written to shared preferences, logs, or diagnostics, and never shown on screen. - Authenticated stream URLs are minted on demand and never persisted — not to disk, not to the cache (file names come from the non-secret track id), not to logs, not to a bug report.
- Logs and the event log are secret-free by design. They carry categories and counts, not ids, URLs, or credentials. Keep them that way.
If your change touches auth, streaming, Cast, or diagnostics, say so in the PR description so reviewers can check this quickly. The same rules are summarized for contributors in CONTRIBUTING.md.
- architecture.md — the layering, the golden rule, and the extension points (this is the natural next read).
- development.md — setup in two commands, the verify script, and how CI runs.
- The per-feature docs linked throughout, and the table in the README.
Found something here that's out of date? Fixing it is a genuinely useful first contribution — this map only stays helpful if it tracks the code.