Skip to content

Commit 01a31e4

Browse files
committed
feat: enhance artist navigation and display in details panel with support for multiple artists
1 parent 988bd51 commit 01a31e4

6 files changed

Lines changed: 303 additions & 57 deletions

File tree

assets/tailwind.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,10 @@
11031103
border-style: var(--tw-border-style);
11041104
border-width: 1px;
11051105
}
1106+
.border-2 {
1107+
border-style: var(--tw-border-style);
1108+
border-width: 2px;
1109+
}
11061110
.border-t {
11071111
border-top-style: var(--tw-border-style);
11081112
border-top-width: 1px;
@@ -1342,6 +1346,9 @@
13421346
border-color: color-mix(in oklab, var(--color-zinc-800) 80%, transparent);
13431347
}
13441348
}
1349+
.border-t-emerald-500 {
1350+
border-top-color: var(--color-emerald-500);
1351+
}
13451352
.bg-\[\#1f2a44\] {
13461353
background-color: #1f2a44;
13471354
}

src/components/song_details/details_panel.rs

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,24 +77,94 @@ fn DetailsPanel(props: DetailsPanelProps) -> Element {
7777
.clone()
7878
.filter(|value| !value.trim().is_empty())
7979
.unwrap_or_else(|| "Unknown Artist".to_string());
80+
let song_artist_names = parse_artist_names(
81+
props
82+
.song
83+
.artist
84+
.as_deref()
85+
.unwrap_or_default(),
86+
);
87+
let direct_song_artist_id = if song_artist_names.len() == 1 {
88+
props.song.artist_id.clone()
89+
} else {
90+
None
91+
};
8092
let song_album = props
8193
.song
8294
.album
8395
.clone()
8496
.filter(|value| !value.trim().is_empty())
8597
.unwrap_or_else(|| "Unknown Album".to_string());
98+
let panel_song_id = props.song.id.clone();
8699

87-
let on_open_artist = {
88-
let mut controller = controller.clone();
100+
let make_on_open_artist_named = {
101+
let servers = servers.clone();
102+
let controller = controller.clone();
89103
let navigation = navigation.clone();
90-
let artist_id = props.song.artist_id.clone();
91104
let server_id = props.song.server_id.clone();
92-
move |_| {
93-
if let Some(artist_id) = artist_id.clone() {
94-
controller.close();
95-
navigation.navigate_to(AppView::ArtistDetailView {
96-
artist_id,
97-
server_id: server_id.clone(),
105+
let direct_song_artist_id = direct_song_artist_id.clone();
106+
let panel_song_id = panel_song_id.clone();
107+
move |artist_name: String| {
108+
let servers = servers.clone();
109+
let mut controller = controller.clone();
110+
let navigation = navigation.clone();
111+
let server_id = server_id.clone();
112+
let direct_song_artist_id = direct_song_artist_id.clone();
113+
let panel_song_id = panel_song_id.clone();
114+
move |evt: MouseEvent| {
115+
evt.stop_propagation();
116+
eprintln!(
117+
"[artist-nav.details.click] song_id={} server_id={} artist_name={}",
118+
panel_song_id, server_id, artist_name
119+
);
120+
if let Some(artist_id) = direct_song_artist_id.clone() {
121+
eprintln!(
122+
"[artist-nav.details.direct] song_id={} artist_id={} server_id={}",
123+
panel_song_id, artist_id, server_id
124+
);
125+
controller.close();
126+
navigation.navigate_to(AppView::ArtistDetailView {
127+
artist_id,
128+
server_id: server_id.clone(),
129+
});
130+
return;
131+
}
132+
133+
let server = servers().iter().find(|entry| entry.id == server_id).cloned();
134+
let Some(server) = server else {
135+
eprintln!(
136+
"[artist-nav.details.resolve] missing server for song_id={} server_id={}",
137+
panel_song_id, server_id
138+
);
139+
return;
140+
};
141+
142+
let navigation = navigation.clone();
143+
let server_id = server_id.clone();
144+
let artist_name = artist_name.clone();
145+
let mut controller = controller.clone();
146+
let song_id = panel_song_id.clone();
147+
spawn(async move {
148+
eprintln!(
149+
"[artist-nav.details.resolve.start] song_id={} query='{}' server_id={}",
150+
song_id, artist_name, server_id
151+
);
152+
if let Some(artist_id) = resolve_artist_id_for_name(server, artist_name).await {
153+
eprintln!(
154+
"[artist-nav.details.resolve.ok] song_id={} artist_id={} server_id={}",
155+
song_id, artist_id, server_id
156+
);
157+
controller.close();
158+
navigation.navigate_to(AppView::ArtistDetailView {
159+
artist_id,
160+
server_id,
161+
});
162+
} else {
163+
eprintln!(
164+
"[artist-nav.details.resolve.miss] song_id={} server_id={}",
165+
song_id, server_id
166+
);
167+
}
98168
});
99169
}
100170
}

src/components/song_details/details_panel_view.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,19 @@
5353
h3 { class: "text-xl md:text-2xl font-semibold text-white leading-tight break-words", "{props.song.title}" }
5454
div { class: "space-y-1 pt-1",
5555
p { class: "text-[10px] uppercase tracking-[0.18em] text-zinc-500", "Artist" }
56-
if props.song.artist_id.is_some() {
57-
button {
58-
class: "text-sm text-emerald-300 hover:text-emerald-200 transition-colors whitespace-normal break-words leading-snug",
59-
onclick: on_open_artist,
60-
"{song_artist}"
56+
if !song_artist_names.is_empty() {
57+
div { class: "inline-flex flex-wrap items-center justify-center gap-1",
58+
for (index, artist_name) in song_artist_names.iter().enumerate() {
59+
button {
60+
key: "song-details-artist-{props.song.id}-{artist_name}-{index}",
61+
class: "text-sm text-emerald-300 hover:text-emerald-200 transition-colors whitespace-normal break-words leading-snug",
62+
onclick: make_on_open_artist_named.clone()(artist_name.clone()),
63+
"{artist_name}"
64+
}
65+
if index + 1 < song_artist_names.len() {
66+
span { class: "text-zinc-500", "•" }
67+
}
68+
}
6169
}
6270
} else {
6371
p { class: "text-sm text-zinc-300 whitespace-normal break-words leading-snug", "{song_artist}" }

src/components/song_details/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::api::{
55
search_lyrics_candidates, LyricLine, LyricsQuery, LyricsResult, LyricsSearchCandidate,
66
NavidromeClient, ServerConfig, Song,
77
};
8+
use crate::components::views::artist_links::{parse_artist_names, resolve_artist_id_for_name};
89
use crate::components::{
910
apply_collection_shuffle_mode, generate_queue_extension_from_seed,
1011
queue_should_generate_similar_on_end, seek_to, spawn_shuffle_queue, AddIntent,

src/components/views/artist_detail.rs

Lines changed: 135 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -39,38 +39,110 @@ pub fn ArtistDetailView(artist_id: String, server_id: String) -> Element {
3939
let mut now_playing = use_context::<Signal<Option<Song>>>();
4040
let mut is_playing = use_context::<crate::components::IsPlayingSignal>().0;
4141
let shuffle_enabled = use_context::<crate::components::ShuffleEnabledSignal>().0;
42-
let visible_album_count = use_signal(|| ARTIST_ALBUM_BATCH_SIZE);
42+
let mut visible_album_count = use_signal(|| ARTIST_ALBUM_BATCH_SIZE);
43+
let mut current_artist_id = use_signal(|| artist_id.clone());
44+
let mut current_server_id = use_signal(|| server_id.clone());
4345

44-
let artist_server = servers().into_iter().find(|s| s.id == server_id);
45-
let artist_server_for_artist = artist_server.clone();
46-
let artist_server_for_top = artist_server.clone();
46+
use_effect({
47+
let artist_id = artist_id.clone();
48+
let server_id = server_id.clone();
49+
move || {
50+
if current_artist_id() != artist_id {
51+
eprintln!(
52+
"[artist-detail.route] artist_id change {} -> {}",
53+
current_artist_id(),
54+
artist_id
55+
);
56+
current_artist_id.set(artist_id.clone());
57+
visible_album_count.set(ARTIST_ALBUM_BATCH_SIZE);
58+
}
59+
if current_server_id() != server_id {
60+
eprintln!(
61+
"[artist-detail.route] server_id change {} -> {}",
62+
current_server_id(),
63+
server_id
64+
);
65+
current_server_id.set(server_id.clone());
66+
visible_album_count.set(ARTIST_ALBUM_BATCH_SIZE);
67+
}
68+
}
69+
});
70+
71+
let artist_server = servers().into_iter().find(|s| s.id == current_server_id());
4772

4873
let artist_data = use_resource(move || {
49-
let server = artist_server_for_artist.clone();
50-
let artist_id = artist_id.clone();
74+
let server_id = current_server_id();
75+
let artist_id = current_artist_id();
76+
let server = servers().into_iter().find(|s| s.id == server_id);
5177
async move {
5278
if let Some(server) = server {
79+
eprintln!(
80+
"[artist-detail.fetch.start] artist_id={} server_id={}",
81+
artist_id, server_id
82+
);
5383
let client = NavidromeClient::new(server);
54-
client.get_artist(&artist_id).await.ok()
84+
match client.get_artist(&artist_id).await {
85+
Ok((artist, albums)) => {
86+
eprintln!(
87+
"[artist-detail.fetch.ok] requested_artist_id={} returned_artist_id={} server_id={} albums={}",
88+
artist_id,
89+
artist.id,
90+
server_id,
91+
albums.len()
92+
);
93+
Some((artist, albums))
94+
}
95+
Err(err) => {
96+
eprintln!(
97+
"[artist-detail.fetch.err] artist_id={} server_id={} err={}",
98+
artist_id, server_id, err
99+
);
100+
None
101+
}
102+
}
55103
} else {
104+
eprintln!(
105+
"[artist-detail.fetch.skip] missing server artist_id={} server_id={}",
106+
artist_id, server_id
107+
);
56108
None
57109
}
58110
}
59111
});
60112

61113
let top_songs_data = use_resource({
62-
let server = artist_server_for_top.clone();
63114
let artist_data = artist_data.clone();
64115
move || {
65-
let server = server.clone();
116+
let server_id = current_server_id();
117+
let server = servers().into_iter().find(|s| s.id == server_id);
66118
let artist_name = artist_data()
67119
.and_then(|value| value.map(|(artist, _)| artist.name.clone()))
68120
.filter(|name| !name.is_empty());
69121
async move {
70122
match (server, artist_name) {
71123
(Some(server), Some(artist_name)) => {
124+
eprintln!(
125+
"[artist-detail.top.start] artist_name='{}' server_id={}",
126+
artist_name, server_id
127+
);
72128
let client = NavidromeClient::new(server);
73-
client.get_top_songs(&artist_name, 20).await.ok()
129+
match client.get_top_songs(&artist_name, 20).await {
130+
Ok(songs) => {
131+
eprintln!(
132+
"[artist-detail.top.ok] artist_name='{}' songs={}",
133+
artist_name,
134+
songs.len()
135+
);
136+
Some(songs)
137+
}
138+
Err(err) => {
139+
eprintln!(
140+
"[artist-detail.top.err] artist_name='{}' err={}",
141+
artist_name, err
142+
);
143+
None
144+
}
145+
}
74146
}
75147
_ => None,
76148
}
@@ -151,42 +223,61 @@ pub fn ArtistDetailView(artist_id: String, server_id: String) -> Element {
151223
{
152224
match artist_data() {
153225
Some(Some((artist, albums))) => {
154-
let top_songs = top_songs_data().flatten().unwrap_or_default();
155-
let cover_url = artist_server.as_ref().and_then(|server| {
156-
let client = NavidromeClient::new(server.clone());
157-
artist
158-
.cover_art
159-
.as_ref()
160-
.map(|ca| client.get_cover_art_url(ca, 500))
161-
});
162-
163-
let total_albums = albums.len();
164-
let total_songs: u32 = albums.iter().map(|a| a.song_count).sum();
165-
let current_album_limit = visible_album_count().min(total_albums);
166-
let remaining_albums = total_albums.saturating_sub(current_album_limit);
167-
let cover_element = match cover_url {
168-
Some(url) => rsx! {
169-
img {
170-
src: "{url}",
171-
alt: "{artist.name}",
172-
class: "w-full h-full object-cover",
173-
loading: "lazy",
226+
let requested_artist_id = current_artist_id();
227+
let requested_server_id = current_server_id();
228+
let server_matches = artist.server_id.is_empty()
229+
|| artist.server_id == requested_server_id;
230+
if artist.id != requested_artist_id || !server_matches {
231+
eprintln!(
232+
"[artist-detail.stale] requested_artist_id={} requested_server_id={} returned_artist_id={} returned_server_id={}",
233+
requested_artist_id,
234+
requested_server_id,
235+
artist.id,
236+
artist.server_id
237+
);
238+
rsx! {
239+
div { class: "flex flex-col items-center justify-center py-20",
240+
div { class: "w-16 h-16 rounded-full border-2 border-zinc-700 border-t-emerald-500 animate-spin mb-4" }
241+
p { class: "text-zinc-400", "Loading artist..." }
174242
}
175-
},
176-
None => rsx! {
177-
div { class: "w-full h-full flex items-center justify-center bg-gradient-to-br from-emerald-600 to-teal-700",
178-
Icon {
179-
name: "artist".to_string(),
180-
class: "w-24 h-24 text-white/70".to_string(),
243+
}
244+
} else {
245+
let top_songs = top_songs_data().flatten().unwrap_or_default();
246+
let cover_url = artist_server.as_ref().and_then(|server| {
247+
let client = NavidromeClient::new(server.clone());
248+
artist
249+
.cover_art
250+
.as_ref()
251+
.map(|ca| client.get_cover_art_url(ca, 500))
252+
});
253+
254+
let total_albums = albums.len();
255+
let total_songs: u32 = albums.iter().map(|a| a.song_count).sum();
256+
let current_album_limit = visible_album_count().min(total_albums);
257+
let remaining_albums = total_albums.saturating_sub(current_album_limit);
258+
let cover_element = match cover_url {
259+
Some(url) => rsx! {
260+
img {
261+
src: "{url}",
262+
alt: "{artist.name}",
263+
class: "w-full h-full object-cover",
264+
loading: "lazy",
181265
}
182-
}
183-
},
184-
};
266+
},
267+
None => rsx! {
268+
div { class: "w-full h-full flex items-center justify-center bg-gradient-to-br from-emerald-600 to-teal-700",
269+
Icon {
270+
name: "artist".to_string(),
271+
class: "w-24 h-24 text-white/70".to_string(),
272+
}
273+
}
274+
},
275+
};
185276

186-
rsx! {
187-
div { class: "flex flex-col md:flex-row gap-8 mb-12",
188-
div { class: "w-48 h-48 md:w-64 md:h-64 rounded-full bg-zinc-800 overflow-hidden shadow-2xl flex-shrink-0 mx-auto md:mx-0",
189-
{cover_element}
277+
rsx! {
278+
div { class: "flex flex-col md:flex-row gap-8 mb-12",
279+
div { class: "w-48 h-48 md:w-64 md:h-64 rounded-full bg-zinc-800 overflow-hidden shadow-2xl flex-shrink-0 mx-auto md:mx-0",
280+
{cover_element}
190281
}
191282
div { class: "flex flex-col justify-end text-center md:text-left",
192283
p { class: "text-sm text-zinc-400 uppercase tracking-wide mb-2 font-medium",
@@ -354,6 +445,7 @@ pub fn ArtistDetailView(artist_id: String, server_id: String) -> Element {
354445
}
355446
}
356447
}
448+
}
357449
Some(None) => rsx! {
358450
div { class: "flex flex-col items-center justify-center py-20",
359451
Icon {

0 commit comments

Comments
 (0)