From f75000ec567d088ea42c442bf6edc8f42bb2773c Mon Sep 17 00:00:00 2001 From: Cleboost Date: Mon, 30 Jun 2025 12:56:49 +0200 Subject: [PATCH 1/3] Add play button to album and playlist views Introduces a play button to both album and playlist detail views, allowing users to start playback directly from these screens. Implements new commands and delegate handling to support playing all tracks from an album or playlist, respecting the current queue behavior (e.g., random or sequential). --- psst-gui/src/delegate.rs | 49 +++++++++++++++++++++++++++++++++++++ psst-gui/src/ui/album.rs | 15 +++++++++++- psst-gui/src/ui/playlist.rs | 15 +++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/psst-gui/src/delegate.rs b/psst-gui/src/delegate.rs index 91b0ac82..1dc07b21 100644 --- a/psst-gui/src/delegate.rs +++ b/psst-gui/src/delegate.rs @@ -4,7 +4,9 @@ use druid::{ WindowDesc, WindowId, }; use std::fs; +use rand::seq::IndexedRandom; use threadpool::ThreadPool; +use std::sync::Arc; use crate::ui::playlist::{ RENAME_PLAYLIST, RENAME_PLAYLIST_CONFIRM, UNFOLLOW_PLAYLIST, UNFOLLOW_PLAYLIST_CONFIRM, @@ -18,6 +20,7 @@ use crate::{ webapi::WebApi, widget::remote_image, }; +use crate::data::Track; pub struct Delegate { main_window: Option, @@ -139,6 +142,30 @@ impl AppDelegate for Delegate { data: &mut AppState, _env: &Env, ) -> Handled { + if let Some(playlist_link) = cmd.get(crate::ui::playlist::PLAY_PLAYLIST_REQUEST) { + if let Some(tracks) = data.playlist_detail.tracks.resolved() { + play_items_with_mode( + ctx, + &tracks.tracks.iter().collect::>(), + crate::data::PlaybackOrigin::Playlist(playlist_link.clone()), + data.playback.queue_behavior, + |t: &&Arc| crate::data::Playable::Track((*t).clone()), + ); + } + return Handled::Yes; + } + if let Some(album_link) = cmd.get(crate::ui::album::PLAY_ALBUM_REQUEST) { + if let Some(album) = data.album_detail.album.resolved() { + play_items_with_mode( + ctx, + &album.data.tracks.iter().collect::>(), + crate::data::PlaybackOrigin::Album(album_link.clone()), + data.playback.queue_behavior, + |t: &&Arc| crate::data::Playable::Track((*t).clone()), + ); + } + return Handled::Yes; + } if cmd.is(cmd::SHOW_CREDITS_WINDOW) { let _window_id = self.show_credits(ctx); if let Some(track) = cmd.get(cmd::SHOW_CREDITS_WINDOW) { @@ -325,3 +352,25 @@ impl Delegate { } } } + +fn play_items_with_mode(ctx: &mut DelegateCtx, items: &[T], origin: crate::data::PlaybackOrigin, queue_behavior: crate::data::QueueBehavior, to_playable: F) +where + F: Fn(&T) -> crate::data::Playable, +{ + if !items.is_empty() { + let playables: Vec<_> = items.iter().map(&to_playable).collect(); + let is_random = queue_behavior == crate::data::QueueBehavior::Random; + let position = if is_random { + let mut rng = rand::rng(); + (0..playables.len()).collect::>().choose(&mut rng).copied().unwrap_or(0) + } else { + 0 + }; + let payload = crate::data::PlaybackPayload { + items: playables.into(), + origin, + position, + }; + ctx.submit_command(crate::cmd::PLAY_TRACKS.with(payload)); + } +} diff --git a/psst-gui/src/ui/album.rs b/psst-gui/src/ui/album.rs index eddaeea0..aaaee6c5 100644 --- a/psst-gui/src/ui/album.rs +++ b/psst-gui/src/ui/album.rs @@ -19,6 +19,7 @@ use crate::{ use super::{artist, library, playable, theme, track, utils}; pub const LOAD_DETAIL: Selector = Selector::new("app.album.load-detail"); +pub const PLAY_ALBUM_REQUEST: Selector = Selector::new("app.album.play-request"); pub fn detail_widget() -> impl Widget { Async::new( @@ -66,11 +67,23 @@ fn loaded_detail_widget() -> impl Widget>>> { .with_child(album_label) .padding(theme::grid(1.0)); + let play_button = icons::PLAY + .scale((theme::grid(2.5), theme::grid(2.5))) + .padding(theme::grid(1.0)) + .link() + .circle() + .border(theme::GREY_500, 1.0) + .on_left_click(|ctx, _, album_ctx: &mut WithCtx>, _| { + ctx.submit_command(PLAY_ALBUM_REQUEST.with(album_ctx.data.link())); + }); + let album_top = Flex::row() .with_spacer(theme::grid(4.2)) .with_child(album_cover) .with_default_spacer() - .with_child(album_info.lens(Ctx::data())); + .with_child(album_info.lens(Ctx::data())) + .with_flex_spacer(1.0) + .with_child(play_button); let album_tracks = playable::list_widget(playable::Display { track: track::Display { diff --git a/psst-gui/src/ui/playlist.rs b/psst-gui/src/ui/playlist.rs index 5a5015aa..4e308acc 100644 --- a/psst-gui/src/ui/playlist.rs +++ b/psst-gui/src/ui/playlist.rs @@ -18,11 +18,12 @@ use crate::{ error::Error, ui::menu, webapi::WebApi, - widget::{Async, Empty, MyWidgetExt, RemoteImage, ThemeScope}, + widget::{Async, Empty, MyWidgetExt, RemoteImage, ThemeScope, icons}, }; use super::{playable, theme, track, utils}; +pub const PLAY_PLAYLIST_REQUEST: Selector = Selector::new("app.playlist.play-request"); pub const LOAD_LIST: Selector = Selector::new("app.playlist.load-list"); pub const LOAD_DETAIL: Selector<(PlaylistLink, AppState)> = Selector::new("app.playlist.load-detail"); @@ -437,6 +438,16 @@ fn playlist_info_widget() -> impl Widget> { .clip(Size::new(size, size).to_rounded_rect(4.0)) .context_menu(playlist_menu_ctx); + let play_button = icons::PLAY + .scale((theme::grid(2.5), theme::grid(2.5))) + .padding(theme::grid(1.0)) + .link() + .circle() + .border(theme::GREY_500, 1.0) + .on_left_click(|ctx, _, playlist_ctx: &mut WithCtx, _| { + ctx.submit_command(PLAY_PLAYLIST_REQUEST.with(playlist_ctx.data.link())); + }); + let owner_label = Label::dynamic(|p: &Playlist, _| p.owner.display_name.as_ref().to_string()); let track_count_label = Label::dynamic(|p: &Playlist, _| { @@ -495,6 +506,8 @@ fn playlist_info_widget() -> impl Widget> { .with_child(playlist_cover) .with_default_spacer() .with_child(playlist_info.lens(Ctx::data())) + .with_flex_spacer(1.0) + .with_child(play_button) } fn async_tracks_widget() -> impl Widget { From a476bea4c9dbea93de16125acac9d824d16e7e31 Mon Sep 17 00:00:00 2001 From: Jackson Goode <54308792+jacksongoode@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:21:17 +0900 Subject: [PATCH 2/3] Add play to top bar and group with sort --- psst-gui/src/cmd.rs | 4 +++- psst-gui/src/delegate.rs | 25 +++++++++++++++++-------- psst-gui/src/ui/album.rs | 15 +-------------- psst-gui/src/ui/mod.rs | 28 ++++++++++++++++++++++++++++ psst-gui/src/ui/playlist.rs | 15 +-------------- 5 files changed, 50 insertions(+), 37 deletions(-) diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 9e897e79..e6888090 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Duration; use crate::{ - data::{Nav, PlaybackPayload, QueueBehavior, QueueEntry}, + data::{AlbumLink, Nav, PlaybackPayload, PlaylistLink, QueueBehavior, QueueEntry}, ui::find::Find, }; @@ -46,6 +46,8 @@ pub const PLAYBACK_BLOCKED: Selector = Selector::new("app.playback-blocked"); pub const PLAYBACK_STOPPED: Selector = Selector::new("app.playback-stopped"); // Playback control +pub const PLAY_ALBUM: Selector = Selector::new("app.play-album"); +pub const PLAY_PLAYLIST: Selector = Selector::new("app.play-playlist"); pub const PLAY: Selector = Selector::new("app.play-index"); pub const PLAY_TRACKS: Selector = Selector::new("app.play-tracks"); pub const PLAY_PREVIOUS: Selector = Selector::new("app.play-previous"); diff --git a/psst-gui/src/delegate.rs b/psst-gui/src/delegate.rs index 1dc07b21..57c66409 100644 --- a/psst-gui/src/delegate.rs +++ b/psst-gui/src/delegate.rs @@ -3,11 +3,12 @@ use druid::{ commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target, WindowDesc, WindowId, }; -use std::fs; use rand::seq::IndexedRandom; -use threadpool::ThreadPool; +use std::fs; use std::sync::Arc; +use threadpool::ThreadPool; +use crate::data::Track; use crate::ui::playlist::{ RENAME_PLAYLIST, RENAME_PLAYLIST_CONFIRM, UNFOLLOW_PLAYLIST, UNFOLLOW_PLAYLIST_CONFIRM, }; @@ -20,7 +21,6 @@ use crate::{ webapi::WebApi, widget::remote_image, }; -use crate::data::Track; pub struct Delegate { main_window: Option, @@ -142,7 +142,7 @@ impl AppDelegate for Delegate { data: &mut AppState, _env: &Env, ) -> Handled { - if let Some(playlist_link) = cmd.get(crate::ui::playlist::PLAY_PLAYLIST_REQUEST) { + if let Some(playlist_link) = cmd.get(cmd::PLAY_PLAYLIST) { if let Some(tracks) = data.playlist_detail.tracks.resolved() { play_items_with_mode( ctx, @@ -154,7 +154,7 @@ impl AppDelegate for Delegate { } return Handled::Yes; } - if let Some(album_link) = cmd.get(crate::ui::album::PLAY_ALBUM_REQUEST) { + if let Some(album_link) = cmd.get(cmd::PLAY_ALBUM) { if let Some(album) = data.album_detail.album.resolved() { play_items_with_mode( ctx, @@ -353,8 +353,13 @@ impl Delegate { } } -fn play_items_with_mode(ctx: &mut DelegateCtx, items: &[T], origin: crate::data::PlaybackOrigin, queue_behavior: crate::data::QueueBehavior, to_playable: F) -where +fn play_items_with_mode( + ctx: &mut DelegateCtx, + items: &[T], + origin: crate::data::PlaybackOrigin, + queue_behavior: crate::data::QueueBehavior, + to_playable: F, +) where F: Fn(&T) -> crate::data::Playable, { if !items.is_empty() { @@ -362,7 +367,11 @@ where let is_random = queue_behavior == crate::data::QueueBehavior::Random; let position = if is_random { let mut rng = rand::rng(); - (0..playables.len()).collect::>().choose(&mut rng).copied().unwrap_or(0) + (0..playables.len()) + .collect::>() + .choose(&mut rng) + .copied() + .unwrap_or(0) } else { 0 }; diff --git a/psst-gui/src/ui/album.rs b/psst-gui/src/ui/album.rs index aaaee6c5..eddaeea0 100644 --- a/psst-gui/src/ui/album.rs +++ b/psst-gui/src/ui/album.rs @@ -19,7 +19,6 @@ use crate::{ use super::{artist, library, playable, theme, track, utils}; pub const LOAD_DETAIL: Selector = Selector::new("app.album.load-detail"); -pub const PLAY_ALBUM_REQUEST: Selector = Selector::new("app.album.play-request"); pub fn detail_widget() -> impl Widget { Async::new( @@ -67,23 +66,11 @@ fn loaded_detail_widget() -> impl Widget>>> { .with_child(album_label) .padding(theme::grid(1.0)); - let play_button = icons::PLAY - .scale((theme::grid(2.5), theme::grid(2.5))) - .padding(theme::grid(1.0)) - .link() - .circle() - .border(theme::GREY_500, 1.0) - .on_left_click(|ctx, _, album_ctx: &mut WithCtx>, _| { - ctx.submit_command(PLAY_ALBUM_REQUEST.with(album_ctx.data.link())); - }); - let album_top = Flex::row() .with_spacer(theme::grid(4.2)) .with_child(album_cover) .with_default_spacer() - .with_child(album_info.lens(Ctx::data())) - .with_flex_spacer(1.0) - .with_child(play_button); + .with_child(album_info.lens(Ctx::data())); let album_tracks = playable::list_widget(playable::Display { track: track::Display { diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index ac248b10..f2d5071b 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -244,7 +244,9 @@ fn root_widget() -> impl Widget { .must_fill_main_axis(true) .with_child(topbar_back_button_widget()) .with_child(topbar_title_widget()) + .with_flex_spacer(1.0) .with_child(topbar_sort_widget()) + .with_child(topbar_play_button_widget()) .background(Border::Bottom.with_color(theme::BACKGROUND_DARK)); let main = Flex::column() @@ -467,6 +469,32 @@ fn volume_slider() -> impl Widget { ) } +fn topbar_play_button_widget() -> impl Widget { + let play_button = icons::PLAY + .scale((theme::grid(2.0), theme::grid(2.0))) + .padding(theme::grid(1.0)) + .link() + .rounded(theme::BUTTON_BORDER_RADIUS) + .on_left_click(|ctx, _, data: &mut AppState, _| match &data.nav { + Nav::AlbumDetail(link, _) => { + ctx.submit_command(cmd::PLAY_ALBUM.with(link.clone())); + } + Nav::PlaylistDetail(link) => { + ctx.submit_command(cmd::PLAY_PLAYLIST.with(link.clone())); + } + _ => {} + }); + + Either::new( + |data: &AppState, _| { + matches!(data.nav, Nav::AlbumDetail(..)) || matches!(data.nav, Nav::PlaylistDetail(..)) + }, + play_button, + Empty, + ) + .padding((0.0, 0.0, theme::grid(1.0), 0.0)) +} + fn topbar_sort_widget() -> impl Widget { let up_icon = icons::UP.scale((10.0, theme::grid(2.0))); let down_icon = icons::DOWN.scale((10.0, theme::grid(2.0))); diff --git a/psst-gui/src/ui/playlist.rs b/psst-gui/src/ui/playlist.rs index 4e308acc..5a5015aa 100644 --- a/psst-gui/src/ui/playlist.rs +++ b/psst-gui/src/ui/playlist.rs @@ -18,12 +18,11 @@ use crate::{ error::Error, ui::menu, webapi::WebApi, - widget::{Async, Empty, MyWidgetExt, RemoteImage, ThemeScope, icons}, + widget::{Async, Empty, MyWidgetExt, RemoteImage, ThemeScope}, }; use super::{playable, theme, track, utils}; -pub const PLAY_PLAYLIST_REQUEST: Selector = Selector::new("app.playlist.play-request"); pub const LOAD_LIST: Selector = Selector::new("app.playlist.load-list"); pub const LOAD_DETAIL: Selector<(PlaylistLink, AppState)> = Selector::new("app.playlist.load-detail"); @@ -438,16 +437,6 @@ fn playlist_info_widget() -> impl Widget> { .clip(Size::new(size, size).to_rounded_rect(4.0)) .context_menu(playlist_menu_ctx); - let play_button = icons::PLAY - .scale((theme::grid(2.5), theme::grid(2.5))) - .padding(theme::grid(1.0)) - .link() - .circle() - .border(theme::GREY_500, 1.0) - .on_left_click(|ctx, _, playlist_ctx: &mut WithCtx, _| { - ctx.submit_command(PLAY_PLAYLIST_REQUEST.with(playlist_ctx.data.link())); - }); - let owner_label = Label::dynamic(|p: &Playlist, _| p.owner.display_name.as_ref().to_string()); let track_count_label = Label::dynamic(|p: &Playlist, _| { @@ -506,8 +495,6 @@ fn playlist_info_widget() -> impl Widget> { .with_child(playlist_cover) .with_default_spacer() .with_child(playlist_info.lens(Ctx::data())) - .with_flex_spacer(1.0) - .with_child(play_button) } fn async_tracks_widget() -> impl Widget { From 54cbd2a5acb5de4a705a91946ca9a000fa070edd Mon Sep 17 00:00:00 2001 From: Cleboost Date: Mon, 14 Jul 2025 13:36:31 +0200 Subject: [PATCH 3/3] Refactor play_items_with_mode to early return on empty items Changed the function to return early if the items list is empty, reducing nesting and improving readability. --- psst-gui/src/delegate.rs | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/psst-gui/src/delegate.rs b/psst-gui/src/delegate.rs index 57c66409..7f98424d 100644 --- a/psst-gui/src/delegate.rs +++ b/psst-gui/src/delegate.rs @@ -362,24 +362,25 @@ fn play_items_with_mode( ) where F: Fn(&T) -> crate::data::Playable, { - if !items.is_empty() { - let playables: Vec<_> = items.iter().map(&to_playable).collect(); - let is_random = queue_behavior == crate::data::QueueBehavior::Random; - let position = if is_random { - let mut rng = rand::rng(); - (0..playables.len()) - .collect::>() - .choose(&mut rng) - .copied() - .unwrap_or(0) - } else { - 0 - }; - let payload = crate::data::PlaybackPayload { - items: playables.into(), - origin, - position, - }; - ctx.submit_command(crate::cmd::PLAY_TRACKS.with(payload)); + if items.is_empty() { + return; } + + let playables: Vec<_> = items.iter().map(&to_playable).collect(); + let position = if queue_behavior == crate::data::QueueBehavior::Random { + let mut rng = rand::rng(); + (0..playables.len()) + .collect::>() + .choose(&mut rng) + .copied() + .unwrap_or(0) + } else { + 0 + }; + let payload = crate::data::PlaybackPayload { + items: playables.into(), + origin, + position, + }; + ctx.submit_command(crate::cmd::PLAY_TRACKS.with(payload)); }