Skip to content

Commit

Permalink
Credits support (#526)
Browse files Browse the repository at this point in the history
Co-authored-by: so9010 <[email protected]>
Co-authored-by: Samuel Oldham <[email protected]>
  • Loading branch information
3 people authored Oct 24, 2024
1 parent bb143b6 commit 0292319
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 16 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ cargo bundle --release
- [x] Audio loudness normalization
- [x] Genre playlists and "For You" content
- [x] Dark theme
- [x] Credits support
- [ ] Resilience to network errors (automatically retry timed-out requests)
- [ ] Managing playlists
- Follow/unfollow
Expand Down
9 changes: 7 additions & 2 deletions psst-gui/src/cmd.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use std::time::Duration;

use crate::data::Track;
use druid::{Selector, WidgetId};
use psst_core::{item_id::ItemId, player::item::PlaybackItem};
use std::sync::Arc;
use std::time::Duration;

use crate::{
data::{Nav, PlaybackPayload, QueueBehavior, QueueEntry},
Expand Down Expand Up @@ -66,3 +67,7 @@ pub const SORT_BY_DURATION: Selector = Selector::new("app.sort-by-duration");

// Sort direction control
pub const TOGGLE_SORT_ORDER: Selector = Selector::new("app.toggle-sort-order");

// Track credits
pub const SHOW_CREDITS_WINDOW: Selector<Arc<Track>> = Selector::new("app.credits-show-window");
pub const LOAD_TRACK_CREDITS: Selector<Arc<Track>> = Selector::new("app.credits-load");
3 changes: 3 additions & 0 deletions psst-gui/src/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub use crate::data::{
user::{PublicUser, UserProfile},
utils::{Cached, Float64, Image, Page},
};
use crate::ui::credits::TrackCredits;

pub const ALERT_DURATION: Duration = Duration::from_secs(5);

Expand All @@ -86,6 +87,7 @@ pub struct AppState {
pub finder: Finder,
pub added_queue: Vector<QueueEntry>,
pub lyrics: Promise<Vector<TrackLines>>,
pub credits: Option<TrackCredits>,
}

impl AppState {
Expand Down Expand Up @@ -169,6 +171,7 @@ impl AppState {
alerts: Vector::new(),
finder: Finder::new(),
lyrics: Promise::Empty,
credits: None,
}
}
}
Expand Down
53 changes: 51 additions & 2 deletions psst-gui/src/delegate.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use druid::{
commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target, WindowId,
commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target,
WindowDesc, WindowId,
};
use threadpool::ThreadPool;

use crate::ui::playlist::{
RENAME_PLAYLIST, RENAME_PLAYLIST_CONFIRM, UNFOLLOW_PLAYLIST, UNFOLLOW_PLAYLIST_CONFIRM,
};
use crate::ui::theme;
use crate::{
cmd,
data::{AppState, Config},
Expand All @@ -17,6 +19,7 @@ use crate::{
pub struct Delegate {
main_window: Option<WindowId>,
preferences_window: Option<WindowId>,
credits_window: Option<WindowId>,
image_pool: ThreadPool,
size_updated: bool,
}
Expand All @@ -28,6 +31,7 @@ impl Delegate {
Self {
main_window: None,
preferences_window: None,
credits_window: None,
image_pool: ThreadPool::with_name("image_loading".into(), MAX_IMAGE_THREADS),
size_updated: false,
}
Expand Down Expand Up @@ -88,13 +92,39 @@ impl Delegate {
ctx.submit_command(commands::CLOSE_ALL_WINDOWS);
self.main_window = None;
self.preferences_window = None;
self.credits_window = None;
}

fn close_preferences(&mut self, ctx: &mut DelegateCtx) {
if let Some(id) = self.preferences_window.take() {
ctx.submit_command(commands::CLOSE_WINDOW.to(id));
}
}

fn close_credits(&mut self, ctx: &mut DelegateCtx) {
if let Some(id) = self.credits_window.take() {
ctx.submit_command(commands::CLOSE_WINDOW.to(id));
}
}

fn show_credits(&mut self, ctx: &mut DelegateCtx) -> WindowId {
match self.credits_window {
Some(id) => {
ctx.submit_command(commands::SHOW_WINDOW.to(id));
id
}
None => {
let window = WindowDesc::new(ui::credits::credits_widget())
.title("Track Credits")
.window_size((theme::grid(50.0), theme::grid(55.0)))
.resizable(false);
let window_id = window.id;
self.credits_window = Some(window_id);
ctx.new_window(window);
window_id
}
}
}
}

impl AppDelegate<AppState> for Delegate {
Expand All @@ -106,7 +136,17 @@ impl AppDelegate<AppState> for Delegate {
data: &mut AppState,
_env: &Env,
) -> Handled {
if cmd.is(cmd::SHOW_MAIN) {
if cmd.is(cmd::SHOW_CREDITS_WINDOW) {
let _window_id = self.show_credits(ctx);
if let Some(track) = cmd.get(cmd::SHOW_CREDITS_WINDOW) {
ctx.submit_command(
cmd::LOAD_TRACK_CREDITS
.with(track.clone())
.to(Target::Global),
);
}
Handled::Yes
} else if cmd.is(cmd::SHOW_MAIN) {
self.show_main(&data.config, ctx);
Handled::Yes
} else if cmd.is(cmd::SHOW_ACCOUNT_SETUP) {
Expand All @@ -124,6 +164,11 @@ impl AppDelegate<AppState> for Delegate {
self.close_preferences(ctx);
return Handled::Yes;
}
} else if let Some(window_id) = self.credits_window {
if target == Target::Window(window_id) {
self.close_credits(ctx);
return Handled::Yes;
}
}
Handled::No
} else if let Some(text) = cmd.get(cmd::COPY) {
Expand Down Expand Up @@ -159,6 +204,10 @@ impl AppDelegate<AppState> for Delegate {
_env: &Env,
ctx: &mut DelegateCtx,
) {
if self.credits_window == Some(id) {
self.credits_window = None;
data.credits = None;
}
if self.preferences_window == Some(id) {
self.preferences_window.take();
data.preferences.reset();
Expand Down
228 changes: 228 additions & 0 deletions psst-gui/src/ui/credits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
use std::sync::Arc;

use crate::widget::Empty;
use crate::{
cmd,
data::{AppState, ArtistLink, Nav},
ui::theme,
ui::utils,
};
use druid::{
widget::{Controller, CrossAxisAlignment, Either, Flex, Label, List, Maybe, Painter, Scroll},
Cursor, Data, Env, Event, EventCtx, Lens, RenderContext, Target, UpdateCtx, Widget, WidgetExt,
};
use serde::Deserialize;

#[derive(Debug, Clone, Data, Lens, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackCredits {
pub track_uri: String,
pub track_title: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub role_credits: Arc<Vec<RoleCredit>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub extended_credits: Arc<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub source_names: Arc<Vec<String>>,
}

#[derive(Debug, Clone, Data, Lens, Deserialize)]
pub struct RoleCredit {
#[serde(rename = "roleTitle")]
pub role_title: String,
pub artists: Arc<Vec<CreditArtist>>,
}

#[derive(Debug, Clone, Data, Lens, Deserialize)]
pub struct CreditArtist {
pub uri: Option<String>,
pub name: String,
#[serde(rename = "imageUri")]
pub image_uri: Option<String>,
#[serde(rename = "externalUrl")]
pub external_url: Option<String>,
#[serde(rename = "creatorUri")]
pub creator_uri: Option<String>,
#[serde(default)]
pub subroles: Arc<Vec<String>>,
#[serde(default)]
pub weight: f64,
}

pub fn credits_widget() -> impl Widget<AppState> {
Scroll::new(
Maybe::new(
|| {
Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Label::new(|data: &TrackCredits, _: &_| data.track_title.clone())
.with_font(theme::UI_FONT_MEDIUM)
.with_text_size(theme::TEXT_SIZE_LARGE)
.padding(theme::grid(2.0))
.expand_width(),
)
.with_child(Either::new(
|data: &TrackCredits, _| data.role_credits.is_empty(),
Empty,
List::new(role_credit_widget).lens(TrackCredits::role_credits),
))
.with_child(Either::new(
|data: &TrackCredits, _| data.source_names.is_empty(),
Empty,
Label::new(|data: &TrackCredits, _: &_| {
format!("Source: {}", data.source_names.join(", "))
})
.with_text_size(theme::TEXT_SIZE_SMALL)
.with_text_color(theme::PLACEHOLDER_COLOR)
.padding(theme::grid(2.0)),
))
.padding(theme::grid(2.0))
},
utils::spinner_widget,
)
.lens(AppState::credits)
.controller(CreditsController),
)
.vertical()
.expand()
}

fn role_credit_widget() -> impl Widget<RoleCredit> {
Either::new(
|role: &RoleCredit, _| role.artists.is_empty(),
Empty,
Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Label::new(|role: &RoleCredit, _: &_| capitalize_first(&role.role_title))
.with_text_size(theme::TEXT_SIZE_NORMAL)
.padding((theme::grid(2.0), theme::grid(1.0))),
)
.with_child(
List::new(credit_artist_widget)
.lens(RoleCredit::artists)
.padding((theme::grid(2.0), 0.0, theme::grid(2.0), 0.0)),
),
)
}

fn credit_artist_widget() -> impl Widget<CreditArtist> {
let painter = Painter::new(|ctx, data: &CreditArtist, env| {
let bounds = ctx.size().to_rect();

if ctx.is_hot() && data.uri.is_some() {
ctx.fill(bounds, &env.get(theme::LINK_HOT_COLOR));
} else if data.uri.is_some() {
ctx.fill(bounds, &env.get(theme::LINK_COLD_COLOR));
}

if ctx.is_active() && data.uri.is_some() {
ctx.fill(bounds, &env.get(theme::LINK_ACTIVE_COLOR));
}
});

Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(
Label::new(|artist: &CreditArtist, _: &_| proper_case(&artist.name))
.with_font(theme::UI_FONT_MEDIUM),
)
.with_child(
Label::new(|artist: &CreditArtist, _: &_| {
capitalize_first(&artist.subroles.join(", "))
})
.with_text_size(theme::TEXT_SIZE_SMALL)
.with_text_color(theme::PLACEHOLDER_COLOR),
)
.padding(theme::grid(1.0))
.expand_width()
.background(painter)
.rounded(theme::BUTTON_BORDER_RADIUS)
.on_click(|ctx: &mut EventCtx, data: &mut CreditArtist, _: &Env| {
if let Some(uri) = &data.uri {
let artist_id = uri.split(':').last().unwrap_or("").to_string();
let artist_link = ArtistLink {
id: artist_id.into(),
name: data.name.clone().into(),
};
ctx.submit_command(
cmd::NAVIGATE
.with(Nav::ArtistDetail(artist_link))
.to(Target::Global),
);
}
})
.disabled_if(|artist: &CreditArtist, _| artist.uri.is_none())
.controller(CursorController),
)
.padding((0.0, theme::grid(0.5)))
}

struct CursorController;

impl<W: Widget<CreditArtist>> Controller<CreditArtist, W> for CursorController {
fn event(
&mut self,
child: &mut W,
ctx: &mut EventCtx,
event: &Event,
data: &mut CreditArtist,
env: &Env,
) {
if let Event::MouseMove(_) = event {
if data.uri.is_some() {
ctx.set_cursor(&Cursor::Pointer);
} else {
ctx.clear_cursor();
}
}
child.event(ctx, event, data, env)
}
}

fn proper_case(s: &str) -> String {
s.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(f) => {
f.to_uppercase().collect::<String>() + chars.as_str().to_lowercase().as_str()
}
}
})
.collect::<Vec<String>>()
.join(" ")
}

fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
}
}

/// Controller that handles updating the credits view when data changes
pub struct CreditsController;

impl<W: Widget<AppState>> Controller<AppState, W> for CreditsController {
fn update(
&mut self,
child: &mut W,
ctx: &mut UpdateCtx,
old_data: &AppState,
data: &AppState,
env: &Env,
) {
if !old_data.credits.same(&data.credits) {
ctx.request_layout();
ctx.request_paint();
}
child.update(ctx, old_data, data, env)
}
}
Loading

0 comments on commit 0292319

Please sign in to comment.