Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Credits support #526

Merged
merged 11 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
218 changes: 218 additions & 0 deletions psst-gui/src/ui/credits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
use std::sync::Arc;

use crate::{
cmd,
data::{AppState, ArtistLink, Nav},
ui::theme,
ui::utils,
};
use druid::{
widget::{Controller, CrossAxisAlignment, 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)]
pub struct TrackCredits {
#[serde(rename = "trackUri")]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead using this we should use: #[serde(rename_all = "camelCase")]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

pub track_uri: String,
#[serde(rename = "trackTitle")]
pub track_title: String,
#[serde(rename = "roleCredits")]
pub role_credits: Arc<Vec<RoleCredit>>,
#[serde(rename = "extendedCredits")]
pub extended_credits: Arc<Vec<String>>,
Copy link
Contributor

@SO9010 SO9010 Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here would could have #[serde(skip_serializing_if = "Option::is_none")]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I made all the possible credits optional I believe.

#[serde(rename = "sourceNames")]
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(List::new(role_credit_widget).lens(TrackCredits::role_credits))
.with_child(
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> {
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