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:
vicrodh committed 2026-01-31 11:12:25 -06:00
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'}
+1 -1
View File
@@ -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>
+1
View File
@@ -254,6 +254,7 @@
"noLyrics": "No lyrics available",
"fetchingLyrics": "Fetching lyrics...",
"credits": "Credits",
"trackInfo": "Track Info",
"suggestions": "Suggestions",
"shuffle": "Shuffle",
"repeat": "Repeat",
+1
View File
@@ -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",