feat(immersive): add multiple display modes like Cider
Display modes: - Coverflow: centered album art with track info - Split: artwork + panel side by side (current default) - Lyrics Focus: single line at a time, large centered text - Queue Focus: full screen queue view - Visualizer Focus: full screen visualizer Background: - Heavy CSS blur (120px) for abstract color blobs - No recognizable image, just colors Keyboard shortcuts: 1-5 to switch modes Inspired by Cider 3's immersive player design.
This commit is contained in:
1 parent
484ef87ee7
commit
b7e08cc4cb
5 files changed
+534
-94
No files matched your search
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
|
||||
<div class="immersive-background" class:loading={isLoading}>
|
||||
<!-- Full artwork as background -->
|
||||
<!-- Heavily blurred artwork - just color blobs -->
|
||||
{#if artwork}
|
||||
<img
|
||||
src={artwork}
|
||||
@@ -32,11 +32,8 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Vignette overlay - strong fade to black at edges -->
|
||||
<div class="vignette-overlay"></div>
|
||||
|
||||
<!-- Gradient overlay for text readability -->
|
||||
<div class="gradient-overlay"></div>
|
||||
<!-- Dark overlay for better contrast -->
|
||||
<div class="dark-overlay"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -50,50 +47,27 @@
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
/* Scale larger than viewport to hide edges */
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
min-width: 115%;
|
||||
min-height: 115%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
/* Extend beyond viewport to hide blur edges */
|
||||
inset: -100px;
|
||||
width: calc(100% + 200px);
|
||||
height: calc(100% + 200px);
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transition: opacity 400ms ease-out;
|
||||
/* Slight desaturation for better text contrast */
|
||||
filter: saturate(0.85) brightness(0.9);
|
||||
/* Heavy blur - should be unrecognizable, just color blobs */
|
||||
filter: blur(120px) saturate(1.3) brightness(0.6);
|
||||
transform: scale(1.2);
|
||||
transition: opacity 500ms ease-out;
|
||||
}
|
||||
|
||||
.loading .background-image {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Strong vignette - fades to black at edges */
|
||||
.vignette-overlay {
|
||||
/* Subtle dark overlay */
|
||||
.dark-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse 80% 80% at 50% 45%,
|
||||
transparent 0%,
|
||||
transparent 30%,
|
||||
rgba(0, 0, 0, 0.4) 60%,
|
||||
rgba(0, 0, 0, 0.85) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gradient for text areas - darker where text appears */
|
||||
.gradient-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
/* Top gradient for header */
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 15%),
|
||||
/* Bottom gradient for controls */
|
||||
linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 25%),
|
||||
/* Right side gradient for lyrics panel */
|
||||
linear-gradient(to left, rgba(0, 0, 0, 0.4) 0%, transparent 40%);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { X, Maximize2, Minimize2, Music2, Info, Radio, BarChart3, ListMusic } from 'lucide-svelte';
|
||||
import { X, Disc3, LayoutGrid, MessageSquareText, ListMusic, BarChart3, Music2, Info, Radio } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
export type ImmersiveTab = 'lyrics' | 'credits' | 'suggestions' | 'visualizer' | 'queue';
|
||||
export type DisplayMode = 'coverflow' | 'split' | 'lyrics-focus' | 'queue-focus' | 'visualizer-focus';
|
||||
|
||||
interface Props {
|
||||
activeTab: ImmersiveTab;
|
||||
displayMode: DisplayMode;
|
||||
onTabChange: (tab: ImmersiveTab) => void;
|
||||
onDisplayModeChange: (mode: DisplayMode) => void;
|
||||
onClose: () => void;
|
||||
visible?: boolean;
|
||||
hasLyrics?: boolean;
|
||||
@@ -17,7 +20,9 @@
|
||||
|
||||
let {
|
||||
activeTab,
|
||||
displayMode,
|
||||
onTabChange,
|
||||
onDisplayModeChange,
|
||||
onClose,
|
||||
visible = true,
|
||||
hasLyrics = true,
|
||||
@@ -33,29 +38,55 @@
|
||||
{ id: 'visualizer' as const, label: $t('player.visualizer') || 'Visualizer', icon: BarChart3, enabled: hasVisualizer },
|
||||
{ id: 'queue' as const, label: $t('player.queue') || 'Queue', icon: ListMusic, enabled: true },
|
||||
].filter(tab => tab.enabled));
|
||||
|
||||
const displayModes: { id: DisplayMode; icon: typeof Disc3; title: string }[] = [
|
||||
{ id: 'coverflow', icon: Disc3, title: 'Coverflow' },
|
||||
{ id: 'split', icon: LayoutGrid, title: 'Split View' },
|
||||
{ id: 'lyrics-focus', icon: MessageSquareText, title: 'Lyrics Focus' },
|
||||
{ id: 'queue-focus', icon: ListMusic, title: 'Queue Focus' },
|
||||
{ id: 'visualizer-focus', icon: BarChart3, title: 'Visualizer' },
|
||||
];
|
||||
|
||||
// Only show tabs in split mode
|
||||
const showTabs = $derived(displayMode === 'split');
|
||||
</script>
|
||||
|
||||
<header class="immersive-header" class:visible>
|
||||
<!-- Left: Logo/Brand (optional) -->
|
||||
<!-- Left: Display mode switcher -->
|
||||
<div class="header-left">
|
||||
<!-- Reserved for future use -->
|
||||
<div class="mode-switcher">
|
||||
{#each displayModes as mode (mode.id)}
|
||||
<button
|
||||
class="mode-btn"
|
||||
class:active={displayMode === mode.id}
|
||||
onclick={() => onDisplayModeChange(mode.id)}
|
||||
title={mode.title}
|
||||
>
|
||||
<mode.icon size={18} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center/Right: Tabs -->
|
||||
<nav class="tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === tab.id}
|
||||
onclick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
<span class="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<!-- Center: Tabs (only in split mode) -->
|
||||
{#if showTabs}
|
||||
<nav class="tabs">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === tab.id}
|
||||
onclick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
<span class="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{:else}
|
||||
<div class="tabs-placeholder"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<!-- Right: Close button -->
|
||||
<div class="header-actions">
|
||||
<button class="action-btn" onclick={onClose} title={$t('actions.close') + ' (Esc)'}>
|
||||
<X size={20} />
|
||||
@@ -91,6 +122,44 @@
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.mode-switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--alpha-10, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: var(--alpha-80, rgba(255, 255, 255, 0.8));
|
||||
background: var(--alpha-10, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
color: var(--text-primary, white);
|
||||
background: var(--alpha-15, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.tabs-placeholder {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -126,7 +195,6 @@
|
||||
background: var(--alpha-15, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
|
||||
.header-actions {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
@@ -174,8 +242,13 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: none;
|
||||
.mode-switcher {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
import { startActiveLineUpdates } from '$lib/stores/lyricsStore';
|
||||
import ImmersiveBackground from './ImmersiveBackground.svelte';
|
||||
import ImmersiveArtwork from './ImmersiveArtwork.svelte';
|
||||
import ImmersiveHeader, { type ImmersiveTab } from './ImmersiveHeader.svelte';
|
||||
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 SuggestionsPanel from './panels/SuggestionsPanel.svelte';
|
||||
import VisualizerPanel from './panels/VisualizerPanel.svelte';
|
||||
import QueuePanel from './panels/QueuePanel.svelte';
|
||||
import CoverflowPanel from './panels/CoverflowPanel.svelte';
|
||||
import LyricsFocusPanel from './panels/LyricsFocusPanel.svelte';
|
||||
|
||||
interface LyricsLine {
|
||||
text: string;
|
||||
@@ -114,6 +116,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
// UI State
|
||||
let displayMode: DisplayMode = $state('split');
|
||||
let activeTab: ImmersiveTab = $state('lyrics');
|
||||
let showUI = $state(true);
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -159,25 +162,42 @@
|
||||
onSeek(Math.min(duration, currentTime + 5));
|
||||
}
|
||||
break;
|
||||
// Display mode shortcuts
|
||||
case '1':
|
||||
displayMode = 'coverflow';
|
||||
break;
|
||||
case '2':
|
||||
displayMode = 'split';
|
||||
break;
|
||||
case '3':
|
||||
displayMode = 'lyrics-focus';
|
||||
break;
|
||||
case '4':
|
||||
displayMode = 'queue-focus';
|
||||
break;
|
||||
case '5':
|
||||
displayMode = 'visualizer-focus';
|
||||
break;
|
||||
// Tab shortcuts (only in split mode)
|
||||
case 'l':
|
||||
case 'L':
|
||||
activeTab = 'lyrics';
|
||||
if (displayMode === 'split') activeTab = 'lyrics';
|
||||
break;
|
||||
case 'c':
|
||||
case 'C':
|
||||
if (enableCredits) activeTab = 'credits';
|
||||
if (displayMode === 'split' && enableCredits) activeTab = 'credits';
|
||||
break;
|
||||
case 's':
|
||||
case 'S':
|
||||
if (enableSuggestions) activeTab = 'suggestions';
|
||||
if (displayMode === 'split' && enableSuggestions) activeTab = 'suggestions';
|
||||
break;
|
||||
case 'v':
|
||||
case 'V':
|
||||
if (enableVisualizer) activeTab = 'visualizer';
|
||||
if (displayMode === 'split' && enableVisualizer) activeTab = 'visualizer';
|
||||
break;
|
||||
case 'q':
|
||||
case 'Q':
|
||||
activeTab = 'queue';
|
||||
if (displayMode === 'split') activeTab = 'queue';
|
||||
break;
|
||||
}
|
||||
resetHideTimer();
|
||||
@@ -216,10 +236,12 @@
|
||||
<!-- Background -->
|
||||
<ImmersiveBackground {artwork} />
|
||||
|
||||
<!-- Header with tabs -->
|
||||
<!-- Header with mode switcher -->
|
||||
<ImmersiveHeader
|
||||
{activeTab}
|
||||
{displayMode}
|
||||
onTabChange={(tab) => activeTab = tab}
|
||||
onDisplayModeChange={(mode) => displayMode = mode}
|
||||
{onClose}
|
||||
visible={showUI}
|
||||
hasLyrics={true}
|
||||
@@ -228,40 +250,79 @@
|
||||
hasVisualizer={enableVisualizer}
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- Left: Artwork -->
|
||||
<div class="artwork-section">
|
||||
<ImmersiveArtwork {artwork} {trackTitle} variant="floating" />
|
||||
</div>
|
||||
|
||||
<!-- Right: Active Panel -->
|
||||
<div class="panel-section">
|
||||
{#if activeTab === 'lyrics'}
|
||||
<LyricsPanel
|
||||
lines={lyricsLines}
|
||||
activeIndex={lyricsActiveIndex}
|
||||
activeProgress={lyricsActiveProgress}
|
||||
isSynced={lyricsSynced}
|
||||
isLoading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
/>
|
||||
{:else if activeTab === 'credits'}
|
||||
<CreditsPanel {trackId} />
|
||||
{:else if activeTab === 'suggestions'}
|
||||
<SuggestionsPanel {trackId} {artistId} />
|
||||
{:else if activeTab === 'visualizer'}
|
||||
<VisualizerPanel {isPlaying} />
|
||||
{:else if activeTab === 'queue'}
|
||||
<!-- Content based on display mode -->
|
||||
{#if displayMode === 'coverflow'}
|
||||
<!-- Coverflow: Centered artwork -->
|
||||
<CoverflowPanel
|
||||
{artwork}
|
||||
{trackTitle}
|
||||
{artist}
|
||||
{album}
|
||||
{isPlaying}
|
||||
/>
|
||||
{:else if displayMode === 'lyrics-focus'}
|
||||
<!-- Lyrics Focus: Single line, large, centered -->
|
||||
<LyricsFocusPanel
|
||||
lines={lyricsLines}
|
||||
activeIndex={lyricsActiveIndex}
|
||||
isLoading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
/>
|
||||
{:else if displayMode === 'queue-focus'}
|
||||
<!-- Queue Focus: Full screen queue -->
|
||||
<div class="focus-panel">
|
||||
<div class="focus-panel-content queue-content">
|
||||
<QueuePanel
|
||||
tracks={queueTracks}
|
||||
currentIndex={queueCurrentIndex}
|
||||
onPlayTrack={(index) => onQueuePlayTrack?.(index)}
|
||||
onClear={onQueueClear}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if displayMode === 'visualizer-focus'}
|
||||
<!-- Visualizer Focus: Full screen visualizer -->
|
||||
<div class="focus-panel">
|
||||
<div class="focus-panel-content visualizer-content">
|
||||
<VisualizerPanel {isPlaying} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Split: Artwork + Panel side by side -->
|
||||
<div class="main-content">
|
||||
<!-- Left: Artwork -->
|
||||
<div class="artwork-section">
|
||||
<ImmersiveArtwork {artwork} {trackTitle} variant="floating" />
|
||||
</div>
|
||||
|
||||
<!-- Right: Active Panel -->
|
||||
<div class="panel-section">
|
||||
{#if activeTab === 'lyrics'}
|
||||
<LyricsPanel
|
||||
lines={lyricsLines}
|
||||
activeIndex={lyricsActiveIndex}
|
||||
activeProgress={lyricsActiveProgress}
|
||||
isSynced={lyricsSynced}
|
||||
isLoading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
/>
|
||||
{:else if activeTab === 'credits'}
|
||||
<CreditsPanel {trackId} />
|
||||
{:else if activeTab === 'suggestions'}
|
||||
<SuggestionsPanel {trackId} {artistId} />
|
||||
{:else if activeTab === 'visualizer'}
|
||||
<VisualizerPanel {isPlaying} />
|
||||
{:else if activeTab === 'queue'}
|
||||
<QueuePanel
|
||||
tracks={queueTracks}
|
||||
currentIndex={queueCurrentIndex}
|
||||
onPlayTrack={(index) => onQueuePlayTrack?.(index)}
|
||||
onClear={onQueueClear}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom Controls -->
|
||||
<ImmersiveControls
|
||||
@@ -312,6 +373,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Split mode layout */
|
||||
.main-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -345,6 +407,33 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Focus mode panels (queue, visualizer) */
|
||||
.focus-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 48px 140px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.focus-panel-content {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.focus-panel-content.queue-content {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.focus-panel-content.visualizer-content {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.main-content {
|
||||
@@ -355,6 +444,10 @@
|
||||
.panel-section {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.focus-panel {
|
||||
padding: 70px 32px 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -374,6 +467,14 @@
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.focus-panel {
|
||||
padding: 70px 24px 140px;
|
||||
}
|
||||
|
||||
.focus-panel-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -381,5 +482,9 @@
|
||||
padding: 60px 16px 130px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.focus-panel {
|
||||
padding: 60px 16px 130px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { BarChart2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
artwork: string;
|
||||
trackTitle: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
artwork,
|
||||
trackTitle,
|
||||
artist,
|
||||
album,
|
||||
isPlaying = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="coverflow-panel">
|
||||
<div class="artwork-container" class:playing={isPlaying}>
|
||||
<img src={artwork} alt={trackTitle} class="artwork" />
|
||||
</div>
|
||||
|
||||
<div class="track-info">
|
||||
{#if isPlaying}
|
||||
<div class="now-playing-indicator">
|
||||
<BarChart2 size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="track-title">{trackTitle}</h1>
|
||||
<p class="track-artist">{artist}</p>
|
||||
{#if album}
|
||||
<p class="track-album">{album}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.coverflow-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 80px 40px 140px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
position: relative;
|
||||
width: min(50vh, 400px);
|
||||
height: min(50vh, 400px);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 16px 64px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.artwork-container:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.artwork {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.now-playing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent-primary, #7c3aed);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: clamp(20px, 3vw, 28px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, white);
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: clamp(14px, 2vw, 18px);
|
||||
color: var(--alpha-70, rgba(255, 255, 255, 0.7));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.track-album {
|
||||
font-size: clamp(12px, 1.5vw, 14px);
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.coverflow-panel {
|
||||
padding: 70px 24px 130px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
width: min(60vw, 300px);
|
||||
height: min(60vw, 300px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.artwork-container {
|
||||
width: min(35vh, 250px);
|
||||
height: min(35vh, 250px);
|
||||
}
|
||||
|
||||
.track-info {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface LyricsLine {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lines: LyricsLine[];
|
||||
activeIndex?: number;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
lines = [],
|
||||
activeIndex = -1,
|
||||
isLoading = false,
|
||||
error = null
|
||||
}: Props = $props();
|
||||
|
||||
let previousIndex = $state(-1);
|
||||
let animationKey = $state(0);
|
||||
|
||||
// Track index changes for animation
|
||||
$effect(() => {
|
||||
if (activeIndex !== previousIndex && activeIndex >= 0) {
|
||||
previousIndex = activeIndex;
|
||||
animationKey++;
|
||||
}
|
||||
});
|
||||
|
||||
const currentLine = $derived(activeIndex >= 0 && activeIndex < lines.length ? lines[activeIndex]?.text : '');
|
||||
const hasLyrics = $derived(lines.length > 0);
|
||||
</script>
|
||||
|
||||
<div class="lyrics-focus-panel">
|
||||
{#if isLoading}
|
||||
<div class="lyrics-state">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="lyrics-state">
|
||||
<span class="error-text">{error}</span>
|
||||
</div>
|
||||
{:else if hasLyrics && currentLine}
|
||||
{#key animationKey}
|
||||
<div class="lyrics-line">
|
||||
<span class="line-text">{currentLine}</span>
|
||||
</div>
|
||||
{/key}
|
||||
{:else if hasLyrics}
|
||||
<div class="lyrics-state">
|
||||
<span class="waiting-text">{$t('player.waitingForLyrics') || '♪'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="lyrics-state">
|
||||
<span class="no-lyrics">{$t('player.noLyrics') || 'No lyrics available'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lyrics-focus-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 60px 140px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.lyrics-line {
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
animation: fadeInUp 400ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.line-text {
|
||||
font-size: clamp(28px, 5vw, 56px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, white);
|
||||
line-height: 1.3;
|
||||
text-shadow:
|
||||
0 2px 20px rgba(0, 0, 0, 0.5),
|
||||
0 4px 40px rgba(0, 0, 0, 0.3);
|
||||
/* Word wrap for long lines */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.lyrics-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--alpha-50, rgba(255, 255, 255, 0.5));
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.waiting-text {
|
||||
font-size: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--alpha-40, rgba(255, 255, 255, 0.4));
|
||||
}
|
||||
|
||||
.no-lyrics {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--alpha-20, rgba(255, 255, 255, 0.2));
|
||||
border-top-color: var(--alpha-80, rgba(255, 255, 255, 0.8));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.lyrics-focus-panel {
|
||||
padding: 70px 30px 130px;
|
||||
}
|
||||
|
||||
.line-text {
|
||||
font-size: clamp(22px, 6vw, 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user