global player pt. 1
This commit is contained in:
475
src/lib/components/GlobalPlayer.svelte
Normal file
475
src/lib/components/GlobalPlayer.svelte
Normal 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>
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
<script module lang="ts">
|
|
||||||
let activeMediaToken: symbol | null = null;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<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 = {
|
type SongEntryProps = {
|
||||||
|
annSongId: number;
|
||||||
animeName: string;
|
animeName: string;
|
||||||
type: SongType;
|
type: SongType;
|
||||||
number: number;
|
number: number;
|
||||||
songName: string;
|
songName: string;
|
||||||
artistName: string | null;
|
artistName: string | null;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
showPlayer?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
annSongId,
|
||||||
animeName,
|
animeName,
|
||||||
type,
|
type,
|
||||||
number,
|
number,
|
||||||
songName,
|
songName,
|
||||||
artistName,
|
artistName,
|
||||||
fileName = null,
|
fileName = null,
|
||||||
showPlayer = false,
|
|
||||||
}: SongEntryProps = $props();
|
}: SongEntryProps = $props();
|
||||||
|
|
||||||
let paused = $state(true);
|
|
||||||
const mediaToken = Symbol("song-entry");
|
|
||||||
|
|
||||||
const typeLabelMap: Record<number, string> = {
|
const typeLabelMap: Record<number, string> = {
|
||||||
1: "OP",
|
1: "OP",
|
||||||
2: "ED",
|
2: "ED",
|
||||||
@@ -41,36 +41,24 @@
|
|||||||
() => artistName?.trim() || "Unknown Artist",
|
() => 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 isQueued = $derived(hasTrack(annSongId));
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded border px-3 py-2">
|
<div class="rounded border px-3 py-2">
|
||||||
<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">
|
||||||
<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
|
>{displayTypeNumber}</span
|
||||||
>
|
>
|
||||||
{animeName}
|
{animeName}
|
||||||
@@ -81,18 +69,57 @@
|
|||||||
<span class="text-sm text-muted-foreground">— {artistDisplay}</span>
|
<span class="text-sm text-muted-foreground">— {artistDisplay}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showPlayer && fileName}
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<div class="mt-2">
|
<button
|
||||||
<audio
|
type="button"
|
||||||
class="w-full"
|
class="rounded border px-2 py-1 text-sm"
|
||||||
controls
|
disabled={!track}
|
||||||
preload="metadata"
|
onclick={() => {
|
||||||
title={`${mediaTitle} — ${songName} — ${mediaArtist}`}
|
if (!track) return;
|
||||||
bind:paused
|
play(track);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<source src={`/cdn/${fileName}`} type="audio/mpeg" />
|
Play
|
||||||
Your browser does not support the audio element.
|
</button>
|
||||||
</audio>
|
|
||||||
</div>
|
<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)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="text-xs text-muted-foreground">Queued</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !track}
|
||||||
|
<span class="text-xs text-muted-foreground">No audio file</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
189
src/lib/player/media-session.ts
Normal file
189
src/lib/player/media-session.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
import type { Track } from "./types";
|
||||||
|
|
||||||
|
export type MediaSessionHandlers = {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
next: () => void;
|
||||||
|
prev: () => void;
|
||||||
|
seekTo?: (timeSeconds: number) => void;
|
||||||
|
seekBy?: (deltaSeconds: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaSessionBindings = {
|
||||||
|
/**
|
||||||
|
* Call whenever the current track changes.
|
||||||
|
*/
|
||||||
|
setTrack: (track: Track | null) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call on play/pause changes if you want to keep Media Session "active" state
|
||||||
|
* aligned with your app (handlers still work regardless).
|
||||||
|
*/
|
||||||
|
setPlaybackState: (state: "none" | "paused" | "playing") => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call reasonably often (e.g. on `timeupdate`, `loadedmetadata`, `ratechange`)
|
||||||
|
* to keep lockscreen / OS UI in sync.
|
||||||
|
*/
|
||||||
|
updatePositionState: (args: {
|
||||||
|
duration: number;
|
||||||
|
position: number;
|
||||||
|
playbackRate?: number;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters handlers. Optional — layout-scoped players typically never unmount.
|
||||||
|
*/
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function canUseMediaSession() {
|
||||||
|
return (
|
||||||
|
browser &&
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
"mediaSession" in navigator &&
|
||||||
|
typeof (navigator as Navigator).mediaSession !== "undefined"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseMetadata() {
|
||||||
|
return typeof MediaMetadata !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaSessionBindings(
|
||||||
|
handlers: MediaSessionHandlers,
|
||||||
|
): MediaSessionBindings {
|
||||||
|
const mediaSession = canUseMediaSession()
|
||||||
|
? (navigator as Navigator).mediaSession
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const setActionHandler = (
|
||||||
|
action: MediaSessionAction,
|
||||||
|
handler: MediaSessionActionHandler | null,
|
||||||
|
) => {
|
||||||
|
if (!mediaSession) return;
|
||||||
|
try {
|
||||||
|
mediaSession.setActionHandler(action, handler);
|
||||||
|
} catch {
|
||||||
|
// Some browsers throw for unsupported actions; ignore.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeNumber = (n: number) => (Number.isFinite(n) ? n : 0);
|
||||||
|
|
||||||
|
const setTrack = (track: Track | null) => {
|
||||||
|
if (!mediaSession) return;
|
||||||
|
if (!canUseMetadata()) return;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
// Keep it simple: clear metadata.
|
||||||
|
mediaSession.metadata = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
// You can add artwork later if/when you have it:
|
||||||
|
// artwork: [{ src: "/some.png", sizes: "512x512", type: "image/png" }]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPlaybackState = (state: "none" | "paused" | "playing") => {
|
||||||
|
if (!mediaSession) return;
|
||||||
|
try {
|
||||||
|
mediaSession.playbackState = state;
|
||||||
|
} catch {
|
||||||
|
// Some browsers may not implement playbackState; ignore.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePositionState = (args: {
|
||||||
|
duration: number;
|
||||||
|
position: number;
|
||||||
|
playbackRate?: number;
|
||||||
|
}) => {
|
||||||
|
if (!mediaSession) return;
|
||||||
|
|
||||||
|
const anySession = mediaSession as unknown as {
|
||||||
|
setPositionState?: (state: {
|
||||||
|
duration: number;
|
||||||
|
playbackRate?: number;
|
||||||
|
position: number;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof anySession.setPositionState !== "function") return;
|
||||||
|
|
||||||
|
const duration = Math.max(0, safeNumber(args.duration));
|
||||||
|
const position = Math.max(0, safeNumber(args.position));
|
||||||
|
const playbackRate = args.playbackRate ?? 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
anySession.setPositionState({
|
||||||
|
duration,
|
||||||
|
position: Math.min(position, duration || position),
|
||||||
|
playbackRate,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// iOS Safari and some Chromium variants can throw on invalid values.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installHandlers = () => {
|
||||||
|
if (!mediaSession) return;
|
||||||
|
|
||||||
|
setActionHandler("play", () => handlers.play());
|
||||||
|
setActionHandler("pause", () => handlers.pause());
|
||||||
|
setActionHandler("previoustrack", () => handlers.prev());
|
||||||
|
setActionHandler("nexttrack", () => handlers.next());
|
||||||
|
|
||||||
|
// Seeking (optional)
|
||||||
|
setActionHandler("seekto", (details) => {
|
||||||
|
if (!handlers.seekTo) return;
|
||||||
|
const d = details as MediaSessionActionDetails & { seekTime?: number };
|
||||||
|
if (typeof d.seekTime !== "number") return;
|
||||||
|
handlers.seekTo(d.seekTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActionHandler("seekbackward", (details) => {
|
||||||
|
const d = details as MediaSessionActionDetails & { seekOffset?: number };
|
||||||
|
const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
|
||||||
|
if (handlers.seekBy) handlers.seekBy(-offset);
|
||||||
|
else if (handlers.seekTo) handlers.seekTo(0); // fallback-ish
|
||||||
|
});
|
||||||
|
|
||||||
|
setActionHandler("seekforward", (details) => {
|
||||||
|
const d = details as MediaSessionActionDetails & { seekOffset?: number };
|
||||||
|
const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
|
||||||
|
if (handlers.seekBy) handlers.seekBy(offset);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop isn't as universally supported; map to pause.
|
||||||
|
setActionHandler("stop", () => handlers.pause());
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
if (!mediaSession) return;
|
||||||
|
// Clear handlers we set.
|
||||||
|
setActionHandler("play", null);
|
||||||
|
setActionHandler("pause", null);
|
||||||
|
setActionHandler("previoustrack", null);
|
||||||
|
setActionHandler("nexttrack", null);
|
||||||
|
setActionHandler("seekto", null);
|
||||||
|
setActionHandler("seekbackward", null);
|
||||||
|
setActionHandler("seekforward", null);
|
||||||
|
setActionHandler("stop", null);
|
||||||
|
};
|
||||||
|
|
||||||
|
installHandlers();
|
||||||
|
|
||||||
|
return {
|
||||||
|
setTrack,
|
||||||
|
setPlaybackState,
|
||||||
|
updatePositionState,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
232
src/lib/player/persist.ts
Normal file
232
src/lib/player/persist.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import type { Track } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence for the global player.
|
||||||
|
*
|
||||||
|
* Persisted:
|
||||||
|
* - queue
|
||||||
|
* - currentIndex
|
||||||
|
* - shuffleEnabled
|
||||||
|
* - wrapEnabled
|
||||||
|
* - shuffle traversal bookkeeping (order/history/cursor)
|
||||||
|
* - volume
|
||||||
|
* - uiOpen
|
||||||
|
*
|
||||||
|
* Not persisted by design:
|
||||||
|
* - currentTime / playback position
|
||||||
|
* - isPlaying (we always restore paused)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "amqtrain:player:v1";
|
||||||
|
const STORAGE_VERSION = 1;
|
||||||
|
|
||||||
|
const TrackSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.number().int().nonnegative(),
|
||||||
|
src: z.string().min(1),
|
||||||
|
title: z.string().default(""),
|
||||||
|
artist: z.string().default(""),
|
||||||
|
album: z.string().default(""),
|
||||||
|
|
||||||
|
animeName: z.string().optional(),
|
||||||
|
type: z.number().optional(),
|
||||||
|
number: z.number().optional(),
|
||||||
|
fileName: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const PersistedSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.literal(STORAGE_VERSION),
|
||||||
|
|
||||||
|
queue: z.array(TrackSchema).default([]),
|
||||||
|
currentIndex: z.number().int().nullable().default(null),
|
||||||
|
|
||||||
|
shuffleEnabled: z.boolean().default(false),
|
||||||
|
wrapEnabled: z.boolean().default(false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffle traversal:
|
||||||
|
* - order: upcoming indices into `queue` in the order they will be visited
|
||||||
|
* - history: visited indices into `queue` in visit order
|
||||||
|
* - cursor: index into `history` pointing at the current item
|
||||||
|
*/
|
||||||
|
order: z.array(z.number().int().nonnegative()).default([]),
|
||||||
|
history: z.array(z.number().int().nonnegative()).default([]),
|
||||||
|
cursor: z.number().int().default(0),
|
||||||
|
|
||||||
|
volume: z.number().min(0).max(1).default(1),
|
||||||
|
uiOpen: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type PersistedSnapshot = z.infer<typeof PersistedSnapshotSchema>;
|
||||||
|
|
||||||
|
export type PersistablePlayerState = {
|
||||||
|
queue: Track[];
|
||||||
|
currentIndex: number | null;
|
||||||
|
|
||||||
|
shuffleEnabled: boolean;
|
||||||
|
wrapEnabled: boolean;
|
||||||
|
|
||||||
|
order: number[];
|
||||||
|
history: number[];
|
||||||
|
cursor: number;
|
||||||
|
|
||||||
|
volume: number;
|
||||||
|
uiOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadPersistedPlayerState(): PersistablePlayerState | null {
|
||||||
|
if (!browser) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const parsed = PersistedSnapshotSchema.safeParse(JSON.parse(raw));
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
|
return sanitizePersistedState(parsed.data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePersistedPlayerState(state: PersistablePlayerState): void {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const snapshot: PersistedSnapshot = {
|
||||||
|
version: STORAGE_VERSION,
|
||||||
|
|
||||||
|
queue: state.queue,
|
||||||
|
currentIndex: state.currentIndex,
|
||||||
|
|
||||||
|
shuffleEnabled: state.shuffleEnabled,
|
||||||
|
wrapEnabled: state.wrapEnabled,
|
||||||
|
|
||||||
|
order: state.order,
|
||||||
|
history: state.history,
|
||||||
|
cursor: state.cursor,
|
||||||
|
|
||||||
|
volume: clamp01(state.volume),
|
||||||
|
uiOpen: state.uiOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/security errors; persistence is a best-effort feature.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttled saver (simple debounce). Call this from reactive effects.
|
||||||
|
*/
|
||||||
|
export function createPersistScheduler(delayMs = 250) {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return (state: PersistablePlayerState) => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
savePersistedPlayerState(state);
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPersistedPlayerState(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePersistedState(
|
||||||
|
snapshot: PersistedSnapshot,
|
||||||
|
): PersistablePlayerState {
|
||||||
|
const queue = dedupeById(snapshot.queue);
|
||||||
|
|
||||||
|
const maxIndex = queue.length - 1;
|
||||||
|
const currentIndex =
|
||||||
|
snapshot.currentIndex == null
|
||||||
|
? null
|
||||||
|
: snapshot.currentIndex >= 0 && snapshot.currentIndex <= maxIndex
|
||||||
|
? snapshot.currentIndex
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const order = filterValidIndices(snapshot.order, queue.length);
|
||||||
|
const history = filterValidIndices(snapshot.history, queue.length);
|
||||||
|
|
||||||
|
// cursor points into history; if history is empty, cursor should be 0
|
||||||
|
const cursor =
|
||||||
|
history.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.max(0, Math.min(snapshot.cursor, history.length - 1));
|
||||||
|
|
||||||
|
// If we have a currentIndex but history doesn't reflect it, try to repair:
|
||||||
|
// put currentIndex at end and point cursor there.
|
||||||
|
let repairedHistory = history;
|
||||||
|
let repairedCursor = cursor;
|
||||||
|
|
||||||
|
if (currentIndex != null) {
|
||||||
|
if (history.length === 0 || history[cursor] !== currentIndex) {
|
||||||
|
repairedHistory = [...history, currentIndex];
|
||||||
|
repairedCursor = repairedHistory.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure order doesn't contain items already visited up through cursor.
|
||||||
|
const visitedSet =
|
||||||
|
repairedHistory.length === 0
|
||||||
|
? new Set<number>()
|
||||||
|
: new Set(repairedHistory.slice(0, repairedCursor + 1));
|
||||||
|
const repairedOrder = order.filter((i) => !visitedSet.has(i));
|
||||||
|
|
||||||
|
return {
|
||||||
|
queue,
|
||||||
|
currentIndex,
|
||||||
|
|
||||||
|
shuffleEnabled: snapshot.shuffleEnabled,
|
||||||
|
wrapEnabled: snapshot.wrapEnabled,
|
||||||
|
|
||||||
|
order: repairedOrder,
|
||||||
|
history: repairedHistory,
|
||||||
|
cursor: repairedCursor,
|
||||||
|
|
||||||
|
volume: clamp01(snapshot.volume),
|
||||||
|
uiOpen: snapshot.uiOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterValidIndices(indices: number[], length: number) {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const i of indices) {
|
||||||
|
if (Number.isInteger(i) && i >= 0 && i < length) out.push(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeById(tracks: Track[]) {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: Track[] = [];
|
||||||
|
for (const t of tracks) {
|
||||||
|
const id = Number(t.id);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(n: number) {
|
||||||
|
if (!Number.isFinite(n)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, n));
|
||||||
|
}
|
||||||
730
src/lib/player/player.svelte.ts
Normal file
730
src/lib/player/player.svelte.ts
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
import {
|
||||||
|
createPersistScheduler,
|
||||||
|
loadPersistedPlayerState,
|
||||||
|
type PersistablePlayerState,
|
||||||
|
} from "./persist";
|
||||||
|
import {
|
||||||
|
buildInitialShuffleOrder,
|
||||||
|
injectNextIntoShuffleOrder,
|
||||||
|
reindexAfterMoveOrRemove,
|
||||||
|
} from "./shuffle";
|
||||||
|
import type { Track } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global audio player state + queue/shuffle actions (Svelte 5 runes).
|
||||||
|
*
|
||||||
|
* This module is intended to be imported from UI components and pages.
|
||||||
|
* The actual <audio> element lives in a single GlobalPlayer component, which
|
||||||
|
* binds to the state here and calls actions.
|
||||||
|
*
|
||||||
|
* Canonical dedupe id: Track.id === annSongId (number).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InsertMode = "play" | "playNext" | "add";
|
||||||
|
|
||||||
|
export type PlayerSnapshot = {
|
||||||
|
queue: Track[];
|
||||||
|
currentIndex: number | null;
|
||||||
|
|
||||||
|
// derived-ish convenience
|
||||||
|
currentTrack: Track | null;
|
||||||
|
|
||||||
|
shuffleEnabled: boolean;
|
||||||
|
wrapEnabled: boolean;
|
||||||
|
|
||||||
|
// shuffle traversal
|
||||||
|
order: number[];
|
||||||
|
history: number[];
|
||||||
|
cursor: number;
|
||||||
|
|
||||||
|
volume: number;
|
||||||
|
|
||||||
|
// UI
|
||||||
|
uiOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_VOLUME = 1;
|
||||||
|
|
||||||
|
function isMobileLike() {
|
||||||
|
if (!browser) return false;
|
||||||
|
return window.matchMedia?.("(max-width: 1023px)")?.matches ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(n: number) {
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_VOLUME;
|
||||||
|
return Math.max(0, Math.min(1, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayRemoveAt<T>(arr: T[], index: number) {
|
||||||
|
if (index < 0 || index >= arr.length) return arr;
|
||||||
|
return [...arr.slice(0, index), ...arr.slice(index + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayInsertAt<T>(arr: T[], index: number, item: T) {
|
||||||
|
const i = Math.max(0, Math.min(index, arr.length));
|
||||||
|
return [...arr.slice(0, i), item, ...arr.slice(i)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayMove<T>(arr: T[], from: number, to: number) {
|
||||||
|
if (from === to) return arr;
|
||||||
|
if (from < 0 || from >= arr.length) return arr;
|
||||||
|
if (to < 0 || to >= arr.length) return arr;
|
||||||
|
|
||||||
|
const copy = [...arr];
|
||||||
|
const [item] = copy.splice(from, 1);
|
||||||
|
copy.splice(to, 0, item);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeTracks(tracks: Track[]) {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: Track[] = [];
|
||||||
|
for (const t of tracks) {
|
||||||
|
const id = Number(t.id);
|
||||||
|
if (!Number.isFinite(id)) continue;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitedSet(history: number[], cursor: number) {
|
||||||
|
if (history.length === 0) return new Set<number>();
|
||||||
|
const end = Math.max(0, Math.min(cursor, history.length - 1));
|
||||||
|
return new Set(history.slice(0, end + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistableState(): PersistablePlayerState {
|
||||||
|
return {
|
||||||
|
queue,
|
||||||
|
currentIndex,
|
||||||
|
|
||||||
|
shuffleEnabled,
|
||||||
|
wrapEnabled,
|
||||||
|
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
|
||||||
|
volume,
|
||||||
|
uiOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Initialize state (restore persisted if present) --- */
|
||||||
|
|
||||||
|
const persisted = browser ? loadPersistedPlayerState() : null;
|
||||||
|
|
||||||
|
let queue = $state<Track[]>(persisted?.queue ?? []);
|
||||||
|
let currentIndex = $state<number | null>(persisted?.currentIndex ?? null);
|
||||||
|
|
||||||
|
let shuffleEnabled = $state<boolean>(persisted?.shuffleEnabled ?? false);
|
||||||
|
let wrapEnabled = $state<boolean>(persisted?.wrapEnabled ?? false);
|
||||||
|
|
||||||
|
let order = $state<number[]>(persisted?.order ?? []);
|
||||||
|
let history = $state<number[]>(persisted?.history ?? []);
|
||||||
|
let cursor = $state<number>(persisted?.cursor ?? 0);
|
||||||
|
|
||||||
|
let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME));
|
||||||
|
|
||||||
|
let uiOpen = $state<boolean>(
|
||||||
|
persisted?.uiOpen ??
|
||||||
|
// Defaults per agreement:
|
||||||
|
// - mobile: closed
|
||||||
|
// - desktop: open
|
||||||
|
!isMobileLike(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentTrack = $derived<Track | null>(
|
||||||
|
currentIndex == null ? null : (queue[currentIndex] ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = $derived<PlayerSnapshot>({
|
||||||
|
queue,
|
||||||
|
currentIndex,
|
||||||
|
currentTrack,
|
||||||
|
shuffleEnabled,
|
||||||
|
wrapEnabled,
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
volume,
|
||||||
|
uiOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Persist on changes (best effort, throttled). */
|
||||||
|
const schedulePersist = createPersistScheduler(250);
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
schedulePersist(persistableState());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** --- Public reads --- */
|
||||||
|
|
||||||
|
export function getSnapshot(): PlayerSnapshot {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTrack(id: number): boolean {
|
||||||
|
const wanted = Number(id);
|
||||||
|
return queue.some((t) => t.id === wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexOfTrack(id: number): number {
|
||||||
|
const wanted = Number(id);
|
||||||
|
return queue.findIndex((t) => t.id === wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Queue traversal helpers --- */
|
||||||
|
|
||||||
|
function ensureTraversalStateForCurrent() {
|
||||||
|
// Ensure history/cursor reflect currentIndex when possible.
|
||||||
|
if (currentIndex == null) {
|
||||||
|
history = [];
|
||||||
|
cursor = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
history = [currentIndex];
|
||||||
|
cursor = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = history[cursor];
|
||||||
|
if (cur !== currentIndex) {
|
||||||
|
// Repair without rewriting past: append and move cursor to it.
|
||||||
|
history = [...history, currentIndex];
|
||||||
|
cursor = history.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildShuffleOrderPreservingPast() {
|
||||||
|
if (!shuffleEnabled) {
|
||||||
|
order = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
|
||||||
|
const visited = visitedSet(history, cursor);
|
||||||
|
order = buildInitialShuffleOrder(queue.length, visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next queue index according to shuffle/linear mode.
|
||||||
|
* Returns null if there is no next item (and wrap is disabled).
|
||||||
|
*/
|
||||||
|
function computeNextIndex(): number | null {
|
||||||
|
if (queue.length === 0) return null;
|
||||||
|
|
||||||
|
if (currentIndex == null) return 0;
|
||||||
|
|
||||||
|
if (!shuffleEnabled) {
|
||||||
|
const next = currentIndex + 1;
|
||||||
|
if (next < queue.length) return next;
|
||||||
|
return wrapEnabled ? 0 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
|
||||||
|
// If user went backwards in history and presses next, prefer moving forward in history.
|
||||||
|
if (cursor < history.length - 1) {
|
||||||
|
return history[cursor + 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.length === 0) {
|
||||||
|
if (!wrapEnabled) return null;
|
||||||
|
|
||||||
|
// Wrap in shuffle mode: keep past history, but allow revisiting by
|
||||||
|
// generating a fresh future order excluding "past" only up through cursor.
|
||||||
|
// Since at end, visited is all history; to wrap, we treat visited as empty
|
||||||
|
// and start a new cycle, but we MUST NOT mutate history.
|
||||||
|
// Easiest: generate an order containing all indices except current first,
|
||||||
|
// then inject current out.
|
||||||
|
const all = new Set<number>();
|
||||||
|
// visited empty for wrap cycle
|
||||||
|
order = buildInitialShuffleOrder(queue.length, all);
|
||||||
|
order = order.filter((i) => i !== history[cursor]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the previous queue index according to shuffle history or linear mode.
|
||||||
|
* Returns null if there is no previous item (and wrap is disabled).
|
||||||
|
*/
|
||||||
|
function computePrevIndex(currentTimeSeconds = 0): number | null {
|
||||||
|
if (queue.length === 0) return null;
|
||||||
|
if (currentIndex == null) return null;
|
||||||
|
|
||||||
|
// Standard behavior: if you've listened a bit, restart track.
|
||||||
|
if (currentTimeSeconds > 3) return currentIndex;
|
||||||
|
|
||||||
|
if (!shuffleEnabled) {
|
||||||
|
const prev = currentIndex - 1;
|
||||||
|
if (prev >= 0) return prev;
|
||||||
|
return wrapEnabled ? queue.length - 1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
|
||||||
|
if (cursor > 0) return history[cursor - 1] ?? null;
|
||||||
|
if (!wrapEnabled) return null;
|
||||||
|
|
||||||
|
// Wrap backwards in shuffle mode:
|
||||||
|
// We can jump to the last item in history if it exists, otherwise pick any.
|
||||||
|
if (history.length > 0) return history[history.length - 1] ?? null;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurrentIndex(next: number | null) {
|
||||||
|
currentIndex = next;
|
||||||
|
|
||||||
|
if (next == null) {
|
||||||
|
// stop traversal, but keep queue
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shuffleEnabled) return;
|
||||||
|
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
|
||||||
|
// If we navigated forward within history, just advance cursor.
|
||||||
|
if (cursor < history.length && history[cursor] !== next) {
|
||||||
|
const existingPos = history.indexOf(next);
|
||||||
|
if (existingPos !== -1) {
|
||||||
|
cursor = existingPos;
|
||||||
|
} else {
|
||||||
|
// New visit: append and advance cursor.
|
||||||
|
history = [...history, next];
|
||||||
|
cursor = history.length - 1;
|
||||||
|
}
|
||||||
|
} else if (history[cursor] !== next) {
|
||||||
|
history = [...history, next];
|
||||||
|
cursor = history.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume from order if we used its head
|
||||||
|
if (order[0] === next) order = order.slice(1);
|
||||||
|
|
||||||
|
// Remove from future order if it appears later (avoid revisiting within cycle)
|
||||||
|
order = order.filter((i) => i !== next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Public traversal actions --- */
|
||||||
|
|
||||||
|
export function next(): void {
|
||||||
|
const idx = computeNextIndex();
|
||||||
|
if (idx == null) {
|
||||||
|
currentIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyCurrentIndex(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prev(currentTimeSeconds = 0): void {
|
||||||
|
const idx = computePrevIndex(currentTimeSeconds);
|
||||||
|
if (idx == null) return;
|
||||||
|
|
||||||
|
// If idx === currentIndex, we interpret that as "restart track"
|
||||||
|
applyCurrentIndex(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to an existing queued track by id (does not reorder). */
|
||||||
|
export function jumpToTrack(id: number): void {
|
||||||
|
const i = indexOfTrack(id);
|
||||||
|
if (i === -1) return;
|
||||||
|
applyCurrentIndex(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Queue mutation primitives (keep traversal state consistent) --- */
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
if (index < 0 || index >= queue.length) return;
|
||||||
|
|
||||||
|
queue = arrayRemoveAt(queue, index);
|
||||||
|
|
||||||
|
// Fix currentIndex
|
||||||
|
if (currentIndex != null) {
|
||||||
|
if (currentIndex === index) {
|
||||||
|
// Removing current track -> advance to next if possible else stop
|
||||||
|
currentIndex = null;
|
||||||
|
} else if (currentIndex > index) {
|
||||||
|
currentIndex = currentIndex - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
const re = reindexAfterMoveOrRemove({
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
currentIndex,
|
||||||
|
fromIndex: index,
|
||||||
|
toIndex: null,
|
||||||
|
});
|
||||||
|
order = re.order;
|
||||||
|
history = re.history;
|
||||||
|
cursor = re.cursor;
|
||||||
|
currentIndex = re.currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If current was removed, attempt to advance
|
||||||
|
if (currentIndex == null && queue.length > 0) {
|
||||||
|
const idx = computeNextIndex();
|
||||||
|
if (idx != null) applyCurrentIndex(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveIndex(from: number, to: number) {
|
||||||
|
if (from === to) return;
|
||||||
|
if (from < 0 || from >= queue.length) return;
|
||||||
|
if (to < 0 || to >= queue.length) return;
|
||||||
|
|
||||||
|
queue = arrayMove(queue, from, to);
|
||||||
|
|
||||||
|
// Fix currentIndex (linear)
|
||||||
|
if (currentIndex != null) {
|
||||||
|
if (currentIndex === from) currentIndex = to;
|
||||||
|
else if (to < from) {
|
||||||
|
// moved earlier
|
||||||
|
if (currentIndex >= to && currentIndex < from) currentIndex += 1;
|
||||||
|
} else {
|
||||||
|
// moved later
|
||||||
|
if (currentIndex > from && currentIndex <= to) currentIndex -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
const re = reindexAfterMoveOrRemove({
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
currentIndex,
|
||||||
|
fromIndex: from,
|
||||||
|
toIndex: to,
|
||||||
|
});
|
||||||
|
order = re.order;
|
||||||
|
history = re.history;
|
||||||
|
cursor = re.cursor;
|
||||||
|
currentIndex = re.currentIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a new track (that is NOT currently in queue) at index. */
|
||||||
|
function insertNewAt(index: number, track: Track) {
|
||||||
|
queue = arrayInsertAt(queue, index, track);
|
||||||
|
|
||||||
|
// Fix currentIndex if insertion occurs before/at current
|
||||||
|
if (currentIndex != null && index <= currentIndex) {
|
||||||
|
currentIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shuffleEnabled) return;
|
||||||
|
|
||||||
|
// When shuffle is enabled, new item should be eligible for future play.
|
||||||
|
// We do not touch past history.
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
|
||||||
|
// We inserted at `index`, which shifts all existing indices >= index by +1.
|
||||||
|
// Reindex traversal state for a conceptual move "fromIndex = -1" doesn't fit,
|
||||||
|
// so perform a manual shift.
|
||||||
|
const shiftUp = (i: number) => (i >= index ? i + 1 : i);
|
||||||
|
|
||||||
|
history = history.map(shiftUp);
|
||||||
|
order = order.map(shiftUp);
|
||||||
|
|
||||||
|
// New track is at `index`. By default it should appear in the remaining order.
|
||||||
|
// We'll append; specific actions (play/playNext) will inject it as needed.
|
||||||
|
order = [...order, index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Public queue actions --- */
|
||||||
|
|
||||||
|
export function clearQueue(): void {
|
||||||
|
queue = [];
|
||||||
|
currentIndex = null;
|
||||||
|
|
||||||
|
order = [];
|
||||||
|
history = [];
|
||||||
|
cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTrack(id: number): void {
|
||||||
|
const i = indexOfTrack(id);
|
||||||
|
if (i === -1) return;
|
||||||
|
removeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core insertion behavior per your rules.
|
||||||
|
*
|
||||||
|
* - "play": move/insert to right-after-current and then skip to it
|
||||||
|
* - "playNext": move/insert to right-after-current but don't skip
|
||||||
|
* - "add": append (deduped)
|
||||||
|
*
|
||||||
|
* Dedupe semantics:
|
||||||
|
* - If exists, we MOVE it instead of duplicating.
|
||||||
|
*/
|
||||||
|
export function insertTrack(track: Track, mode: InsertMode): void {
|
||||||
|
// Normalize + basic guard
|
||||||
|
if (!track || !Number.isFinite(track.id) || !track.src) return;
|
||||||
|
|
||||||
|
// Empty queue behavior
|
||||||
|
if (queue.length === 0) {
|
||||||
|
queue = [track];
|
||||||
|
currentIndex = 0;
|
||||||
|
|
||||||
|
// Initialize traversal state
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
history = [0];
|
||||||
|
cursor = 0;
|
||||||
|
order = [];
|
||||||
|
} else {
|
||||||
|
history = [];
|
||||||
|
cursor = 0;
|
||||||
|
order = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "add" || mode === "playNext") {
|
||||||
|
// If user only adds, do not auto-start; but we still set currentIndex?
|
||||||
|
// Per your desired behavior: "start the queue if it's empty" occurs on Play,
|
||||||
|
// not necessarily on Add/PlayNext. We'll keep "currentIndex = 0" so it shows
|
||||||
|
// a selected track, but GlobalPlayer should remain paused until user hits play.
|
||||||
|
// NOTE: If you prefer currentIndex to remain null for add/playNext on empty,
|
||||||
|
// we can tweak later.
|
||||||
|
}
|
||||||
|
|
||||||
|
// For play: skipping to inserted is effectively current track already.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine insertion target: right after current, or at end if nothing selected.
|
||||||
|
const base =
|
||||||
|
currentIndex == null
|
||||||
|
? -1
|
||||||
|
: Math.max(-1, Math.min(currentIndex, queue.length - 1));
|
||||||
|
const targetIndex = base + 1;
|
||||||
|
|
||||||
|
const existingIndex = indexOfTrack(track.id);
|
||||||
|
|
||||||
|
if (mode === "add") {
|
||||||
|
if (existingIndex !== -1) return;
|
||||||
|
insertNewAt(queue.length, track);
|
||||||
|
// No traversal tweaks required
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// play / playNext:
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
// Insert as a new item at targetIndex
|
||||||
|
insertNewAt(targetIndex, track);
|
||||||
|
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
// Ensure it becomes next in shuffled traversal
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
order = injectNextIntoShuffleOrder({
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
nextIndex: targetIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Move existing to targetIndex (account for removal shifting)
|
||||||
|
// If existing is before target, the "real" target after removal shifts by -1
|
||||||
|
const adjustedTarget =
|
||||||
|
existingIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||||||
|
|
||||||
|
if (existingIndex !== adjustedTarget) {
|
||||||
|
moveIndex(existingIndex, adjustedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
order = injectNextIntoShuffleOrder({
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
nextIndex: adjustedTarget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "play") {
|
||||||
|
// Skip current -> go to that "next"
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertTracks(tracks: Track[], mode: InsertMode): void {
|
||||||
|
const incoming = dedupeTracks(tracks).filter((t) => t?.src);
|
||||||
|
|
||||||
|
if (incoming.length === 0) return;
|
||||||
|
|
||||||
|
if (mode === "add") {
|
||||||
|
// Append in order
|
||||||
|
for (const t of incoming) insertTrack(t, "add");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For play/playNext with multiple tracks:
|
||||||
|
// Place them sequentially after current in the order provided.
|
||||||
|
// - playNext: do not skip
|
||||||
|
// - play: skip to first inserted (becomes next), but keep subsequent ones after it
|
||||||
|
const base =
|
||||||
|
queue.length === 0
|
||||||
|
? -1
|
||||||
|
: currentIndex == null
|
||||||
|
? queue.length - 1
|
||||||
|
: currentIndex;
|
||||||
|
|
||||||
|
let insertPos = base + 1;
|
||||||
|
|
||||||
|
for (const t of incoming) {
|
||||||
|
// If it already exists, moving it will affect indices; recompute on each iteration.
|
||||||
|
// We use insertTrack which handles move + dedupe, but for multiple we want stable sequential order.
|
||||||
|
// We'll emulate by:
|
||||||
|
// - ensuring track is at insertPos
|
||||||
|
// - then increment insertPos
|
||||||
|
const existing = indexOfTrack(t.id);
|
||||||
|
if (existing === -1) {
|
||||||
|
insertNewAt(insertPos, t);
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
order = injectNextIntoShuffleOrder({
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
nextIndex: insertPos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
insertPos += 1;
|
||||||
|
} else {
|
||||||
|
const adjustedTarget = existing < insertPos ? insertPos - 1 : insertPos;
|
||||||
|
if (existing !== adjustedTarget) moveIndex(existing, adjustedTarget);
|
||||||
|
if (shuffleEnabled) {
|
||||||
|
ensureTraversalStateForCurrent();
|
||||||
|
order = injectNextIntoShuffleOrder({
|
||||||
|
order,
|
||||||
|
history,
|
||||||
|
cursor,
|
||||||
|
nextIndex: adjustedTarget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
insertPos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "play") {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Toggles / settings --- */
|
||||||
|
|
||||||
|
export function setVolume(v: number): void {
|
||||||
|
volume = clamp01(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUiOpen(open: boolean): void {
|
||||||
|
uiOpen = !!open;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleUiOpen(): void {
|
||||||
|
uiOpen = !uiOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleWrap(): void {
|
||||||
|
wrapEnabled = !wrapEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableShuffle(enable: boolean): void {
|
||||||
|
const nextVal = !!enable;
|
||||||
|
if (shuffleEnabled === nextVal) return;
|
||||||
|
|
||||||
|
shuffleEnabled = nextVal;
|
||||||
|
|
||||||
|
if (!shuffleEnabled) {
|
||||||
|
order = [];
|
||||||
|
history = [];
|
||||||
|
cursor = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning shuffle on: preserve current as starting history point
|
||||||
|
if (currentIndex != null) {
|
||||||
|
history = [currentIndex];
|
||||||
|
cursor = 0;
|
||||||
|
} else {
|
||||||
|
history = [];
|
||||||
|
cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildShuffleOrderPreservingPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleShuffle(): void {
|
||||||
|
enableShuffle(!shuffleEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure traversal state is sane if queue was externally replaced.
|
||||||
|
* Not expected in normal usage, but handy if you implement "replace queue" later.
|
||||||
|
*/
|
||||||
|
export function setQueue(
|
||||||
|
tracks: Track[],
|
||||||
|
opts?: { startIndex?: number | null },
|
||||||
|
) {
|
||||||
|
queue = dedupeTracks(tracks);
|
||||||
|
currentIndex =
|
||||||
|
opts?.startIndex == null
|
||||||
|
? null
|
||||||
|
: opts.startIndex >= 0 && opts.startIndex < queue.length
|
||||||
|
? opts.startIndex
|
||||||
|
: null;
|
||||||
|
|
||||||
|
order = [];
|
||||||
|
history = [];
|
||||||
|
cursor = 0;
|
||||||
|
|
||||||
|
if (shuffleEnabled) rebuildShuffleOrderPreservingPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --- Convenience wrappers that match UI wording --- */
|
||||||
|
|
||||||
|
export function play(track: Track): void {
|
||||||
|
insertTrack(track, "play");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playNext(track: Track): void {
|
||||||
|
insertTrack(track, "playNext");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToQueue(track: Track): void {
|
||||||
|
insertTrack(track, "add");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAllToQueue(tracks: Track[]): void {
|
||||||
|
insertTracks(tracks, "add");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playAllNext(tracks: Track[]): void {
|
||||||
|
insertTracks(tracks, "playNext");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal "now playing" string helpers for UI.
|
||||||
|
* Keeping here avoids repeated null checks in templates.
|
||||||
|
*/
|
||||||
|
export function nowPlayingLabel(): string {
|
||||||
|
if (!currentTrack) return "Nothing playing";
|
||||||
|
const t = currentTrack;
|
||||||
|
const title = (t.title ?? "").trim() || "Unknown title";
|
||||||
|
const artist = (t.artist ?? "").trim() || "Unknown Artist";
|
||||||
|
return `${title} — ${artist}`;
|
||||||
|
}
|
||||||
181
src/lib/player/shuffle.ts
Normal file
181
src/lib/player/shuffle.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
export type ShuffleState = {
|
||||||
|
/**
|
||||||
|
* Upcoming indices into the *current queue* in the order they will be visited
|
||||||
|
* when calling `next()` while shuffle is enabled.
|
||||||
|
*/
|
||||||
|
order: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visited indices into the *current queue* in visit order.
|
||||||
|
* `cursor` points at the currently active entry within this array.
|
||||||
|
*/
|
||||||
|
history: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index into `history` that represents the current item.
|
||||||
|
*/
|
||||||
|
cursor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReindexResult = {
|
||||||
|
order: number[];
|
||||||
|
history: number[];
|
||||||
|
cursor: number;
|
||||||
|
currentIndex: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a shuffled play order for the *future* without affecting past history.
|
||||||
|
*
|
||||||
|
* - `queueLength`: length of the queue.
|
||||||
|
* - `visited`: indices that are considered "already visited" (typically history[0..cursor]).
|
||||||
|
* - `rng`: optional deterministic RNG for tests.
|
||||||
|
*/
|
||||||
|
export function buildInitialShuffleOrder(
|
||||||
|
queueLength: number,
|
||||||
|
visited: Set<number>,
|
||||||
|
rng: () => number = Math.random,
|
||||||
|
): number[] {
|
||||||
|
const remaining: number[] = [];
|
||||||
|
for (let i = 0; i < queueLength; i += 1) {
|
||||||
|
if (!visited.has(i)) remaining.push(i);
|
||||||
|
}
|
||||||
|
return shuffleArray(remaining, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a track so it becomes the "next" item, even while shuffle is enabled.
|
||||||
|
*
|
||||||
|
* This modifies ONLY the future order. Past history is preserved.
|
||||||
|
*
|
||||||
|
* - If `nextIndex` is already in `order`, it is moved to the front.
|
||||||
|
* - If `nextIndex` is currently in "visited" history (<= cursor), we do not
|
||||||
|
* reschedule it as next (that would violate history semantics). In that case,
|
||||||
|
* this function is a no-op.
|
||||||
|
* - If `track` is the current item (history[cursor]), no-op.
|
||||||
|
*/
|
||||||
|
export function injectNextIntoShuffleOrder(args: {
|
||||||
|
order: number[];
|
||||||
|
history: number[];
|
||||||
|
cursor: number;
|
||||||
|
nextIndex: number;
|
||||||
|
}): number[] {
|
||||||
|
const { order, history, cursor, nextIndex } = args;
|
||||||
|
|
||||||
|
if (!Number.isInteger(nextIndex) || nextIndex < 0) return order;
|
||||||
|
|
||||||
|
const current = history[cursor];
|
||||||
|
if (current === nextIndex) return order;
|
||||||
|
|
||||||
|
// Preserve past: don't schedule already-visited entries as "next"
|
||||||
|
const visited = new Set(history.slice(0, cursor + 1));
|
||||||
|
if (visited.has(nextIndex)) return order;
|
||||||
|
|
||||||
|
const nextOrder: number[] = [];
|
||||||
|
nextOrder.push(nextIndex);
|
||||||
|
|
||||||
|
for (const i of order) {
|
||||||
|
if (i === nextIndex) continue;
|
||||||
|
nextOrder.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reindex a shuffle traversal state after a queue mutation that changes indices.
|
||||||
|
*
|
||||||
|
* Provide:
|
||||||
|
* - `fromIndex`: original index of the moved/removed item
|
||||||
|
* - `toIndex`: new index of the moved item, or `null` if the item was removed
|
||||||
|
* - `currentIndex`: the current queue index before reindexing (may be null)
|
||||||
|
*
|
||||||
|
* This updates `history`, `order`, and `currentIndex` so they point to correct
|
||||||
|
* indices in the new queue.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - This assumes a *single-item* move or remove.
|
||||||
|
* - For "insert new item" (not previously present), you typically don't need
|
||||||
|
* this; instead you just insert the index into `order` as desired.
|
||||||
|
*/
|
||||||
|
export function reindexAfterMoveOrRemove(args: {
|
||||||
|
order: number[];
|
||||||
|
history: number[];
|
||||||
|
cursor: number;
|
||||||
|
currentIndex: number | null;
|
||||||
|
|
||||||
|
fromIndex: number;
|
||||||
|
toIndex: number | null;
|
||||||
|
}): ReindexResult {
|
||||||
|
const { fromIndex, toIndex } = args;
|
||||||
|
|
||||||
|
const remap = (i: number): number | null => {
|
||||||
|
if (!Number.isInteger(i) || i < 0) return null;
|
||||||
|
|
||||||
|
// Removal
|
||||||
|
if (toIndex == null) {
|
||||||
|
if (i === fromIndex) return null;
|
||||||
|
if (i > fromIndex) return i - 1;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move
|
||||||
|
if (fromIndex === toIndex) return i;
|
||||||
|
|
||||||
|
// Moving earlier: items between [toIndex .. fromIndex-1] shift +1
|
||||||
|
if (toIndex < fromIndex) {
|
||||||
|
if (i === fromIndex) return toIndex;
|
||||||
|
if (i >= toIndex && i < fromIndex) return i + 1;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moving later: items between [fromIndex+1 .. toIndex] shift -1
|
||||||
|
// (because we remove fromIndex then insert at toIndex)
|
||||||
|
if (i === fromIndex) return toIndex;
|
||||||
|
if (i > fromIndex && i <= toIndex) return i - 1;
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
|
||||||
|
const history = compactAndDedupePreservingOrder(
|
||||||
|
args.history.map((i) => remap(i)).filter((i): i is number => i != null),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cursor points into history; keep it at the same logical "current" where possible.
|
||||||
|
// If the current item was removed, clamp.
|
||||||
|
let cursor = args.cursor;
|
||||||
|
if (history.length === 0) cursor = 0;
|
||||||
|
else cursor = Math.max(0, Math.min(cursor, history.length - 1));
|
||||||
|
|
||||||
|
const order = compactAndDedupePreservingOrder(
|
||||||
|
args.order.map((i) => remap(i)).filter((i): i is number => i != null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentIndex =
|
||||||
|
args.currentIndex == null ? null : remap(args.currentIndex);
|
||||||
|
|
||||||
|
return { order, history, cursor, currentIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleArray<T>(arr: T[], rng: () => number) {
|
||||||
|
// Fisher–Yates shuffle (in place), return copy for convenience
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
const tmp = a[i];
|
||||||
|
a[i] = a[j];
|
||||||
|
a[j] = tmp;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactAndDedupePreservingOrder(indices: number[]) {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const i of indices) {
|
||||||
|
if (!Number.isInteger(i) || i < 0) continue;
|
||||||
|
if (seen.has(i)) continue;
|
||||||
|
seen.add(i);
|
||||||
|
out.push(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
59
src/lib/player/types.ts
Normal file
59
src/lib/player/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export type SongType = 1 | 2 | 3 | number;
|
||||||
|
|
||||||
|
export type Track = {
|
||||||
|
/**
|
||||||
|
* Canonical unique identifier for playback + dedupe.
|
||||||
|
*
|
||||||
|
* Per your rules: use `annSongId` (NOT `songId`).
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** Audio source URL. Typically `/cdn/${fileName}`. */
|
||||||
|
src: string;
|
||||||
|
|
||||||
|
/** Display metadata */
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
|
||||||
|
/** Optional extra context for rendering/debugging */
|
||||||
|
animeName?: string;
|
||||||
|
type?: SongType;
|
||||||
|
number?: number;
|
||||||
|
fileName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SongRowLike = {
|
||||||
|
annSongId: number;
|
||||||
|
animeName: string;
|
||||||
|
type: SongType;
|
||||||
|
number: number;
|
||||||
|
songName: string;
|
||||||
|
artistName: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DB/UI song row into a normalized playable Track.
|
||||||
|
*
|
||||||
|
* Note: returns `null` if we can't build a playable `src` (missing fileName).
|
||||||
|
*/
|
||||||
|
export function trackFromSongRow(row: SongRowLike): Track | null {
|
||||||
|
const fileName = row.fileName ?? null;
|
||||||
|
if (!fileName) return null;
|
||||||
|
|
||||||
|
const artist = (row.artistName ?? "").trim() || "Unknown Artist";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.annSongId),
|
||||||
|
src: `/cdn/${fileName}`,
|
||||||
|
title: row.songName,
|
||||||
|
artist,
|
||||||
|
album: row.animeName,
|
||||||
|
|
||||||
|
animeName: row.animeName,
|
||||||
|
type: row.type,
|
||||||
|
number: row.number,
|
||||||
|
fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.svg";
|
import favicon from "$lib/assets/favicon.svg";
|
||||||
|
import GlobalPlayer from "$lib/components/GlobalPlayer.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -9,3 +10,4 @@
|
|||||||
><link rel="icon" href={favicon} /><title>AMQ Train</title></svelte:head
|
><link rel="icon" href={favicon} /><title>AMQ Train</title></svelte:head
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<GlobalPlayer />
|
||||||
|
|||||||
@@ -58,13 +58,13 @@
|
|||||||
{#each data.animeWithSongs.songs as s (s.annSongId)}
|
{#each data.animeWithSongs.songs as s (s.annSongId)}
|
||||||
<li>
|
<li>
|
||||||
<SongEntry
|
<SongEntry
|
||||||
|
annSongId={s.annSongId}
|
||||||
animeName={data.animeWithSongs.anime.mainName}
|
animeName={data.animeWithSongs.anime.mainName}
|
||||||
type={s.type}
|
type={s.type}
|
||||||
number={s.number}
|
number={s.number}
|
||||||
songName={s.songName}
|
songName={s.songName}
|
||||||
artistName={s.artistName}
|
artistName={s.artistName}
|
||||||
fileName={s.fileName}
|
fileName={s.fileName}
|
||||||
showPlayer={true}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import { invalidate } from "$app/navigation";
|
import { invalidate } from "$app/navigation";
|
||||||
import SongEntry from "$lib/components/SongEntry.svelte";
|
import SongEntry from "$lib/components/SongEntry.svelte";
|
||||||
import { db as clientDb } from "$lib/db/client-db";
|
import { db as clientDb } from "$lib/db/client-db";
|
||||||
|
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
|
||||||
|
import { trackFromSongRow } from "$lib/player/types";
|
||||||
import {
|
import {
|
||||||
MalAnimeListQuerySchema,
|
MalAnimeListQuerySchema,
|
||||||
MalAnimeListStatusEnum,
|
MalAnimeListStatusEnum,
|
||||||
@@ -47,6 +49,22 @@
|
|||||||
function makeMalHref(username: string) {
|
function makeMalHref(username: string) {
|
||||||
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
|
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tracksFromResults = $derived.by(() =>
|
||||||
|
data.songRows
|
||||||
|
.map((r) =>
|
||||||
|
trackFromSongRow({
|
||||||
|
annSongId: r.annSongId,
|
||||||
|
animeName: r.animeName,
|
||||||
|
type: r.type,
|
||||||
|
number: r.number,
|
||||||
|
songName: r.songName,
|
||||||
|
artistName: songArtistLabel(r),
|
||||||
|
fileName: r.fileName,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.filter((t) => t !== null),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1 class="text-2xl font-semibold">MAL List → Songs</h1>
|
<h1 class="text-2xl font-semibold">MAL List → Songs</h1>
|
||||||
@@ -115,6 +133,34 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.songRows.length > 0}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-3 py-2 text-sm"
|
||||||
|
onclick={() => addAllToQueue(tracksFromResults)}
|
||||||
|
disabled={tracksFromResults.length === 0}
|
||||||
|
>
|
||||||
|
Add all to queue
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-3 py-2 text-sm"
|
||||||
|
onclick={() => playAllNext(tracksFromResults)}
|
||||||
|
disabled={tracksFromResults.length === 0}
|
||||||
|
>
|
||||||
|
Play all next
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if tracksFromResults.length !== data.songRows.length}
|
||||||
|
<span class="self-center text-sm text-muted-foreground">
|
||||||
|
({tracksFromResults.length} playable)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.username}
|
{#if data.username}
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<a
|
<a
|
||||||
@@ -149,13 +195,13 @@
|
|||||||
{#each data.songRows as r (String(r.annId) + ":" + String(r.annSongId))}
|
{#each data.songRows as r (String(r.annId) + ":" + String(r.annSongId))}
|
||||||
<li>
|
<li>
|
||||||
<SongEntry
|
<SongEntry
|
||||||
|
annSongId={r.annSongId}
|
||||||
animeName={r.animeName}
|
animeName={r.animeName}
|
||||||
type={r.type}
|
type={r.type}
|
||||||
number={r.number}
|
number={r.number}
|
||||||
songName={r.songName}
|
songName={r.songName}
|
||||||
artistName={songArtistLabel(r)}
|
artistName={songArtistLabel(r)}
|
||||||
fileName={r.fileName}
|
fileName={r.fileName}
|
||||||
showPlayer={true}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user