a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Shift+X shuffle-play shortcut

Plays the highlighted container (album, artist, playlist, series) or,
on flat views, the whole visible list (favorites, videos, open
album/playlist tracks) in random order. The pool is Fisher-Yates
shuffled up front so the first track is random too, and shuffle mode
is switched on for anything enqueued later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

+92 -1
+8
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Added 11 + - **Shuffle-play shortcut** — `Shift+X` plays the highlighted container 12 + (album, artist, playlist, series) or, on flat views, everything in the 13 + current list (all favorite tracks, all videos, an open album/playlist's 14 + tracks) in random order. The pool is shuffled up front so the first track 15 + is random too, and shuffle mode is switched on for whatever joins the 16 + queue later. 17 + 10 18 ### Changed 11 19 - **ReplayGain now runs in the Rockbox DSP** — upgraded `rockbox-dsp` to 12 20 0.2.0 and moved gain application into its pre-gain (PGA) stage, the same
+1
Cargo.lock
··· 723 723 "fin-player", 724 724 "futures", 725 725 "parking_lot", 726 + "rand", 726 727 "ratatui", 727 728 "serde", 728 729 "serde_json",
+2 -1
README.md
··· 55 55 - **fzf-style instant search** — results update on every keystroke. 56 56 - **Drill-in navigation** — Enter on an album lists its tracks, Enter on a 57 57 series lists its episodes, Enter on a playlist lists its items. `x` 58 - plays the whole container in one go. 58 + plays the whole container in one go, `Shift+X` does the same shuffled. 59 59 - **No list truncation** — Music, Videos, and Playlists fetch every item 60 60 the server has, so nothing stays hidden past an arbitrary limit. 61 61 - **Three renderers**, one interface: ··· 340 340 | `PgUp` / `PgDown` | jump 10 rows | 341 341 | `Enter` | **drill in** on a container (album, series, playlist) — plays a leaf (track, episode, movie); on Queue → **jump** the playhead to the selected entry; on Devices → connect to the selected Chromecast / UPnP renderer; on Settings → switch server | 342 342 | `x` | play the highlighted container as one queue **without** drilling in (album → all tracks, playlist → all items) | 343 + | `Shift+X` | **shuffle-play** — same pool as `x` for a highlighted container; on flat views (Favorites, Videos, open album/playlist) the whole list, in random order, with shuffle mode switched on | 343 344 | `a` | enqueue the highlighted item | 344 345 | `n` | play the highlighted item **next** | 345 346 | `Shift+L` / `Shift+D` | **like / dislike** — add or remove the highlighted item (or the playing track) from Favorites; Jellyfin favorites, Subsonic stars |
+3
crates/fin-tui/Cargo.toml
··· 25 25 fin-jellyfin.workspace = true 26 26 fin-media.workspace = true 27 27 fin-player.workspace = true 28 + 29 + # Shift+X shuffle-play uses Fisher-Yates, same version as fin-player. 30 + rand = "0.9"
+77
crates/fin-tui/src/app.rs
··· 10 10 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 11 11 }; 12 12 use parking_lot::Mutex; 13 + use rand::seq::SliceRandom; 13 14 use ratatui::backend::CrosstermBackend; 14 15 use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}; 15 16 use ratatui::style::{Modifier, Style}; ··· 707 708 } 708 709 } 709 710 711 + /// `Shift+X` — shuffle-play. If the highlighted item is a container 712 + /// (album, artist, playlist, series) its children become the pool; 713 + /// otherwise every playable item in the current view does — all 714 + /// favorite tracks, all videos, an open album/playlist's tracks, …. 715 + /// The pool is Fisher-Yates shuffled client-side so the first track 716 + /// is random too — the queue's own shuffle flag only reshuffles 717 + /// *behind* the playhead, which would pin track 1. 718 + async fn play_all_shuffled(&mut self) { 719 + if matches!( 720 + self.screen, 721 + Screen::Queue | Screen::Devices | Screen::Settings 722 + ) { 723 + self.set_status("Shuffle-play works on library screens — z shuffles the queue."); 724 + return; 725 + } 726 + let pool = match self.selected_item() { 727 + Some(item) 728 + if matches!( 729 + item.kind(), 730 + ItemKind::MusicAlbum 731 + | ItemKind::MusicArtist 732 + | ItemKind::Playlist 733 + | ItemKind::Series 734 + ) => 735 + { 736 + match self.expand_playable(item).await { 737 + Ok(v) => v, 738 + Err(e) => { 739 + self.set_status(format!("expand: {}", e)); 740 + return; 741 + } 742 + } 743 + } 744 + _ => { 745 + // Flat views (favorites, videos, open album/playlist, 746 + // search results): take everything directly playable and 747 + // skip container rows. 748 + let mut v = self.current_list(); 749 + v.retain(|it| it.kind().is_playable()); 750 + v 751 + } 752 + }; 753 + if pool.is_empty() { 754 + self.set_status("Nothing playable here."); 755 + return; 756 + } 757 + let format = StreamFormat::Direct; 758 + let mut queue_items = Vec::with_capacity(pool.len()); 759 + for it in &pool { 760 + match self.base_item_to_queue(it, format) { 761 + Ok(q) => queue_items.push(q), 762 + Err(e) => warn!(?e, "skip item"), 763 + } 764 + } 765 + if queue_items.is_empty() { 766 + self.set_status("No playable stream URLs."); 767 + return; 768 + } 769 + queue_items.shuffle(&mut rand::rng()); 770 + let count = queue_items.len(); 771 + let renderer = self.renderer.lock().clone(); 772 + match renderer.play(queue_items, 0).await { 773 + Ok(()) => { 774 + // Flag shuffle mode ON too, so the indicator lights up and 775 + // anything enqueued later joins the deck shuffled. 776 + let _ = renderer.set_shuffle(true).await; 777 + self.set_status(format!("⤨ Shuffling {} item(s)", count)); 778 + } 779 + Err(e) => self.set_status(format!("renderer: {}", e)), 780 + } 781 + } 782 + 710 783 async fn open_album_selected(&mut self, item: BaseItem) { 711 784 let jf = self.jf(); 712 785 let out = self.album_tracks.clone(); ··· 1371 1444 (KeyCode::Char('n'), _) => app.play_selected(PlayMode::PlayNext).await, 1372 1445 // `x` — play the highlighted container *right now*, without drilling in. 1373 1446 (KeyCode::Char('x'), _) => app.play_selected(PlayMode::PlayContainer).await, 1447 + // `Shift+X` — same idea but shuffled: the highlighted container's 1448 + // tracks, or the whole visible list (favorites / videos / open 1449 + // album / playlist), in random order. 1450 + (KeyCode::Char('X'), _) => app.play_all_shuffled().await, 1374 1451 (KeyCode::Char(' '), _) | (KeyCode::Char('p'), _) => { 1375 1452 let renderer = app.renderer.lock().clone(); 1376 1453 let state = renderer.state();
+1
crates/fin-tui/src/widgets/help.rs
··· 49 49 title: "Playback", 50 50 entries: &[ 51 51 HelpEntry { key: "x", description: "play the highlighted container without drilling in" }, 52 + HelpEntry { key: "Shift+X", description: "shuffle-play — the highlighted container or the whole view (album / playlist / favorites / videos)" }, 52 53 HelpEntry { key: "a", description: "enqueue the highlighted item" }, 53 54 HelpEntry { key: "n", description: "play the highlighted item next" }, 54 55 HelpEntry { key: "Space / p", description: "pause / resume" },