feat(immersive): create unified ImmersivePlayer component

New component architecture:
- ImmersivePlayer.svelte: Main container with tab system
- ImmersiveHeader.svelte: Tabs (lyrics/credits/suggestions/visualizer)
- ImmersiveControls.svelte: Bottom bar with all playback controls
- ImmersiveArtwork.svelte: Album art with floating/vinyl variants
- ImmersiveBackground.svelte: Optimized blur background (from Phase 1)

Panel components:
- LyricsPanel: Wrapper for existing LyricsLines
- CreditsPanel: Placeholder (Phase 3)
- SuggestionsPanel: Placeholder (Phase 4)
- VisualizerPanel: Placeholder (Phase 5)

Shared components:
- ProgressBar: Reusable seek bar with keyboard support
- VolumeSlider: Reusable volume control

Features:
- Tab-based navigation (L/C/S/V keyboard shortcuts)
- Auto-hide UI after 4 seconds of inactivity
- Full keyboard control (Space, Arrows, Escape)
- Responsive layout (desktop/tablet/mobile)
- Accessibility: ARIA roles, focus management
This commit is contained in:
vicrodh committed 2026-01-30 16:11:03 -06:00
1 parent 513614466e
commit 7c9f1820f8
11 files changed
+1730

No files matched your search

@@ -0,0 +1,359 @@
<script lang="ts">
import { startActiveLineUpdates } from '$lib/stores/lyricsStore';
import ImmersiveBackground from './ImmersiveBackground.svelte';
import ImmersiveArtwork from './ImmersiveArtwork.svelte';
import ImmersiveHeader, { type ImmersiveTab } 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';
interface LyricsLine {
text: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
// Track info
artwork: string;
trackTitle: string;
artist: string;
album?: string;
trackId?: number;
artistId?: number;
// Quality
quality?: string;
bitDepth?: number;
samplingRate?: number;
// Playback state
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isShuffle: boolean;
repeatMode: 'off' | 'all' | 'one';
isFavorite: boolean;
// Callbacks
onTogglePlay: () => void;
onSkipBack?: () => void;
onSkipForward?: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
onToggleShuffle: () => void;
onToggleRepeat: () => void;
onToggleFavorite: () => void;
onOpenQueue?: () => void;
// Lyrics
lyricsLines?: LyricsLine[];
lyricsActiveIndex?: number;
lyricsActiveProgress?: number;
lyricsSynced?: boolean;
lyricsLoading?: boolean;
lyricsError?: string | null;
// Feature flags
enableCredits?: boolean;
enableSuggestions?: boolean;
enableVisualizer?: boolean;
}
let {
isOpen,
onClose,
artwork,
trackTitle,
artist,
album,
trackId,
artistId,
quality,
bitDepth,
samplingRate,
isPlaying,
currentTime,
duration,
volume,
isShuffle,
repeatMode,
isFavorite,
onTogglePlay,
onSkipBack,
onSkipForward,
onSeek,
onVolumeChange,
onToggleShuffle,
onToggleRepeat,
onToggleFavorite,
onOpenQueue,
lyricsLines = [],
lyricsActiveIndex = -1,
lyricsActiveProgress = 0,
lyricsSynced = false,
lyricsLoading = false,
lyricsError = null,
enableCredits = true,
enableSuggestions = true,
enableVisualizer = false
}: Props = $props();
// UI State
let activeTab: ImmersiveTab = $state('lyrics');
let showUI = $state(true);
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
const hasLyrics = $derived(lyricsLines.length > 0 || lyricsLoading);
const AUTO_HIDE_DELAY = 4000;
// Auto-hide UI after inactivity
function resetHideTimer() {
showUI = true;
if (hideTimeout) clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
showUI = false;
}, AUTO_HIDE_DELAY);
}
function handleMouseMove() {
resetHideTimer();
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
switch (e.key) {
case 'Escape':
onClose();
break;
case ' ':
e.preventDefault();
onTogglePlay();
break;
case 'ArrowLeft':
if (e.shiftKey && onSkipBack) {
onSkipBack();
} else {
onSeek(Math.max(0, currentTime - 5));
}
break;
case 'ArrowRight':
if (e.shiftKey && onSkipForward) {
onSkipForward();
} else {
onSeek(Math.min(duration, currentTime + 5));
}
break;
case 'l':
case 'L':
activeTab = 'lyrics';
break;
case 'c':
case 'C':
if (enableCredits) activeTab = 'credits';
break;
case 's':
case 'S':
if (enableSuggestions) activeTab = 'suggestions';
break;
case 'v':
case 'V':
if (enableVisualizer) activeTab = 'visualizer';
break;
}
resetHideTimer();
}
// Setup event listeners when open
$effect(() => {
if (isOpen) {
resetHideTimer();
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
if (hideTimeout) clearTimeout(hideTimeout);
};
}
});
// Start lyrics updates when open and playing synced lyrics
$effect(() => {
if (isOpen && isPlaying && lyricsSynced) {
startActiveLineUpdates();
}
});
</script>
{#if isOpen}
<div
class="immersive-player"
onmousemove={handleMouseMove}
role="dialog"
aria-modal="true"
aria-label="Now Playing"
tabindex="-1"
>
<!-- Background -->
<ImmersiveBackground {artwork} mode="blur" />
<!-- Header with tabs -->
<ImmersiveHeader
{activeTab}
onTabChange={(tab) => activeTab = tab}
{onClose}
visible={showUI}
hasLyrics={true}
hasCredits={enableCredits}
hasSuggestions={enableSuggestions}
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} />
{/if}
</div>
</div>
<!-- Bottom Controls -->
<ImmersiveControls
visible={showUI}
{artwork}
{trackTitle}
{artist}
{isPlaying}
{currentTime}
{duration}
{isShuffle}
{repeatMode}
{isFavorite}
{volume}
{quality}
{bitDepth}
{samplingRate}
{onTogglePlay}
{onSkipBack}
{onSkipForward}
{onSeek}
{onToggleShuffle}
{onToggleRepeat}
{onToggleFavorite}
{onVolumeChange}
{onOpenQueue}
/>
</div>
{/if}
<style>
.immersive-player {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
background-color: var(--bg-primary, #0a0a0b);
animation: fadeIn 200ms ease-out;
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.main-content {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 1400px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 80px 48px 140px;
gap: 60px;
z-index: 1;
}
.artwork-section {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.panel-section {
flex: 1;
min-width: 0;
min-height: 0;
max-width: 560px;
height: 100%;
display: flex;
flex-direction: column;
}
/* Responsive */
@media (max-width: 1200px) {
.main-content {
padding: 70px 32px 130px;
gap: 40px;
}
.panel-section {
max-width: 480px;
}
}
@media (max-width: 900px) {
.main-content {
flex-direction: column;
padding: 70px 24px 140px;
gap: 24px;
justify-content: flex-start;
}
.artwork-section {
flex: 0 0 auto;
}
.panel-section {
flex: 1;
max-width: 100%;
width: 100%;
}
}
@media (max-width: 600px) {
.main-content {
padding: 60px 16px 130px;
gap: 20px;
}
}
</style>