feat(mukke): redesign FullPlayer as immersive fullscreen experience

Replace popup overlay with fullscreen page where the visualizer fills
the entire background. Controls overlay at bottom with gradient for
readability. Visualizer switcher in top-right corner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 10:52:10 +01:00
parent 734ab8012a
commit cf37d92633

View file

@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { playerStore } from '$lib/stores/player.svelte'; import { playerStore } from '$lib/stores/player.svelte';
import VisualizerRenderer from '$lib/visualizer/VisualizerRenderer.svelte'; import VisualizerRenderer from '$lib/visualizer/VisualizerRenderer.svelte';
import { visualizerStore } from '$lib/visualizer/registry.svelte';
let innerHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);
let progress = $derived( let progress = $derived(
playerStore.duration > 0 ? (playerStore.currentTime / playerStore.duration) * 100 : 0 playerStore.duration > 0 ? (playerStore.currentTime / playerStore.duration) * 100 : 0
@ -23,173 +26,177 @@
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
playerStore.setVolume(parseFloat(input.value)); playerStore.setVolume(parseFloat(input.value));
} }
function repeatIcon(mode: string): string {
if (mode === 'one') return '1';
return '';
}
</script> </script>
<svelte:window bind:innerHeight />
{#if playerStore.showFullPlayer && playerStore.currentSong} {#if playerStore.showFullPlayer && playerStore.currentSong}
<div class="fixed inset-0 z-50 bg-background flex flex-col"> <div class="full-player">
<!-- Header --> <!-- Visualizer as full background -->
<div class="flex items-center justify-between p-4"> <div class="viz-background">
<button <VisualizerRenderer height={innerHeight} showSwitcher={false} />
onclick={() => playerStore.toggleFullPlayer()}
class="p-2 rounded-lg hover:bg-surface-hover transition-colors text-foreground-secondary"
aria-label="Close player"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<div class="text-sm text-foreground-secondary">Now Playing</div>
<div class="w-10"></div>
</div> </div>
<!-- Content --> <!-- Dark overlay for readability -->
<div class="flex-1 flex flex-col items-center justify-center px-8 gap-8"> <div class="viz-overlay"></div>
<!-- Cover art -->
<div class="w-64 h-64 rounded-2xl bg-surface flex items-center justify-center shadow-lg">
<svg class="w-24 h-24 text-foreground-secondary" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
/>
</svg>
</div>
<!-- Visualizer --> <!-- Content layer -->
<div class="w-full max-w-md"> <div class="content-layer">
<VisualizerRenderer height={200} showSwitcher={true} /> <!-- Top bar -->
</div> <div class="flex items-center justify-between p-4">
<!-- Song info -->
<div class="text-center w-full max-w-md">
<div class="text-xl font-bold text-foreground truncate">
{playerStore.currentSong.title}
</div>
<div class="text-foreground-secondary mt-1 truncate">
{playerStore.currentSong.artist || 'Unknown Artist'}
</div>
{#if playerStore.currentSong.album}
<div class="text-sm text-foreground-secondary mt-0.5 truncate">
{playerStore.currentSong.album}
</div>
{/if}
</div>
<!-- Progress bar -->
<div class="w-full max-w-md">
<button <button
class="w-full h-2 bg-border rounded-full cursor-pointer relative block" onclick={() => playerStore.toggleFullPlayer()}
onclick={handleProgressClick} class="p-2 rounded-lg bg-white/10 hover:bg-white/20 backdrop-blur-sm transition-colors text-white"
aria-label="Seek" aria-label="Close player"
> >
<div <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="h-full bg-primary rounded-full transition-all duration-100"
style="width: {progress}%"
></div>
<div
class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-primary rounded-full shadow"
style="left: {progress}%"
></div>
</button>
<div class="flex justify-between mt-2 text-xs text-foreground-secondary">
<span>{formatTime(playerStore.currentTime)}</span>
<span>{formatTime(playerStore.duration)}</span>
</div>
</div>
<!-- Transport controls -->
<div class="flex items-center gap-6">
<!-- Previous -->
<button
onclick={() => playerStore.previousSong()}
class="p-3 rounded-full hover:bg-surface-hover transition-colors text-foreground"
aria-label="Previous track"
>
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<!-- Play/Pause -->
<button
onclick={() => playerStore.togglePlay()}
class="p-4 rounded-full bg-primary text-white hover:bg-primary-hover transition-colors"
aria-label={playerStore.isPlaying ? 'Pause' : 'Play'}
>
{#if playerStore.isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next -->
<button
onclick={() => playerStore.nextSong()}
class="p-3 rounded-full hover:bg-surface-hover transition-colors text-foreground"
aria-label="Next track"
>
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm2-8.14L11.03 12 8 14.14V9.86zM16 6h2v12h-2z" />
</svg>
</button>
</div>
<!-- Bottom row: Shuffle, Repeat, Volume, Queue -->
<div class="flex items-center gap-6 w-full max-w-md justify-center">
<!-- Shuffle -->
<button
onclick={() => playerStore.toggleShuffle()}
class="p-2 rounded-lg transition-colors {playerStore.shuffleOn
? 'text-primary'
: 'text-foreground-secondary hover:text-foreground'}"
aria-label="Toggle shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path <path
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/> />
</svg> </svg>
</button> </button>
<div class="text-sm text-white/70">Now Playing</div>
<!-- Repeat --> <!-- Visualizer switcher -->
<button <div class="flex gap-1">
onclick={() => playerStore.toggleRepeat()} {#each visualizerStore.all as viz}
class="p-2 rounded-lg transition-colors relative {playerStore.repeatMode !== 'off' <button
? 'text-primary' onclick={() => visualizerStore.setActive(viz.id)}
: 'text-foreground-secondary hover:text-foreground'}" class="px-2 py-1 text-xs rounded-md transition-colors {visualizerStore.active ===
aria-label="Toggle repeat ({playerStore.repeatMode})" viz.id
> ? 'bg-white/30 text-white'
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> : 'bg-white/10 text-white/50 hover:bg-white/20 hover:text-white/80'}"
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" /> title={viz.description}
</svg>
{#if playerStore.repeatMode === 'one'}
<span
class="absolute -top-1 -right-1 text-[10px] font-bold bg-primary text-white rounded-full w-4 h-4 flex items-center justify-center"
>1</span
> >
{/if} {viz.name}
</button> </button>
{/each}
</div>
</div>
<!-- Volume --> <!-- Spacer to push controls to bottom -->
<div class="flex items-center gap-2"> <div class="flex-1"></div>
<svg
class="w-5 h-5 text-foreground-secondary shrink-0" <!-- Song info + controls at bottom -->
fill="currentColor" <div class="p-6 pb-8 flex flex-col gap-5 max-w-lg mx-auto w-full">
viewBox="0 0 24 24" <!-- Song info -->
<div class="text-center">
<div class="text-2xl font-bold text-white truncate">
{playerStore.currentSong.title}
</div>
<div class="text-white/70 mt-1 truncate">
{playerStore.currentSong.artist || 'Unknown Artist'}
</div>
{#if playerStore.currentSong.album}
<div class="text-sm text-white/50 mt-0.5 truncate">
{playerStore.currentSong.album}
</div>
{/if}
</div>
<!-- Progress bar -->
<div class="w-full">
<button
class="w-full h-1.5 bg-white/20 rounded-full cursor-pointer relative block"
onclick={handleProgressClick}
aria-label="Seek"
> >
<div
class="h-full bg-white rounded-full transition-all duration-100"
style="width: {progress}%"
></div>
<div
class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg"
style="left: {progress}%"
></div>
</button>
<div class="flex justify-between mt-1.5 text-xs text-white/50">
<span>{formatTime(playerStore.currentTime)}</span>
<span>{formatTime(playerStore.duration)}</span>
</div>
</div>
<!-- Transport controls -->
<div class="flex items-center justify-center gap-8">
<!-- Shuffle -->
<button
onclick={() => playerStore.toggleShuffle()}
class="p-2 rounded-lg transition-colors {playerStore.shuffleOn
? 'text-white'
: 'text-white/40 hover:text-white/70'}"
aria-label="Toggle shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
/>
</svg>
</button>
<!-- Previous -->
<button
onclick={() => playerStore.previousSong()}
class="p-2 rounded-full hover:bg-white/10 transition-colors text-white"
aria-label="Previous track"
>
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<!-- Play/Pause -->
<button
onclick={() => playerStore.togglePlay()}
class="p-4 rounded-full bg-white text-black hover:bg-white/90 transition-colors"
aria-label={playerStore.isPlaying ? 'Pause' : 'Play'}
>
{#if playerStore.isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next -->
<button
onclick={() => playerStore.nextSong()}
class="p-2 rounded-full hover:bg-white/10 transition-colors text-white"
aria-label="Next track"
>
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm2-8.14L11.03 12 8 14.14V9.86zM16 6h2v12h-2z" />
</svg>
</button>
<!-- Repeat -->
<button
onclick={() => playerStore.toggleRepeat()}
class="p-2 rounded-lg transition-colors relative {playerStore.repeatMode !== 'off'
? 'text-white'
: 'text-white/40 hover:text-white/70'}"
aria-label="Toggle repeat ({playerStore.repeatMode})"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
{#if playerStore.repeatMode === 'one'}
<span
class="absolute -top-1 -right-1 text-[10px] font-bold bg-white text-black rounded-full w-4 h-4 flex items-center justify-center"
>1</span
>
{/if}
</button>
</div>
<!-- Volume + Queue row -->
<div class="flex items-center justify-center gap-4">
<svg class="w-4 h-4 text-white/50 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path <path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/> />
@ -201,25 +208,105 @@
step="0.01" step="0.01"
value={playerStore.volume} value={playerStore.volume}
oninput={handleVolumeInput} oninput={handleVolumeInput}
class="w-20 h-1.5 bg-border rounded-lg appearance-none cursor-pointer" class="volume-slider"
/> />
<button
onclick={() => playerStore.toggleQueue()}
class="p-2 rounded-lg transition-colors {playerStore.showQueue
? 'text-white'
: 'text-white/40 hover:text-white/70'}"
aria-label="Toggle queue"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
</button>
</div> </div>
<!-- Queue -->
<button
onclick={() => playerStore.toggleQueue()}
class="p-2 rounded-lg transition-colors {playerStore.showQueue
? 'text-primary'
: 'text-foreground-secondary hover:text-foreground'}"
aria-label="Toggle queue"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<style>
.full-player {
position: fixed;
inset: 0;
z-index: 50;
background: #000;
display: flex;
flex-direction: column;
}
.viz-background {
position: absolute;
inset: 0;
z-index: 0;
}
/* Make the visualizer fill the entire background */
.viz-background :global(.relative.w-full) {
height: 100% !important;
width: 100% !important;
}
.viz-background :global(canvas) {
height: 100% !important;
width: 100% !important;
border-radius: 0 !important;
}
.viz-background :global(div[aria-hidden='true']) {
height: 100% !important;
}
.viz-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.1) 40%,
rgba(0, 0, 0, 0.4) 70%,
rgba(0, 0, 0, 0.85) 100%
);
pointer-events: none;
}
.content-layer {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
height: 100%;
}
.volume-slider {
width: 5rem;
height: 4px;
border-radius: 2px;
appearance: none;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
}
</style>