feat(credits): add Album Credits Modal (Phase 4)

- Add get_album_credits Tauri command with TrackCredits, AlbumCredits,
  and AlbumInfo structs for consolidated album/track credits view
- Create AlbumCreditsModal component with two-column layout:
  album info (artwork, metadata, quality badges) and collapsible
  track list with performers and copyright
- Add Info button to AlbumDetailView header actions
- Integrate modal in +page.svelte with track play support
This commit is contained in:
vicrodh committed 2026-01-25 02:53:46 -06:00
1 parent 6601a3a5e6
commit 3410774f32
7 files changed
+799 -2

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
//! Track credits and album credits Tauri commands
use serde::Serialize;
use tauri::State;
use crate::api::models::Album;
use crate::api::performers::{parse_performers, Performer};
use crate::AppState;
/// Track credits with parsed performers
#[derive(Debug, Clone, Serialize)]
pub struct TrackCredits {
pub id: u64,
pub number: u32,
pub title: String,
pub artist: String,
pub duration: String,
pub duration_seconds: u32,
pub performers: Vec<Performer>,
pub copyright: Option<String>,
pub album_id: Option<String>,
pub artist_id: Option<u64>,
}
/// Album credits response with all tracks
#[derive(Debug, Clone, Serialize)]
pub struct AlbumCredits {
pub album: AlbumInfo,
pub tracks: Vec<TrackCredits>,
}
/// Album metadata for credits modal
#[derive(Debug, Clone, Serialize)]
pub struct AlbumInfo {
pub id: String,
pub artwork: String,
pub title: String,
pub artist: String,
pub artist_id: Option<u64>,
pub year: String,
pub release_date: Option<String>,
pub label: String,
pub genre: String,
pub quality: String,
pub track_count: u32,
pub duration: String,
pub bit_depth: Option<u32>,
pub sampling_rate: Option<f64>,
}
/// Format duration in seconds to "Xm Ys" or "Xh Ym" format
fn format_duration(seconds: u32) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if hours > 0 {
format!("{}h {}m", hours, minutes)
} else {
format!("{}m {}s", minutes, secs)
}
}
/// Format track duration to "M:SS" format
fn format_track_duration(seconds: u32) -> String {
let minutes = seconds / 60;
let secs = seconds % 60;
format!("{}:{:02}", minutes, secs)
}
/// Build quality string from bit depth and sample rate
fn format_quality(bit_depth: Option<u32>, sampling_rate: Option<f64>) -> String {
match (bit_depth, sampling_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {}kHz", bd, sr),
(Some(bd), None) => format!("{}-bit", bd),
(None, Some(sr)) => format!("{}kHz", sr),
(None, None) => "Lossless".to_string(),
}
}
/// Convert API Album to AlbumInfo
fn album_to_info(album: &Album) -> AlbumInfo {
let year = album
.release_date_original
.as_ref()
.and_then(|d| d.split('-').next())
.unwrap_or("")
.to_string();
let total_duration = album.duration.unwrap_or(0);
AlbumInfo {
id: album.id.clone(),
artwork: album.image.large.clone().unwrap_or_default(),
title: album.title.clone(),
artist: album.artist.name.clone(),
artist_id: if album.artist.id > 0 { Some(album.artist.id) } else { None },
year,
release_date: album.release_date_original.clone(),
label: album.label.as_ref().map(|l| l.name.clone()).unwrap_or_default(),
genre: album.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
quality: format_quality(album.maximum_bit_depth, album.maximum_sampling_rate),
track_count: album.tracks_count.unwrap_or(0),
duration: format_duration(total_duration),
bit_depth: album.maximum_bit_depth,
sampling_rate: album.maximum_sampling_rate,
}
}
/// Get album credits with all tracks and parsed performers
#[tauri::command]
pub async fn get_album_credits(
album_id: String,
state: State<'_, AppState>,
) -> Result<AlbumCredits, String> {
log::info!("Command: get_album_credits {}", album_id);
let client = state.client.lock().await;
// Fetch the album with tracks
let album = client
.get_album(&album_id)
.await
.map_err(|e| format!("Failed to get album: {}", e))?;
// Convert album to info
let album_info = album_to_info(&album);
// Process tracks with performers
let tracks: Vec<TrackCredits> = album
.tracks
.as_ref()
.map(|tc| {
tc.items
.iter()
.map(|track| {
let performers = track
.performers
.as_ref()
.map(|p| parse_performers(p))
.unwrap_or_default();
TrackCredits {
id: track.id,
number: track.track_number,
title: track.title.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_else(|| album.artist.name.clone()),
duration: format_track_duration(track.duration),
duration_seconds: track.duration,
performers,
copyright: track.copyright.clone(),
album_id: Some(album.id.clone()),
artist_id: track.performer.as_ref().and_then(|p| {
if p.id > 0 { Some(p.id) } else { None }
}),
}
})
.collect()
})
.unwrap_or_default();
Ok(AlbumCredits {
album: album_info,
tracks,
})
}
+2
View File
@@ -6,6 +6,7 @@ pub mod audio_backends;
pub mod audio_diagnostics;
pub mod auth;
pub mod cache;
pub mod credits;
pub mod favorites;
pub mod lastfm;
pub mod notification;
@@ -22,6 +23,7 @@ pub use audio_backends::*;
pub use audio_diagnostics::*;
pub use auth::*;
pub use cache::*;
pub use credits::*;
pub use favorites::*;
pub use lastfm::*;
pub use notification::*;
+1
View File
@@ -485,6 +485,7 @@ pub fn run() {
commands::get_current_user_id,
commands::subscribe_playlist,
commands::get_track_info,
commands::get_album_credits,
// Playlist import commands
commands::playlist_import_preview,
commands::playlist_import_execute,