global player pt. 1

This commit is contained in:
2026-02-06 01:29:12 -08:00
parent b7f92e2355
commit 6ec6f7c0ed
10 changed files with 1991 additions and 50 deletions

View File

@@ -0,0 +1,475 @@
<script module lang="ts">
import type { Track } from "$lib/player/types";
export type GlobalPlayerNowPlaying = Track | null;
</script>
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import {
getSnapshot,
jumpToTrack,
next,
nowPlayingLabel,
prev,
removeTrack,
setUiOpen,
setVolume,
toggleShuffle,
toggleUiOpen,
toggleWrap,
} from "$lib/player/player.svelte";
import { createMediaSessionBindings } from "$lib/player/media-session";
let audioEl: HTMLAudioElement | null = null;
let isPlaying = $state(false);
let currentTime = $state(0);
let duration = $state(0);
// local UI derived from viewport; not persisted
let isMobile = $state(false);
let snap = $state(getSnapshot());
// Keep `snap` fresh. (This is a simple polling effect; if you later want a
// more "store-like" subscription API, we can refactor player module exports.)
let raf: number | null = null;
function tickSnapshot() {
snap = getSnapshot();
raf = requestAnimationFrame(tickSnapshot);
}
function updateIsMobile() {
isMobile = window.matchMedia?.("(max-width: 1023px)")?.matches ?? false;
}
// Media Session bindings
const media = createMediaSessionBindings({
play: () => void audioEl?.play(),
pause: () => audioEl?.pause(),
next: () => {
next();
syncAudioToCurrentTrack();
void audioEl?.play();
},
prev: () => {
prev(currentTime);
syncAudioToCurrentTrack();
void audioEl?.play();
},
seekTo: (t) => {
if (!audioEl) return;
audioEl.currentTime = Math.max(0, t);
},
seekBy: (d) => {
if (!audioEl) return;
audioEl.currentTime = Math.max(0, audioEl.currentTime + d);
},
});
function syncAudioToCurrentTrack() {
if (!audioEl) return;
const track = snap.currentTrack;
if (!track) return;
const desired = track.src;
const desiredAbs = new URL(desired, window.location.href).href;
if (audioEl.currentSrc !== desiredAbs) {
audioEl.src = desired;
audioEl.load();
audioEl.currentTime = 0;
}
audioEl.volume = snap.volume;
}
function onAudioPlay() {
isPlaying = true;
media.setPlaybackState("playing");
}
function onAudioPause() {
isPlaying = false;
media.setPlaybackState("paused");
}
function onAudioTimeUpdate() {
if (!audioEl) return;
currentTime = audioEl.currentTime || 0;
media.updatePositionState({
duration,
position: currentTime,
playbackRate: audioEl.playbackRate || 1,
});
}
function onAudioLoadedMetadata() {
if (!audioEl) return;
duration = Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
media.updatePositionState({
duration,
position: currentTime,
playbackRate: audioEl.playbackRate || 1,
});
}
function onAudioEnded() {
next();
syncAudioToCurrentTrack();
if (snap.currentTrack) void audioEl?.play();
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function titleForTrack(t: Track) {
const title = (t.title ?? "").trim() || "Unknown title";
const artist = (t.artist ?? "").trim() || "Unknown Artist";
return `${title}${artist}`;
}
const canPrev = $derived(
snap.queue.length > 0 &&
snap.currentIndex != null &&
(snap.wrapEnabled || snap.currentIndex > 0 || snap.shuffleEnabled),
);
const canNext = $derived(
snap.queue.length > 0 &&
(snap.wrapEnabled ||
(snap.currentIndex != null &&
snap.currentIndex < snap.queue.length - 1) ||
snap.shuffleEnabled),
);
const uiVisible = $derived(isMobile ? snap.uiOpen : true);
$effect(() => {
media.setTrack(snap.currentTrack);
if (!audioEl) return;
audioEl.volume = snap.volume;
syncAudioToCurrentTrack();
});
onMount(() => {
updateIsMobile();
window.addEventListener("resize", updateIsMobile);
raf = requestAnimationFrame(tickSnapshot);
media.setPlaybackState("paused");
});
onDestroy(() => {
window.removeEventListener("resize", updateIsMobile);
if (raf) cancelAnimationFrame(raf);
media.destroy();
});
</script>
{#if isMobile}
<!-- Mobile: mini bar + expandable drawer -->
<div
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur"
>
<div class="mx-auto flex max-w-4xl items-center gap-2 px-3 py-2">
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => toggleUiOpen()}
aria-label={snap.uiOpen ? "Close player" : "Open player"}
>
{snap.uiOpen ? "Close" : "Player"}
</button>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{nowPlayingLabel()}</div>
<div class="text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
disabled={!canPrev}
onclick={() => {
prev(currentTime);
syncAudioToCurrentTrack();
void audioEl?.play();
}}
>
Prev
</button>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{isPlaying ? "Pause" : "Play"}
</button>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
disabled={!canNext}
onclick={() => {
next();
syncAudioToCurrentTrack();
void audioEl?.play();
}}
>
Next
</button>
</div>
{#if snap.uiOpen}
<div class="border-t px-3 py-3">
<div class="mx-auto max-w-4xl space-y-3">
<div class="flex flex-wrap items-center gap-2">
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => toggleShuffle()}
>
Shuffle: {snap.shuffleEnabled ? "On" : "Off"}
</button>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => toggleWrap()}
>
Wrap: {snap.wrapEnabled ? "On" : "Off"}
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Vol</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="space-y-1">
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div>
{#if snap.queue.length === 0}
<p class="text-sm text-muted-foreground">Queue is empty.</p>
{:else}
<ul class="max-h-64 overflow-auto rounded border">
{#each snap.queue as t, i (t.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(t.id);
syncAudioToCurrentTrack();
void audioEl?.play();
}}
>
{#if snap.currentIndex === i}
<span class="text-muted-foreground"></span>
{/if}
{titleForTrack(t)}
</button>
<button
class="rounded border px-2 py-1 text-xs"
type="button"
onclick={() => removeTrack(t.id)}
>
Remove
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/if}
</div>
{:else}
<!-- Desktop: right sidebar, collapsible -->
<aside
class="fixed right-0 top-0 z-40 h-dvh w-85 border-l bg-background/95 backdrop-blur"
>
<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>
<div class="truncate text-xs text-muted-foreground">
{nowPlayingLabel()}
</div>
</div>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => setUiOpen(!snap.uiOpen)}
>
{snap.uiOpen ? "Hide" : "Show"}
</button>
</div>
{#if uiVisible}
<div class="flex h-[calc(100dvh-49px)] 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="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => toggleShuffle()}
>
Shuffle: {snap.shuffleEnabled ? "On" : "Off"}
</button>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => toggleWrap()}
>
Wrap: {snap.wrapEnabled ? "On" : "Off"}
</button>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="rounded border px-2 py-1 text-sm"
type="button"
disabled={!canPrev}
onclick={() => {
prev(currentTime);
syncAudioToCurrentTrack();
void audioEl?.play();
}}
>
Prev
</button>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{isPlaying ? "Pause" : "Play"}
</button>
<button
class="rounded border px-2 py-1 text-sm"
type="button"
disabled={!canNext}
onclick={() => {
next();
syncAudioToCurrentTrack();
void audioEl?.play();
}}
>
Next
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Vol</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-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 snap.queue as t, i (t.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(t.id);
syncAudioToCurrentTrack();
void audioEl?.play();
}}
>
{#if snap.currentIndex === i}
<span class="text-muted-foreground"></span>
{/if}
{titleForTrack(t)}
</button>
<button
class="rounded border px-2 py-1 text-xs"
type="button"
onclick={() => removeTrack(t.id)}
>
Remove
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</aside>
<!-- Offset page content so it doesn't sit under the sidebar -->
<div class="pointer-events-none fixed right-0 top-0 h-dvh w-85"></div>
{/if}
<!-- Single global audio element (hidden but functional) -->
<audio
bind:this={audioEl}
class="hidden"
preload="metadata"
onplay={onAudioPlay}
onpause={onAudioPause}
ontimeupdate={onAudioTimeUpdate}
onloadedmetadata={onAudioLoadedMetadata}
onended={onAudioEnded}
></audio>

View File

@@ -1,33 +1,33 @@
<script module lang="ts">
let activeMediaToken: symbol | null = null;
</script>
<script lang="ts">
type SongType = 1 | 2 | 3 | number;
import {
addToQueue,
hasTrack,
play,
playNext,
removeTrack,
} from "$lib/player/player.svelte";
import { type SongType, trackFromSongRow } from "$lib/player/types";
type SongEntryProps = {
annSongId: number;
animeName: string;
type: SongType;
number: number;
songName: string;
artistName: string | null;
fileName?: string | null;
showPlayer?: boolean;
};
let {
annSongId,
animeName,
type,
number,
songName,
artistName,
fileName = null,
showPlayer = false,
}: SongEntryProps = $props();
let paused = $state(true);
const mediaToken = Symbol("song-entry");
const typeLabelMap: Record<number, string> = {
1: "OP",
2: "ED",
@@ -41,36 +41,24 @@
() => artistName?.trim() || "Unknown Artist",
);
const mediaTitle = $derived.by(() => `${animeName}${displayTypeNumber}`);
const track = $derived(
trackFromSongRow({
annSongId,
animeName,
type,
number,
songName,
artistName,
fileName,
}),
);
const mediaArtist = $derived.by(() => artistDisplay);
const mediaAlbum = $derived.by(() => `${animeName} (${displayTypeNumber})`);
$effect(() => {
if (!showPlayer || !fileName) return;
if (typeof navigator === "undefined") return;
const mediaSession = navigator.mediaSession;
if (!mediaSession || typeof MediaMetadata === "undefined") return;
if (!paused) {
activeMediaToken = mediaToken;
mediaSession.metadata = new MediaMetadata({
title: songName,
artist: mediaArtist,
album: mediaAlbum,
});
return;
}
if (activeMediaToken !== mediaToken) return;
activeMediaToken = null;
});
const isQueued = $derived(hasTrack(annSongId));
</script>
<div class="rounded border px-3 py-2">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-sm text-muted-foreground rounded bg-muted px-2 py-0.5"
<span class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>{displayTypeNumber}</span
>
{animeName}
@@ -81,18 +69,57 @@
<span class="text-sm text-muted-foreground">{artistDisplay}</span>
</div>
{#if showPlayer && fileName}
<div class="mt-2">
<audio
class="w-full"
controls
preload="metadata"
title={`${mediaTitle}${songName}${mediaArtist}`}
bind:paused
<div class="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded border px-2 py-1 text-sm"
disabled={!track}
onclick={() => {
if (!track) return;
play(track);
}}
>
Play
</button>
<button
type="button"
class="rounded border px-2 py-1 text-sm"
disabled={!track}
onclick={() => {
if (!track) return;
playNext(track);
}}
>
Play next
</button>
<button
type="button"
class="rounded border px-2 py-1 text-sm"
disabled={!track}
onclick={() => {
if (!track) return;
addToQueue(track);
}}
>
Add to queue
</button>
{#if isQueued}
<button
type="button"
class="rounded border px-2 py-1 text-sm"
onclick={() => removeTrack(annSongId)}
>
<source src={`/cdn/${fileName}`} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/if}
Remove
</button>
<span class="text-xs text-muted-foreground">Queued</span>
{/if}
{#if !track}
<span class="text-xs text-muted-foreground">No audio file</span>
{/if}
</div>
</div>