feat(immersive): replace Credits panel with Track Info panel
- Create TrackInfoPanel with full track metadata and credits - Display: duration, quality, ISRC, label, and all performer credits - Uses same backend as TrackInfoModal (get_track_info command) - Rename tab from 'Credits' to 'Track Info' (player.trackInfo) - Update keyboard shortcut from 'C' to 'T' for Track Info tab - Fix actions.clearQueue -> player.clearQueue translation key - Add i18n translations for trackInfo in EN/ES - Delete old placeholder CreditsPanel
This commit is contained in:
1 parent
282fffc6a4
commit
fe274c466d
8 files changed
+390
-85
No files matched your search
@@ -2,7 +2,7 @@
|
||||
import { X, Disc3, LayoutGrid, Mic2, ListMusic, Music2, Info, Radio } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
export type ImmersiveTab = 'lyrics' | 'credits' | 'suggestions' | 'queue';
|
||||
export type ImmersiveTab = 'lyrics' | 'trackInfo' | 'suggestions' | 'queue';
|
||||
export type DisplayMode = 'coverflow' | 'split' | 'lyrics-focus' | 'queue-focus';
|
||||
|
||||
interface Props {
|
||||
@@ -13,7 +13,7 @@
|
||||
onClose: () => void;
|
||||
visible?: boolean;
|
||||
hasLyrics?: boolean;
|
||||
hasCredits?: boolean;
|
||||
hasTrackInfo?: boolean;
|
||||
hasSuggestions?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
onClose,
|
||||
visible = true,
|
||||
hasLyrics = true,
|
||||
hasCredits = true,
|
||||
hasTrackInfo = true,
|
||||
hasSuggestions = true
|
||||
}: Props = $props();
|
||||
|
||||
const tabs = $derived([
|
||||
{ id: 'lyrics' as const, label: $t('player.lyrics'), icon: Music2, enabled: hasLyrics },
|
||||
{ id: 'credits' as const, label: $t('player.credits') || 'Credits', icon: Info, enabled: hasCredits },
|
||||
{ id: 'trackInfo' as const, label: $t('player.trackInfo') || 'Track Info', icon: Info, enabled: hasTrackInfo },
|
||||
{ id: 'suggestions' as const, label: $t('player.suggestions') || 'Suggestions', icon: Radio, enabled: hasSuggestions },
|
||||
{ id: 'queue' as const, label: $t('player.queue') || 'Queue', icon: ListMusic, enabled: true },
|
||||
].filter(tab => tab.enabled));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import ImmersiveHeader, { type ImmersiveTab, type DisplayMode } from './ImmersiveHeader.svelte';
|
||||
import ImmersiveControls from './ImmersiveControls.svelte';
|
||||
import LyricsPanel from './panels/LyricsPanel.svelte';
|
||||
import CreditsPanel from './panels/CreditsPanel.svelte';
|
||||
import TrackInfoPanel from './panels/TrackInfoPanel.svelte';
|
||||
import SuggestionsPanel from './panels/SuggestionsPanel.svelte';
|
||||
import QueuePanel from './panels/QueuePanel.svelte';
|
||||
import CoverflowPanel from './panels/CoverflowPanel.svelte';
|
||||
@@ -177,9 +177,9 @@
|
||||
case 'L':
|
||||
if (displayMode === 'split') activeTab = 'lyrics';
|
||||
break;
|
||||
case 'c':
|
||||
case 'C':
|
||||
if (displayMode === 'split' && enableCredits) activeTab = 'credits';
|
||||
case 't':
|
||||
case 'T':
|
||||
if (displayMode === 'split' && enableCredits) activeTab = 'trackInfo';
|
||||
break;
|
||||
case 's':
|
||||
case 'S':
|
||||
@@ -235,7 +235,7 @@
|
||||
{onClose}
|
||||
visible={showUI}
|
||||
hasLyrics={true}
|
||||
hasCredits={enableCredits}
|
||||
hasTrackInfo={enableCredits}
|
||||
hasSuggestions={enableSuggestions}
|
||||
/>
|
||||
|
||||
@@ -292,8 +292,8 @@
|
||||
isLoading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
/>
|
||||
{:else if activeTab === 'credits'}
|
||||
<CreditsPanel {trackId} />
|
||||
{:else if activeTab === 'trackInfo'}
|
||||
<TrackInfoPanel {trackId} />
|
||||
{:else if activeTab === 'suggestions'}
|
||||
<SuggestionsPanel {trackId} {artistId} />
|
||||
{:else if activeTab === 'queue'}
|
||||
|
||||
@@ -7,7 +7,7 @@ export { default as ImmersiveControls } from './ImmersiveControls.svelte';
|
||||
|
||||
// Panels
|
||||
export { default as LyricsPanel } from './panels/LyricsPanel.svelte';
|
||||
export { default as CreditsPanel } from './panels/CreditsPanel.svelte';
|
||||
export { default as TrackInfoPanel } from './panels/TrackInfoPanel.svelte';
|
||||
export { default as SuggestionsPanel } from './panels/SuggestionsPanel.svelte';
|
||||
|
||||
// Shared
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Info } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
trackId?: number;
|
||||
}
|
||||
|
||||
let { trackId }: Props = $props();
|
||||
|
||||
// TODO: Implement credits fetching - needs albumId and MusicBrainz integration
|
||||
let isLoading = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="credits-panel">
|
||||
<div class="placeholder">
|
||||
<div class="placeholder-icon">
|
||||
<Info size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span class="placeholder-text">Credits</span>
|
||||
<span class="placeholder-hint">Producer, Composer, Musicians...</span>
|
||||
<span class="coming-soon">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.credits-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
font-size: 13px;
|
||||
color: var(--alpha-30, rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
margin-top: 8px;
|
||||
padding: 6px 14px;
|
||||
background: var(--alpha-10, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
|
||||
}
|
||||
</style>
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
{#if hasUpcoming && onClear}
|
||||
<button class="clear-btn" onclick={onClear}>
|
||||
{$t('actions.clearQueue') || 'Clear'}
|
||||
{$t('player.clearQueue') || 'Clear'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Loader2, Disc3, Clock, Music2, Tag, Building2 } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { TrackInfo, Performer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
trackId?: number;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { trackId, onArtistClick, onLabelClick }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let trackInfo = $state<TrackInfo | null>(null);
|
||||
|
||||
// Format duration from seconds to M:SS
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format quality string
|
||||
function formatQuality(track: TrackInfo['track']): string {
|
||||
const parts: string[] = [];
|
||||
if (track.maximum_bit_depth) {
|
||||
parts.push(`${track.maximum_bit_depth}-bit`);
|
||||
}
|
||||
if (track.maximum_sampling_rate) {
|
||||
parts.push(`${track.maximum_sampling_rate}kHz`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return track.hires_streamable ? 'Hi-Res' : 'Lossless';
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
// Convert CamelCase role to display format
|
||||
function formatRole(role: string): string {
|
||||
return role.replace(/([A-Z])/g, ' $1').trim();
|
||||
}
|
||||
|
||||
// Group performers by role
|
||||
function getGroupedCredits(performers: Performer[]): { role: string; names: string[] }[] {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
|
||||
performers.forEach(performer => {
|
||||
performer.roles.forEach(role => {
|
||||
if (!grouped[role]) {
|
||||
grouped[role] = [];
|
||||
}
|
||||
if (!grouped[role].includes(performer.name)) {
|
||||
grouped[role].push(performer.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Priority ordering
|
||||
const priorityFirst = ['Composer', 'Lyricist', 'Producer'];
|
||||
const priorityLast = ['MainArtist', 'Main Artist'];
|
||||
|
||||
const entries = Object.entries(grouped);
|
||||
|
||||
entries.sort(([a], [b]) => {
|
||||
const aIsFirst = priorityFirst.some(p => a.toLowerCase() === p.toLowerCase());
|
||||
const bIsFirst = priorityFirst.some(p => b.toLowerCase() === p.toLowerCase());
|
||||
const aIsLast = priorityLast.some(p => a.toLowerCase() === p.toLowerCase());
|
||||
const bIsLast = priorityLast.some(p => b.toLowerCase() === p.toLowerCase());
|
||||
|
||||
if (aIsFirst && !bIsFirst) return -1;
|
||||
if (!aIsFirst && bIsFirst) return 1;
|
||||
if (aIsLast && !bIsLast) return 1;
|
||||
if (!aIsLast && bIsLast) return -1;
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return entries.map(([role, names]) => ({ role, names }));
|
||||
}
|
||||
|
||||
// Load track info when trackId changes
|
||||
$effect(() => {
|
||||
if (trackId) {
|
||||
loadTrackInfo(trackId);
|
||||
} else {
|
||||
trackInfo = null;
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadTrackInfo(id: number) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
trackInfo = await invoke<TrackInfo>('get_track_info', { trackId: id });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
trackInfo = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const groupedCredits = $derived(
|
||||
trackInfo?.performers ? getGroupedCredits(trackInfo.performers) : []
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="track-info-panel">
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<Loader2 size={28} class="spinner" />
|
||||
<span>{$t('common.loading') || 'Loading...'}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<span>{$t('errors.failedToLoad') || 'Failed to load'}</span>
|
||||
</div>
|
||||
{:else if !trackId}
|
||||
<div class="empty-state">
|
||||
<Music2 size={32} strokeWidth={1.5} />
|
||||
<span>{$t('player.noTrackSelected') || 'No track selected'}</span>
|
||||
</div>
|
||||
{:else if trackInfo}
|
||||
<div class="info-content">
|
||||
<!-- Track Header -->
|
||||
<div class="track-header">
|
||||
<h2 class="track-title">{trackInfo.track.title}</h2>
|
||||
{#if trackInfo.track.album?.title}
|
||||
<p class="album-title">{trackInfo.track.album.title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div class="metadata-section">
|
||||
<div class="metadata-item">
|
||||
<Clock size={14} />
|
||||
<span class="metadata-label">{$t('track.duration') || 'Duration'}</span>
|
||||
<span class="metadata-value">{formatDuration(trackInfo.track.duration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="metadata-item">
|
||||
<Music2 size={14} />
|
||||
<span class="metadata-label">{$t('track.quality') || 'Quality'}</span>
|
||||
<span class="metadata-value">{formatQuality(trackInfo.track)}</span>
|
||||
</div>
|
||||
|
||||
{#if trackInfo.track.isrc}
|
||||
<div class="metadata-item">
|
||||
<Tag size={14} />
|
||||
<span class="metadata-label">ISRC</span>
|
||||
<span class="metadata-value mono">{trackInfo.track.isrc}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if trackInfo.track.album?.label}
|
||||
<div class="metadata-item">
|
||||
<Building2 size={14} />
|
||||
<span class="metadata-label">{$t('album.label') || 'Label'}</span>
|
||||
{#if onLabelClick}
|
||||
<button
|
||||
class="metadata-link"
|
||||
onclick={() => onLabelClick!(trackInfo!.track.album!.label!.id, trackInfo!.track.album!.label!.name)}
|
||||
>
|
||||
{trackInfo.track.album.label.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="metadata-value">{trackInfo.track.album.label.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
{#if groupedCredits.length > 0}
|
||||
<div class="credits-section">
|
||||
<h3 class="section-title">{$t('player.credits') || 'Credits'}</h3>
|
||||
<div class="credits-list">
|
||||
{#each groupedCredits as { role, names }}
|
||||
<div class="credit-item">
|
||||
<span class="credit-role">{formatRole(role)}</span>
|
||||
<span class="credit-names">{names.join(', ')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Copyright -->
|
||||
{#if trackInfo.track.copyright}
|
||||
<div class="copyright-section">
|
||||
<p class="copyright">{trackInfo.track.copyright}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.track-info-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-state :global(.spinner) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.info-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.info-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.info-content::-webkit-scrollbar-thumb {
|
||||
background: var(--alpha-20, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Track Header */
|
||||
.track-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, white);
|
||||
margin: 0 0 6px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 14px;
|
||||
color: var(--alpha-60, rgba(255, 255, 255, 0.6));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Metadata Section */
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--alpha-10, rgba(255, 255, 255, 0.1));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, white);
|
||||
}
|
||||
|
||||
.metadata-value.mono {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--alpha-70, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
|
||||
.metadata-link {
|
||||
font-size: 14px;
|
||||
color: var(--accent-primary, #7c3aed);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.metadata-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Credits Section */
|
||||
.credits-section {
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--alpha-10, rgba(255, 255, 255, 0.1));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.credits-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.credit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.credit-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.credit-names {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, white);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Copyright */
|
||||
.copyright-section {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 11px;
|
||||
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -254,6 +254,7 @@
|
||||
"noLyrics": "No lyrics available",
|
||||
"fetchingLyrics": "Fetching lyrics...",
|
||||
"credits": "Credits",
|
||||
"trackInfo": "Track Info",
|
||||
"suggestions": "Suggestions",
|
||||
"shuffle": "Shuffle",
|
||||
"repeat": "Repeat",
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
"noLyrics": "No hay letras disponibles",
|
||||
"fetchingLyrics": "Buscando letras...",
|
||||
"credits": "Créditos",
|
||||
"trackInfo": "Info de Pista",
|
||||
"suggestions": "Sugerencias",
|
||||
"shuffle": "Aleatorio",
|
||||
"repeat": "Repetir",
|
||||
|
||||
Reference in new issue
Block a user