player layout shenanigans

This commit is contained in:
2026-02-06 04:04:10 -08:00
parent 3418d19a57
commit 291b8d5d21
2 changed files with 398 additions and 408 deletions

View File

@@ -453,423 +453,188 @@
{/if} {/if}
</svelte:head> </svelte:head>
{#if isMobile} <!-- Mobile UI is allowed to render as an overlay even if this component is mounted
<!-- Mobile: mini bar + expandable drawer --> inside the desktop sidebar column. We hide it on lg+ via CSS, not by unmounting. -->
<div <div
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur shadow-2xl" class="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur shadow-2xl"
> >
<div class="mx-auto flex max-w-4xl items-center gap-2 px-3 py-2"> <div class="mx-auto flex max-w-4xl items-center gap-2 px-3 py-2">
<button <button
class="inline-flex h-8 w-8 items-center justify-center rounded border" class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button" type="button"
onclick={() => toggleUiOpen()} onclick={() => toggleUiOpen()}
aria-label={snap.uiOpen ? "Close player" : "Open player"} aria-label={snap.uiOpen ? "Close player" : "Open player"}
title={snap.uiOpen ? "Close player" : "Open player"} title={snap.uiOpen ? "Close player" : "Open player"}
> >
{#if snap.uiOpen} {#if snap.uiOpen}
<X class="h-4 w-4" /> <X class="h-4 w-4" />
{:else} {:else}
<ChevronsUpDown class="h-4 w-4" /> <ChevronsUpDown class="h-4 w-4" />
{/if} {/if}
</button> </button>
<div <div
class="min-w-0 flex-1" class="min-w-0 flex-1"
role="button" role="button"
tabindex="0" tabindex="0"
aria-label={snap.uiOpen ? "Close player" : "Open player"} aria-label={snap.uiOpen ? "Close player" : "Open player"}
onclick={(e) => { onclick={(e) => {
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
// Only toggle when tapping the track info area. Avoid toggling from // Only toggle when tapping the track info area. Avoid toggling from
// the button zones (above/below/side) by not attaching handlers to the // the button zones (above/below/side) by not attaching handlers to the
// whole bar. // whole bar.
if (t?.closest("button,input,label,a")) return; if (t?.closest("button,input,label,a")) return;
toggleUiOpen(); toggleUiOpen();
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return; if (e.key !== "Enter" && e.key !== " ") return;
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
if (t?.closest("button,input,label,a")) return; if (t?.closest("button,input,label,a")) return;
e.preventDefault(); e.preventDefault();
toggleUiOpen(); toggleUiOpen();
}} }}
> >
{#if snap.currentTrack} {#if snap.currentTrack}
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1"> <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
{#if typeNumberLabel(snap.currentTrack)} {#if typeNumberLabel(snap.currentTrack)}
<span <span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground" class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
> >
{typeNumberLabel(snap.currentTrack)} {typeNumberLabel(snap.currentTrack)}
</span>
{/if}
<span class="truncate">
{(
snap.currentTrack.animeName ??
snap.currentTrack.album ??
""
).trim()}
</span> </span>
</div> {/if}
<span class="truncate">
<div class="mt-1 text-foreground/80"> {(
{(snap.currentTrack.title ?? "").trim() || "Unknown title"} snap.currentTrack.animeName ??
<span class="text-sm text-muted-foreground"> snap.currentTrack.album ??
{(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"} ""
</span> ).trim()}
</div> </span>
{:else}
<div class="truncate text-sm font-medium">{nowPlayingLabel()}</div>
{/if}
<div class="text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</div> </div>
<label class="mt-2 block"> <div class="mt-1 text-foreground/80">
<span class="sr-only">Seek</span> {(snap.currentTrack.title ?? "").trim() || "Unknown title"}
<input <span class="text-sm text-muted-foreground">
class="w-full" {(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
type="range" </span>
min="0" </div>
max={Math.max(0, duration)} {:else}
step="0.1" <div class="truncate text-sm font-medium">{nowPlayingLabel()}</div>
bind:value={currentTime} {/if}
disabled={!snap.currentTrack || duration <= 0}
/> <div class="text-xs text-muted-foreground">
</label> {formatTime(currentTime)} / {formatTime(duration)}
</div> </div>
<button <label class="mt-2 block">
class="inline-flex h-8 w-8 items-center justify-center rounded border" <span class="sr-only">Seek</span>
type="button" <input
disabled={!canPrev} class="w-full"
aria-label="Previous" type="range"
title="Previous" min="0"
onclick={() => { max={Math.max(0, duration)}
prev(currentTime); step="0.1"
void syncAndAutoplay(); bind:value={currentTime}
}} disabled={!snap.currentTrack || duration <= 0}
> />
<SkipBack class="h-4 w-4" /> </label>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{#if isPlaying}
<PauseIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canNext}
aria-label="Next"
title="Next"
onclick={() => {
next();
void syncAndAutoplay();
}}
>
<SkipForward class="h-4 w-4" />
</button>
</div> </div>
{#if snap.uiOpen} <button
<div class="max-h-[65dvh] overflow-y-auto border-t px-3 py-3"> class="inline-flex h-8 w-8 items-center justify-center rounded border"
<div class="mx-auto max-w-4xl space-y-3"> type="button"
<div class="flex flex-wrap items-center gap-2"> disabled={!canPrev}
<button aria-label="Previous"
class="inline-flex h-8 w-8 items-center justify-center rounded border" title="Previous"
type="button" onclick={() => {
onclick={() => toggleShuffle()} prev(currentTime);
aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"} void syncAndAutoplay();
title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"} }}
> >
<Shuffle class="h-4 w-4" /> <SkipBack class="h-4 w-4" />
</button> </button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleWrap()}
aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
>
<Repeat class="h-4 w-4" />
</button>
<label class="ml-auto flex items-center gap-2 text-sm"> <button
<span class="text-muted-foreground" aria-hidden="true"> class="inline-flex h-8 w-8 items-center justify-center rounded border"
<Volume2 class="h-4 w-4" /> type="button"
</span> aria-label={isPlaying ? "Pause" : "Play"}
<span class="sr-only">Volume</span> title={isPlaying ? "Pause" : "Play"}
<input onclick={() => {
type="range" if (!audioEl) return;
min="0" if (audioEl.paused) void audioEl.play();
max="1" else audioEl.pause();
step="0.01" }}
value={snap.volume} disabled={!snap.currentTrack}
oninput={(e) => >
setVolume( {#if isPlaying}
Number((e.currentTarget as HTMLInputElement).value), <PauseIcon class="h-4 w-4" />
)} {:else}
/> <PlayIcon class="h-4 w-4" />
</label> {/if}
</div> </button>
<div class="space-y-1"> <button
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div> class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
{#if snap.queue.length === 0} disabled={!canNext}
<p class="text-sm text-muted-foreground">Queue is empty.</p> aria-label="Next"
{:else} title="Next"
<ul class="overflow-auto rounded border"> onclick={() => {
{#each queueDisplay as item (item.track.id)} next();
<li void syncAndAutoplay();
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0" }}
> >
<button <SkipForward class="h-4 w-4" />
class="min-w-0 flex-1 truncate text-left text-sm hover:underline" </button>
type="button"
onclick={() => {
jumpToTrack(item.track.id);
void syncAndAutoplay();
}}
>
{#if item.isCurrent}
<span class="text-muted-foreground"></span>
{/if}
<div class="min-w-0 flex-1">
<div
class="flex flex-wrap items-baseline gap-x-2 gap-y-1"
>
{#if typeNumberLabel(item.track)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(item.track)}
</span>
{/if}
<span class="truncate text-sm"
>{animeLabel(item.track)}</span
>
</div>
<div class="mt-1 text-foreground/80">
{(item.track.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(item.track.artist ?? "").trim() ||
"Unknown Artist"}
</span>
</div>
</div>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => removeTrack(item.track.id)}
aria-label="Remove from queue"
title="Remove from queue"
>
<ListX class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/if}
</div> </div>
{:else}
<!-- Desktop: sticky, in-flow sidebar (sticks vertically, flows horizontally in the layout column) -->
<aside
class="sticky top-4 h-[calc(100dvh-2rem)] overflow-hidden bg-background flex flex-col"
>
<div class="flex items-center gap-2 border-b px-3 py-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">Player</div>
{#if snap.currentTrack} {#if snap.uiOpen}
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1"> <div class="max-h-[65dvh] overflow-y-auto border-t px-3 py-3">
{#if typeNumberLabel(snap.currentTrack)} <div class="mx-auto max-w-4xl space-y-3">
<span <div class="flex flex-wrap items-center gap-2">
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground" <button
> class="inline-flex h-8 w-8 items-center justify-center rounded border"
{typeNumberLabel(snap.currentTrack)} type="button"
</span> onclick={() => toggleShuffle()}
{/if} aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
<span class="truncate"> title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
{( >
snap.currentTrack.animeName ?? <Shuffle class="h-4 w-4" />
snap.currentTrack.album ?? </button>
"" <button
).trim()} class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleWrap()}
aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
>
<Repeat class="h-4 w-4" />
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground" aria-hidden="true">
<Volume2 class="h-4 w-4" />
</span> </span>
</div> <span class="sr-only">Volume</span>
<div class="mt-1 text-foreground/80">
{(snap.currentTrack.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
</span>
</div>
{:else}
<div class="truncate text-xs text-muted-foreground">
{nowPlayingLabel()}
</div>
{/if}
</div>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleUiOpen()}
aria-label={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
title={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
>
{#if snap.uiOpen}
<PanelRightClose class="h-4 w-4" />
{:else}
<PanelRightOpen class="h-4 w-4" />
{/if}
</button>
</div>
{#if snap.uiOpen}
<div class="flex min-h-0 flex-col">
<div class="space-y-2 px-3 py-3">
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleShuffle()}
aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
>
<Shuffle class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleWrap()}
aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
>
<Repeat class="h-4 w-4" />
</button>
</div>
</div>
<label class="block">
<span class="sr-only">Seek</span>
<input <input
class="w-full"
type="range" type="range"
min="0" min="0"
max={Math.max(0, duration)} max="1"
step="0.1" step="0.01"
bind:value={currentTime} value={snap.volume}
disabled={!snap.currentTrack || duration <= 0} oninput={(e) =>
setVolume(Number((e.currentTarget as HTMLInputElement).value))}
/> />
</label> </label>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canPrev}
aria-label="Previous"
title="Previous"
onclick={() => {
prev(currentTime);
void syncAndAutoplay();
}}
>
<SkipBack class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{#if isPlaying}
<PauseIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canNext}
aria-label="Next"
title="Next"
onclick={() => {
next();
void syncAndAutoplay();
}}
>
<SkipForward class="h-4 w-4" />
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground" aria-hidden="true">
<Volume2 class="h-4 w-4" />
</span>
<span class="sr-only">Volume</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={snap.volume}
oninput={(e) =>
setVolume(
Number((e.currentTarget as HTMLInputElement).value),
)}
/>
</label>
</div>
<div class="border-t pt-2">
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div>
</div>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-3"> <div class="space-y-1">
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div>
{#if snap.queue.length === 0} {#if snap.queue.length === 0}
<p class="text-sm text-muted-foreground">Queue is empty.</p> <p class="text-sm text-muted-foreground">Queue is empty.</p>
{:else} {:else}
<ul class="rounded border"> <ul class="overflow-auto rounded border">
{#each queueDisplay as item (item.track.id)} {#each queueDisplay as item (item.track.id)}
<li <li
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0" class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0"
@@ -926,11 +691,236 @@
{/if} {/if}
</div> </div>
</div> </div>
{/if} </div>
</aside> {/if}
{/if} </div>
<!-- Desktop UI stays in-flow in the right grid column. Hide on small screens via CSS. -->
<aside
class="hidden lg:flex sticky top-4 h-[calc(100dvh-2rem)] overflow-hidden bg-background flex-col"
>
<div class="flex items-center gap-2 border-b px-3 py-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">Player</div>
{#if snap.currentTrack}
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
{#if typeNumberLabel(snap.currentTrack)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(snap.currentTrack)}
</span>
{/if}
<span class="truncate">
{(
snap.currentTrack.animeName ??
snap.currentTrack.album ??
""
).trim()}
</span>
</div>
<div class="mt-1 text-foreground/80">
{(snap.currentTrack.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
</span>
</div>
{:else}
<div class="truncate text-xs text-muted-foreground">
{nowPlayingLabel()}
</div>
{/if}
</div>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleUiOpen()}
aria-label={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
title={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
>
{#if snap.uiOpen}
<PanelRightClose class="h-4 w-4" />
{:else}
<PanelRightOpen class="h-4 w-4" />
{/if}
</button>
</div>
{#if snap.uiOpen}
<div class="flex min-h-0 flex-col">
<div class="space-y-2 px-3 py-3">
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleShuffle()}
aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
>
<Shuffle class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleWrap()}
aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
>
<Repeat class="h-4 w-4" />
</button>
</div>
</div>
<label class="block">
<span class="sr-only">Seek</span>
<input
class="w-full"
type="range"
min="0"
max={Math.max(0, duration)}
step="0.1"
bind:value={currentTime}
disabled={!snap.currentTrack || duration <= 0}
/>
</label>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canPrev}
aria-label="Previous"
title="Previous"
onclick={() => {
prev(currentTime);
void syncAndAutoplay();
}}
>
<SkipBack class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{#if isPlaying}
<PauseIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canNext}
aria-label="Next"
title="Next"
onclick={() => {
next();
void syncAndAutoplay();
}}
>
<SkipForward class="h-4 w-4" />
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground" aria-hidden="true">
<Volume2 class="h-4 w-4" />
</span>
<span class="sr-only">Volume</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={snap.volume}
oninput={(e) =>
setVolume(Number((e.currentTarget as HTMLInputElement).value))}
/>
</label>
</div>
<div class="border-t pt-2">
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
{#if snap.queue.length === 0}
<p class="text-sm text-muted-foreground">Queue is empty.</p>
{:else}
<ul class="rounded border">
{#each queueDisplay as item (item.track.id)}
<li
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0"
>
<button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline"
type="button"
onclick={() => {
jumpToTrack(item.track.id);
void syncAndAutoplay();
}}
>
{#if item.isCurrent}
<span class="text-muted-foreground"></span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
{#if typeNumberLabel(item.track)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(item.track)}
</span>
{/if}
<span class="truncate text-sm"
>{animeLabel(item.track)}</span
>
</div>
<div class="mt-1 text-foreground/80">
{(item.track.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(item.track.artist ?? "").trim() || "Unknown Artist"}
</span>
</div>
</div>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => removeTrack(item.track.id)}
aria-label="Remove from queue"
title="Remove from queue"
>
<ListX class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</aside>
<!-- Single global audio element (hidden but functional) -->
<audio <audio
bind:this={audioEl} bind:this={audioEl}
class="hidden" class="hidden"

View File

@@ -21,15 +21,15 @@
{@render children()} {@render children()}
</main> </main>
<!-- Desktop sidebar column (in normal flow). <!-- Desktop sidebar column (in normal flow) -->
Reserve space in the grid, but render the player only once below. --> <aside class="hidden lg:block">
<aside class="hidden lg:block"></aside> <ClientOnly showFallback={false}>
{#snippet children()}
<GlobalPlayer />
{/snippet}
</ClientOnly>
</aside>
</div> </div>
<!-- Single GlobalPlayer instance (client-only). <!-- Mobile player is rendered by the same GlobalPlayer instance above.
It renders either the desktop sidebar UI or the mobile bar/drawer internally. --> On small screens, it uses fixed positioning internally so it can overlay. -->
<ClientOnly showFallback={false}>
{#snippet children()}
<GlobalPlayer />
{/snippet}
</ClientOnly>