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:
1 parent
6601a3a5e6
commit
3410774f32
7 files changed
+799
-2
No files matched your search
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod audio_backends;
|
|||||||
pub mod audio_diagnostics;
|
pub mod audio_diagnostics;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
pub mod credits;
|
||||||
pub mod favorites;
|
pub mod favorites;
|
||||||
pub mod lastfm;
|
pub mod lastfm;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
@@ -22,6 +23,7 @@ pub use audio_backends::*;
|
|||||||
pub use audio_diagnostics::*;
|
pub use audio_diagnostics::*;
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use cache::*;
|
pub use cache::*;
|
||||||
|
pub use credits::*;
|
||||||
pub use favorites::*;
|
pub use favorites::*;
|
||||||
pub use lastfm::*;
|
pub use lastfm::*;
|
||||||
pub use notification::*;
|
pub use notification::*;
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ pub fn run() {
|
|||||||
commands::get_current_user_id,
|
commands::get_current_user_id,
|
||||||
commands::subscribe_playlist,
|
commands::subscribe_playlist,
|
||||||
commands::get_track_info,
|
commands::get_track_info,
|
||||||
|
commands::get_album_credits,
|
||||||
// Playlist import commands
|
// Playlist import commands
|
||||||
commands::playlist_import_preview,
|
commands::playlist_import_preview,
|
||||||
commands::playlist_import_execute,
|
commands::playlist_import_execute,
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { Play, ChevronDown, ChevronUp, Loader2, X } from 'lucide-svelte';
|
||||||
|
import type { AlbumCredits, TrackCredits } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
albumId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onTrackPlay?: (track: TrackCredits) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, albumId, onClose, onTrackPlay }: Props = $props();
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let credits = $state<AlbumCredits | null>(null);
|
||||||
|
let expandedTracks = $state<Set<number>>(new Set());
|
||||||
|
let hoveredTrack = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Load album credits when modal opens
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen && albumId) {
|
||||||
|
loadAlbumCredits(albumId);
|
||||||
|
} else {
|
||||||
|
credits = null;
|
||||||
|
error = null;
|
||||||
|
expandedTracks = new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAlbumCredits(id: string) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
credits = await invoke<AlbumCredits>('get_album_credits', { albumId: id });
|
||||||
|
// Auto-expand first track if it has credits
|
||||||
|
if (credits.tracks.length > 0 && credits.tracks[0].performers.length > 0) {
|
||||||
|
expandedTracks = new Set([credits.tracks[0].id]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
credits = null;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTrack(trackId: number) {
|
||||||
|
const newSet = new Set(expandedTracks);
|
||||||
|
if (newSet.has(trackId)) {
|
||||||
|
newSet.delete(trackId);
|
||||||
|
} else {
|
||||||
|
newSet.add(trackId);
|
||||||
|
}
|
||||||
|
expandedTracks = newSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackPlay(track: TrackCredits, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTrackPlay?.(track);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="modal-overlay" onclick={onClose} role="dialog" aria-modal="true">
|
||||||
|
<div class="credits-modal" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<!-- Header with tabs -->
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active">Credits</button>
|
||||||
|
<button class="tab disabled" disabled>Review</button>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose} aria-label="Close">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="modal-content">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Loader2 size={32} class="spinner" />
|
||||||
|
<span>Loading credits...</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error-state">
|
||||||
|
<p>Failed to load album credits</p>
|
||||||
|
<span class="error-message">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if credits}
|
||||||
|
<div class="credits-layout">
|
||||||
|
<!-- Left column: Album info -->
|
||||||
|
<div class="album-info">
|
||||||
|
<img
|
||||||
|
src={credits.album.artwork}
|
||||||
|
alt={credits.album.title}
|
||||||
|
class="album-artwork"
|
||||||
|
/>
|
||||||
|
<h2 class="album-title">{credits.album.title}</h2>
|
||||||
|
<p class="album-artist">{credits.album.artist}</p>
|
||||||
|
|
||||||
|
<div class="album-meta">
|
||||||
|
{#if credits.album.label}
|
||||||
|
<p class="meta-row">
|
||||||
|
<span class="meta-label">Released by</span>
|
||||||
|
<span class="meta-value label-name">{credits.album.label}</span>
|
||||||
|
{#if credits.album.release_date}
|
||||||
|
<span class="meta-date">on {new Date(credits.album.release_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if credits.album.genre}
|
||||||
|
<p class="meta-row">
|
||||||
|
<span class="meta-value">{credits.album.genre}</span>
|
||||||
|
<span class="meta-separator">-</span>
|
||||||
|
<span class="meta-value">{credits.album.track_count} Tracks</span>
|
||||||
|
<span class="meta-separator">-</span>
|
||||||
|
<span class="meta-value">{credits.album.duration}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="quality-badges">
|
||||||
|
{#if credits.album.bit_depth}
|
||||||
|
<span class="badge">{credits.album.bit_depth}-Bit</span>
|
||||||
|
{/if}
|
||||||
|
{#if credits.album.sampling_rate}
|
||||||
|
<span class="badge">{credits.album.sampling_rate} kHz - Stereo</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Track list -->
|
||||||
|
<div class="tracks-list">
|
||||||
|
{#each credits.tracks as track (track.id)}
|
||||||
|
{@const isExpanded = expandedTracks.has(track.id)}
|
||||||
|
{@const isHovered = hoveredTrack === track.id}
|
||||||
|
{@const hasCredits = track.performers.length > 0 || track.copyright}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="track-panel"
|
||||||
|
class:expanded={isExpanded}
|
||||||
|
class:has-credits={hasCredits}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="track-header"
|
||||||
|
onclick={() => hasCredits && toggleTrack(track.id)}
|
||||||
|
onmouseenter={() => hoveredTrack = track.id}
|
||||||
|
onmouseleave={() => hoveredTrack = null}
|
||||||
|
disabled={!hasCredits}
|
||||||
|
>
|
||||||
|
<div class="track-number">
|
||||||
|
{#if isHovered && onTrackPlay}
|
||||||
|
<button
|
||||||
|
class="play-btn"
|
||||||
|
onclick={(e) => handleTrackPlay(track, e)}
|
||||||
|
aria-label="Play track"
|
||||||
|
>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span>{track.number}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="track-info">
|
||||||
|
<span class="track-title">{track.title}</span>
|
||||||
|
<span class="track-artist">{track.artist}</span>
|
||||||
|
</div>
|
||||||
|
{#if hasCredits}
|
||||||
|
<div class="track-chevron">
|
||||||
|
{#if isExpanded}
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown size={18} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isExpanded && hasCredits}
|
||||||
|
<div class="track-credits">
|
||||||
|
{#each track.performers as performer}
|
||||||
|
<div class="performer-row">
|
||||||
|
<span class="performer-name">{performer.name}</span>
|
||||||
|
{#if performer.roles.length > 0}
|
||||||
|
<span class="performer-roles">, {performer.roles.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if track.copyright}
|
||||||
|
<div class="copyright">{track.copyright}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fade-in 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-modal {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slide-up 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state :global(.spinner) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Album Info - Left Column */
|
||||||
|
.album-info {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-artwork {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-artist {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-date {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-separator {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tracks List - Right Column */
|
||||||
|
.tracks-list {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-panel.expanded {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-panel.expanded .track-chevron {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-credits {
|
||||||
|
padding: 0 12px 12px 52px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performer-row {
|
||||||
|
padding: 4px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.performer-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.performer-roles {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--bg-hover);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { ArrowLeft, Play, Shuffle, Heart, CloudDownload, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
import { ArrowLeft, Play, Shuffle, Heart, CloudDownload, ChevronLeft, ChevronRight, Info } from 'lucide-svelte';
|
||||||
import AlbumCard from '../AlbumCard.svelte';
|
import AlbumCard from '../AlbumCard.svelte';
|
||||||
import TrackRow from '../TrackRow.svelte';
|
import TrackRow from '../TrackRow.svelte';
|
||||||
import AlbumMenu from '../AlbumMenu.svelte';
|
import AlbumMenu from '../AlbumMenu.svelte';
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
onRelatedAlbumShareSonglink?: (albumId: string) => void;
|
onRelatedAlbumShareSonglink?: (albumId: string) => void;
|
||||||
onViewArtistDiscography?: () => void;
|
onViewArtistDiscography?: () => void;
|
||||||
checkRelatedAlbumDownloaded?: (albumId: string) => Promise<boolean>;
|
checkRelatedAlbumDownloaded?: (albumId: string) => Promise<boolean>;
|
||||||
|
onShowAlbumCredits?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -132,7 +133,8 @@
|
|||||||
onRelatedAlbumShareQobuz,
|
onRelatedAlbumShareQobuz,
|
||||||
onRelatedAlbumShareSonglink,
|
onRelatedAlbumShareSonglink,
|
||||||
onViewArtistDiscography,
|
onViewArtistDiscography,
|
||||||
checkRelatedAlbumDownloaded
|
checkRelatedAlbumDownloaded,
|
||||||
|
onShowAlbumCredits
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isFavorite = $state(false);
|
let isFavorite = $state(false);
|
||||||
@@ -349,6 +351,15 @@
|
|||||||
fill={isFavorite ? 'var(--accent-primary)' : 'none'}
|
fill={isFavorite ? 'var(--accent-primary)' : 'none'}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
{#if onShowAlbumCredits}
|
||||||
|
<button
|
||||||
|
class="action-btn-circle"
|
||||||
|
onclick={onShowAlbumCredits}
|
||||||
|
title="Album credits"
|
||||||
|
>
|
||||||
|
<Info size={18} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<AlbumMenu
|
<AlbumMenu
|
||||||
onPlayNext={onPlayAllNext}
|
onPlayNext={onPlayAllNext}
|
||||||
onPlayLater={onPlayAllLater}
|
onPlayLater={onPlayAllLater}
|
||||||
|
|||||||
@@ -44,6 +44,42 @@ export interface TrackInfo {
|
|||||||
performers: Performer[];
|
performers: Performer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Album credits - consolidated view from album header
|
||||||
|
export interface AlbumCredits {
|
||||||
|
album: AlbumInfo;
|
||||||
|
tracks: TrackCredits[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumInfo {
|
||||||
|
id: string;
|
||||||
|
artwork: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
artist_id?: number;
|
||||||
|
year: string;
|
||||||
|
release_date?: string;
|
||||||
|
label: string;
|
||||||
|
genre: string;
|
||||||
|
quality: string;
|
||||||
|
track_count: number;
|
||||||
|
duration: string;
|
||||||
|
bit_depth?: number;
|
||||||
|
sampling_rate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackCredits {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
duration: string;
|
||||||
|
duration_seconds: number;
|
||||||
|
performers: Performer[];
|
||||||
|
copyright?: string;
|
||||||
|
album_id?: string;
|
||||||
|
artist_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QobuzAlbum {
|
export interface QobuzAlbum {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -283,6 +283,7 @@
|
|||||||
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
|
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
|
||||||
import PlaylistImportModal from '$lib/components/PlaylistImportModal.svelte';
|
import PlaylistImportModal from '$lib/components/PlaylistImportModal.svelte';
|
||||||
import TrackInfoModal from '$lib/components/TrackInfoModal.svelte';
|
import TrackInfoModal from '$lib/components/TrackInfoModal.svelte';
|
||||||
|
import AlbumCreditsModal from '$lib/components/AlbumCreditsModal.svelte';
|
||||||
import CastPicker from '$lib/components/CastPicker.svelte';
|
import CastPicker from '$lib/components/CastPicker.svelte';
|
||||||
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
||||||
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
||||||
@@ -343,6 +344,10 @@
|
|||||||
let isTrackInfoOpen = $state(false);
|
let isTrackInfoOpen = $state(false);
|
||||||
let trackInfoTrackId = $state<number | null>(null);
|
let trackInfoTrackId = $state<number | null>(null);
|
||||||
let userPlaylists = $state<{ id: number; name: string; tracks_count: number }[]>([]);
|
let userPlaylists = $state<{ id: number; name: string; tracks_count: number }[]>([]);
|
||||||
|
|
||||||
|
// Album Credits Modal State
|
||||||
|
let isAlbumCreditsOpen = $state(false);
|
||||||
|
let albumCreditsAlbumId = $state<string | null>(null);
|
||||||
|
|
||||||
// Sidebar reference for refreshing playlists
|
// Sidebar reference for refreshing playlists
|
||||||
let sidebarRef: { getPlaylists: () => { id: number; name: string; tracks_count: number }[], refreshPlaylists: () => void } | undefined;
|
let sidebarRef: { getPlaylists: () => { id: number; name: string; tracks_count: number }[], refreshPlaylists: () => void } | undefined;
|
||||||
@@ -1678,6 +1683,12 @@
|
|||||||
isTrackInfoOpen = true;
|
isTrackInfoOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Album Credits Modal
|
||||||
|
function showAlbumCredits(albumId: string) {
|
||||||
|
albumCreditsAlbumId = albumId;
|
||||||
|
isAlbumCreditsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth Handlers
|
// Auth Handlers
|
||||||
async function handleStartOffline() {
|
async function handleStartOffline() {
|
||||||
// Enable manual offline mode and enter app without authentication
|
// Enable manual offline mode and enter app without authentication
|
||||||
@@ -2501,6 +2512,7 @@
|
|||||||
onRelatedAlbumShareSonglink={shareAlbumSonglinkById}
|
onRelatedAlbumShareSonglink={shareAlbumSonglinkById}
|
||||||
onViewArtistDiscography={handleViewArtistDiscography}
|
onViewArtistDiscography={handleViewArtistDiscography}
|
||||||
checkRelatedAlbumDownloaded={checkAlbumFullyDownloaded}
|
checkRelatedAlbumDownloaded={checkAlbumFullyDownloaded}
|
||||||
|
onShowAlbumCredits={() => selectedAlbum && showAlbumCredits(selectedAlbum.id)}
|
||||||
/>
|
/>
|
||||||
{:else if activeView === 'artist' && selectedArtist}
|
{:else if activeView === 'artist' && selectedArtist}
|
||||||
<ArtistDetailView
|
<ArtistDetailView
|
||||||
@@ -2852,6 +2864,25 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Album Credits Modal -->
|
||||||
|
<AlbumCreditsModal
|
||||||
|
isOpen={isAlbumCreditsOpen}
|
||||||
|
albumId={albumCreditsAlbumId}
|
||||||
|
onClose={() => {
|
||||||
|
isAlbumCreditsOpen = false;
|
||||||
|
albumCreditsAlbumId = null;
|
||||||
|
}}
|
||||||
|
onTrackPlay={(trackCredits) => {
|
||||||
|
// Find the corresponding track in the selected album and play it
|
||||||
|
if (selectedAlbum?.tracks) {
|
||||||
|
const track = selectedAlbum.tracks.find(t => t.id === trackCredits.id);
|
||||||
|
if (track) {
|
||||||
|
handleAlbumTrackPlay(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Cast Picker -->
|
<!-- Cast Picker -->
|
||||||
<CastPicker
|
<CastPicker
|
||||||
isOpen={isCastPickerOpen}
|
isOpen={isCastPickerOpen}
|
||||||
|
|||||||
Reference in new issue
Block a user