A music player with YouTube search, local playback, and MPRIS integration, built with the iced GUI framework.
- YouTube Music Search — Scoped search (Songs / Videos / Artists / Albums / Playlists) via ytmusicapi with yt-dlp fallback; click artists/albums/playlists to drill down into their tracks; paginated "Load More"
- Local Music — Add local audio files (MP3, FLAC, WAV, OGG, M4A, AAC, OPUS, WMA) to playlists
- Streaming + Caching — Streams audio via yt-dlp with fully native decoding (symphonia, no ffmpeg), caching to disk for instant replay
- Downloads — Download tracks to MP3 via yt-dlp, with a Downloads view and on-row indicators
- Playlists — Create, rename, delete, and organize playlists
- Library — Save albums, artists, and playlists to a persistent Library; toggle save from the card or the view header, and browse saved items from a list in the sidebar
- Radio — Song radio and artist radio based on search results
- Go to artist — Click the artist name on any track row, or pick "Go to artist" from the track's right-click context menu, to drill into that artist's view (when the source supplied a browse id)
- MPRIS — Full D-Bus MPRIS interface for media key integration
- Queue — Queue panel with Up Next and Recently Played tabs
- Drag & Drop — Drag tracks between views, into the queue, and onto sidebar playlists. Drag a playlist row in the sidebar to reorder your playlists (a placement bar shows the drop position). Drag an artist/album/playlist card (from search or the Library) onto the playlist list to turn it into a local playlist (its contents are fetched and filled in), or onto the Library to save or reorder it; a placement bar shows the drop position in both lists
- Search History — Fuzzy-searchable search history with persistent storage and inline delete
- Settings — In-app settings view to edit
config.jsonvalues (download directory, stream cache size, history/recently-played limits, and a volume-normalization toggle) live, with a reset-to-defaults action - Volume Normalization — Optional per-track loudness normalization (RMS-based, computed once via native symphonia decoding and cached) so tracks play at a consistent volume; toggle it from Settings
- Navigation History — Back/forward navigation restoring view, results, selection, and scroll
- Context Menu — Right-click menu with Play, Radio, Playlist, Download, and Remove actions; selection-aware
- Session Restore — Reopens with your last view, queue, and volume
- Lyrics — Free, no-API-key lyrics via LRCLib, behind a pluggable provider interface so more sources can be added later. The playbar Lyrics button opens a dedicated view with synced (timed) lines that seek playback when clicked, falling back to plain text or a "not found" state. Lyrics are cached on disk per track.
- Dark Theme — Dark color scheme with accent green highlights
- Rust (stable, edition 2021)
- yt-dlp — for YouTube audio streaming and downloads
- Python 3 with
ytmusicapi— for YouTube Music search (optional, falls back to yt-dlp) - D-Bus session bus (Linux) — for MPRIS
- Network access — lyrics are fetched live from LRCLib (no API key)
ffmpeg is not required. Audio is decoded natively via symphonia.
cargo build
cargo run
cargo fmt && cargo clippy
cargo test| Key | Action |
|---|---|
| Space | Toggle play/pause |
| Esc | Close search history → clear selection → return to Search |
| Delete | Delete selected tracks (playlist view only) |
| Tab | Switch keyboard focus between the track list and the queue |
| ↑/↓ | Move through the focused list (auto-scrolls) |
| Enter | Play the focused track |
| Ctrl+C | Copy selected tracks to clipboard |
| Ctrl+V | Paste clipboard tracks into the current playlist |
Esc applies the first action that matches, in the order listed. The context menu and dialogs are dismissed by clicking outside them.
Config is stored as JSON at ~/.config/music_plr/config.json and is also editable live from the in-app Settings view (sidebar → Settings):
| Field | Description | Default |
|---|---|---|
download_dir |
Directory for downloaded files | ~/Music/music_plr |
cache_max_size_mb |
Max stream cache size, in MB | 1024 |
max_search_history_stored |
Max search history entries kept on disk | 100 |
max_search_history_visible |
Max entries shown in the dropdown | 10 |
max_recently_played |
Max tracks kept in Recently Played | 50 |
volume_normalization |
Scale each track to a consistent loudness | false |
Stores follow the XDG base-directory layout: settings in the config dir,
persistent user data in the data dir (~/.local/share/music_plr), and
regenerable caches in the cache dir.
| Path | Contents |
|---|---|
~/.config/music_plr/config.json |
App config (download dir, cache size, history limits, normalization toggle) |
~/.local/share/music_plr/playlists.json |
Playlists and their tracks |
~/.local/share/music_plr/library.json |
Saved albums, artists, and playlists |
~/.local/share/music_plr/downloads.json |
Registry of downloaded tracks |
~/.local/share/music_plr/search_history.json |
Past search queries |
~/.cache/music_plr/session.json |
Last view, queue, and volume (restored session) |
~/.cache/music_plr/youtube/ |
Streamed audio cache (LRU-evicted) |
~/.cache/music_plr/thumbnails/ |
Downloaded track thumbnails |
~/.cache/music_plr/lyrics_cache.json |
Fetched lyrics, keyed by track id |
Volume-normalization gains are computed in memory each session (not written to disk) and applied on a track's second play onward.
src/
├── main.rs # Entry point — iced::application builder
├── app.rs # MusicPlayer: all state + subscription + update() dispatch
├── app/
│ ├── view_data.rs # ViewData / ViewKind / NavEntry — per-view state
│ ├── message.rs # Message + BackendResult
│ ├── interaction.rs # TrackListKind, TrackPos, DragState, ContextMenuState
│ ├── ui/ # Pure functional view() over &MusicPlayer
│ └── update/ # Handlers: playback, search, playlists, drag,
│ # selection, navigation, input, session, tick
├── audio/
│ ├── mod.rs # AudioPlayer: rodio sink + yt-dlp process management
│ ├── growing.rs # MediaSource over a still-downloading cache file
│ ├── symphonia_source.rs # Streaming symphonia decoder (rodio Source)
│ └── normalization.rs # Per-track loudness analysis (symphonia decode)
├── data/
│ ├── mod.rs # JsonStore trait + config_path()/cache_path()
│ ├── cache.rs # StreamCache: LRU file cache with eviction
│ ├── config.rs # config model (JsonStore)
│ ├── downloads.rs # DownloadRegistry
│ ├── playlists.rs # PlaylistStore
│ ├── library.rs # LibraryStore: saved albums/artists/playlists
│ ├── search_history.rs # SearchHistory
│ ├── session.rs # SessionState for restore
│ └── thumbnails.rs # Thumbnail download cache
├── theme/
│ ├── mod.rs # Palette + AppTheme
│ ├── layout.rs # Spacing, size, and geometry constants
│ └── catalog.rs # widget::*::Catalog impls for AppTheme
├── youtube.rs # Search (ytmusicapi → yt-dlp fallback) + download
├── mpris.rs # MPRIS D-Bus interface (MediaPlayer2 + Player)
├── types.rs # Track, TrackSource, PlayQueue, QueueTab
├── lyrics.rs # LyricsProvider enum + LyricsClient + LRCLib fetch
├── icons.rs # Compile-time SVG icon embedding
└── util.rs # format_duration, fuzzy_match, remove_at, reorder_tracks
- Single source of truth: All application state lives in
MusicPlayerinapp.rs. There are no parallel state mirrors —view()is a pure function of&MusicPlayer. - Functional view pattern:
view()reads directly from&MusicPlayervia iced's builder API. Nosync_*methods, no callback forwarding, noRc<RefCell<Backend>>. - Async results via mpsc: Background threads (search, download, thumbnails) send
BackendResultvariants through an mpsc channel, drained by the 250ms tick. - Flat per-view state: All view-specific state lives in a single
ViewDatastruct whosekind: ViewKindcarries only what actually differs between views (searchexhausted, radio label, selected playlist). Navigation history stores wholeViewDatasnapshots, capped at 20. - Uniform persistence: Every JSON-backed store implements the
JsonStoretrait and declares only its filename;load/saveand path resolution are shared. Failures degrade to defaults rather than panicking, since none of this data is critical to playback.
AudioPlayer runs a dedicated output thread driven by an mpsc command channel. Decoding is fully
native — there is no ffmpeg transmux step and exactly one copy of the audio on disk.
- Streaming:
yt-dlp -f bestaudio[ext=m4a]/bestaudio -o -writes raw AAC-in-M4A bytes straight to the cache file. A copy thread drains yt-dlp's stdout and clears awriter_aliveflag when done. - Decoding: A custom
SymphoniaStreamingSourcewraps a non-seekableGrowingMediaSource. Being non-seekable makes symphonia demux sequentially, so it can probe and play a still-growing file; reads block at EOF while the writer is alive. Playback starts within a few KB of download. - Replay: Cached, downloaded, and local files use the same source with
writer_alive = None, making them seekable.
MIT