
NoirPlayer
Dark-themed Flutter music player for Android, with a local library plus Last.fm-powered discovery, MP3 downloads, background playback with full media-control integration, a native equalizer, and rich playback settings.
Problem
Most lightweight Android music players get one of two things wrong: playback dies the moment the app leaves the foreground, or the library is a flat file list with no sense of the device's actual media. NoirPlayer is a Flutter player built to behave like a real music app: background playback with lock-screen and notification controls, a library queried straight from the device's media store with a tabbed UI, gapless transitions, and a proper Now Playing screen, wrapped in a dark, noir-styled interface. It began as a strictly local player and has since grown an online Discover tab (trending tracks and search that stream and download straight into the local library) without disturbing that offline core.
Architecture
The Flutter UI never talks to the audio engine directly. It sends commands to audio_service, which owns the Android media session. That's what keeps music playing when the app is backgrounded and what powers the notification and lock-screen controls. audio_service delegates actual decoding and playback to just_audio (queue management, gapless transitions, seeking). On the library side, on_audio_query reads songs, albums, and artists from the device MediaStore, feeding the tabbed library views. Online discovery and downloads run through a separate MusicDiscoveryService (covered below), so the playback core stays untouched whether a track came from disk or the Discover tab.
Tech decisions & trade-offs
Why audio_service + just_audio instead of one plugin
The split mirrors how Android actually works: a media session (what the OS sees: notification, lock screen, headset buttons) is a different concern from a media player (decoding and output). audio_service handles the first, just_audio the second, and they're designed to compose. The trade-off is boilerplate (an AudioHandler bridging the two), but the payoff is playback that survives backgrounding, audio focus changes, and headset events the way users expect. Committing to just_audio for the player layer kept paying off: its AndroidEqualizer later gave a native per-band equalizer (frequency-labelled sliders with Flat / Bass / Vocal / Treble presets) without bolting on a separate audio-effects plugin.
Why query the MediaStore instead of scanning files
Walking the filesystem for .mp3s is the naive approach: it's slow, needs broad storage permissions, and misses the metadata Android already indexed. on_audio_query reads the system's own media database: instant library loads, album art and tags included, with narrower permissions. The cost is fidelity to whatever the MediaStore knows: files with broken tags show as <unknown>, which is the honest state of the data rather than a parsing failure.
Why the download path chains three APIs
No single API hands you a downloadable MP3 for an arbitrary track, so the Discover tab composes three. Last.fm supplies trending lists, search, and album art; the chosen track's title is resolved to a YouTube video id via the YouTube Data API; and a RapidAPI youtube-mp36 endpoint turns that id into an MP3 URL, which is stream-downloaded with dio (progress-aware) and written to the device Music folder through media_store_plus, with duplicate-download protection. The trade-off is a multi-hop pipeline with three keys to manage, but each hop uses the best source for its job, and because the keys load from a gitignored .env, the whole module is optional: without them the local player runs exactly as before and only the Discover tab goes dark.
Why a local-first core
Streaming apps live and die by their backend; a player that owns the user's local collection works on a plane and has zero ongoing cost. NoirPlayer keeps that local core as the foundation and layers online discovery on top rather than the other way around: the library, playback, and controls all work with no network and no API keys, and Discover is purely additive. That ordering kept the architecture testable: the playback path never depends on a network call, so the online features could ship without putting the offline experience at risk.
Branches
Online discovery and downloads started as experiments and have since shipped to main (v1.0.0 / v1.1.0). The repo keeps the earlier spikes around:
main: the full, released app: local library and background playback plus online discovery and MP3 downloads.download-music: the original prototype for online search and downloading, where the Last.fm → YouTube → MP3 flow was first worked out before landing in the releases.firebase: a Firebase-backed variant exploring cloud auth and features, alongside UI work like the system-theme option that follows the device's light/dark setting.
