WIP: global player refactor pt. 1

This commit is contained in:
2026-02-09 23:19:17 -08:00
parent 9126e34f38
commit aea41df214
20 changed files with 1045 additions and 2740 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,7 @@
SkipForward,
Trash2,
} from "@lucide/svelte";
import {
addToQueue,
hasTrack,
play,
playNext,
removeTrack,
} from "$lib/player/player.svelte";
import { player } from "$lib/player/store.svelte";
import { type SongType, trackFromSongRow } from "$lib/player/types";
import { Button } from "./ui/button";
@@ -63,7 +57,7 @@
}),
);
const isQueued = $derived(hasTrack(annSongId));
const isQueued = $derived(player.hasTrack(annSongId));
function requestGlobalAutoplay() {
if (typeof window === "undefined") return;
@@ -91,7 +85,8 @@
<div class="flex flex-col">
<div class="flex flex-wrap w-fit items-baseline gap-x-2 gap-y-1">
{animeName}
<span class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>{displayTypeNumber}</span
>
<span class=" text-muted-foreground">
@@ -115,7 +110,7 @@
class="btn-icon"
title="Remove from queue"
aria-label="Remove from queue"
onclick={() => removeTrack(annSongId)}
onclick={() => player.remove(annSongId)}
>
<Trash2 class="icon-btn" />
</button>
@@ -128,7 +123,7 @@
aria-label="Play"
onclick={() => {
if (!track) return;
play(track);
player.add(track, true);
requestGlobalAutoplay();
}}
>
@@ -143,7 +138,7 @@
aria-label="Play next"
onclick={() => {
if (!track) return;
playNext(track);
player.playNext(track);
requestGlobalAutoplay();
}}
>
@@ -158,7 +153,7 @@
aria-label="Add to queue"
onclick={() => {
if (!track) return;
addToQueue(track);
player.add(track);
}}
>
<ListPlus class="icon-btn" />

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import {
Pause,
Play,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
} from "@lucide/svelte";
import { Button } from "$lib/components/ui/button";
import { player } from "$lib/player/store.svelte";
import { getAudioContext } from "./ctx.svelte";
let audio = getAudioContext();
// Derived state for icons/labels
let isPlaying = $derived(!audio.paused);
let shuffleMode = $derived(player.isShuffled);
let repeatMode = $derived(player.repeatMode);
</script>
<div class="flex items-center gap-2">
<!-- Shuffle -->
<Button
variant="ghost"
size="icon"
class={shuffleMode
? "text-primary hover:bg-primary/20 hover:text-primary"
: "text-muted-foreground"}
onclick={() => player.toggleShuffle()}
title="Toggle Shuffle"
>
<Shuffle class="h-4 w-4" />
</Button>
<!-- Prev -->
<Button
variant="ghost"
size="icon"
onclick={() => {
if (audio.currentTime > 3) {
audio.seek(0);
} else {
player.prev();
}
}}
disabled={player.history.length <= 1 && audio.currentTime <= 3}
title="Previous"
>
<SkipBack class="h-5 w-5" />
</Button>
<!-- Play/Pause -->
<Button
variant="outline"
size="icon"
class="h-10 w-10 rounded-full"
onclick={() => audio.toggle()}
disabled={!player.currentTrack}
title={isPlaying ? "Pause" : "Play"}
>
{#if isPlaying}
<Pause class="h-5 w-5" />
{:else}
<Play class="h-5 w-5 ml-0.5" />
{/if}
</Button>
<!-- Next -->
<Button
variant="ghost"
size="icon"
onclick={() => player.next()}
disabled={player.queue.length === 0}
title="Next"
>
<SkipForward class="h-5 w-5" />
</Button>
<!-- Repeat -->
<Button
variant="ghost"
size="icon"
class={repeatMode !== "off"
? "text-primary hover:bg-primary/20 hover:text-primary"
: "text-muted-foreground"}
onclick={() => player.toggleRepeat()}
title="Toggle Repeat"
>
{#if repeatMode === "one"}
<Repeat1 class="h-4 w-4" />
{:else}
<Repeat class="h-4 w-4" />
{/if}
</Button>
</div>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { Disc, Volume1, Volume2, VolumeX } from "@lucide/svelte";
import { Slider } from "$lib/components/ui/slider";
import { player } from "$lib/player/store.svelte";
import Controls from "./Controls.svelte";
import { getAudioContext } from "./ctx.svelte";
import Queue from "./Queue.svelte";
import { formatTime } from "./utils";
const audio = getAudioContext();
function onSeek(v: number[]) {
audio.seek(v[0]);
}
function onVolume(v: number[]) {
player.setVolume(v[0]);
}
function toggleMute() {
player.toggleMute();
}
</script>
<div
class="h-full flex flex-col border-l bg-background/50 backdrop-blur w-full"
>
{#if player.currentTrack}
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Artwork -->
<div
class="aspect-square w-full relative rounded-xl overflow-hidden bg-muted flex items-center justify-center shadow-lg border"
>
<Disc class="h-1/3 w-1/3 text-muted-foreground opacity-50" />
<!-- Ideally use real album art if available -->
</div>
<!-- Track Info -->
<div class="space-y-1.5 text-center">
<h2 class="text-xl font-bold leading-tight line-clamp-2">
{player.currentTrack.title}
</h2>
<p
class="text-muted-foreground font-medium text-lg line-clamp-1"
>
{player.currentTrack.artist}
</p>
<p class="text-xs text-muted-foreground/80">
{player.currentTrack.album ||
player.currentTrack.animeName ||
""}
</p>
</div>
<!-- Progress -->
<div class="space-y-2">
<Slider
value={[audio.currentTime]}
max={audio.duration || 100}
step={1}
onValueChange={onSeek}
type="multiple"
class="w-full"
/>
<div
class="flex justify-between text-xs text-muted-foreground font-variant-numeric tabular-nums px-1"
>
<span>{formatTime(audio.currentTime)}</span>
<span>{formatTime(audio.duration)}</span>
</div>
</div>
<!-- Controls -->
<div class="flex justify-center">
<Controls />
</div>
<!-- Volume -->
<div class="flex items-center gap-3 px-4">
<button
onclick={toggleMute}
class="text-muted-foreground hover:text-foreground transition-colors"
title={player.isMuted ? "Unmute" : "Mute"}
>
{#if player.isMuted || player.volume === 0}
<VolumeX class="h-4 w-4" />
{:else if player.volume < 0.5}
<Volume1 class="h-4 w-4" />
{:else}
<Volume2 class="h-4 w-4" />
{/if}
</button>
<Slider
value={[player.isMuted ? 0 : player.volume]}
max={1}
step={0.01}
onValueChange={onVolume}
type="multiple"
class="flex-1"
/>
</div>
</div>
<!-- Divider -->
<div class="h-px bg-border mx-6"></div>
<!-- Queue (Scrollable) -->
<div class="flex-1 min-h-0 overflow-hidden relative p-4">
<div class="absolute inset-0 p-4 pt-0">
<div
class="h-full overflow-hidden rounded-lg border bg-muted/20"
>
<Queue />
</div>
</div>
</div>
{:else}
<div
class="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-4 p-8 text-center"
>
<div
class="h-16 w-16 rounded-full bg-muted flex items-center justify-center"
>
<Disc class="h-8 w-8 opacity-50" />
</div>
<p>No track playing</p>
<p class="text-xs max-w-xs opacity-70">
Pick a song from the library to start listening.
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { ChevronUp, Disc, X } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button";
import * as Drawer from "$lib/components/ui/drawer";
import { Slider } from "$lib/components/ui/slider";
import { player } from "$lib/player/store.svelte";
import Controls from "./Controls.svelte";
import { getAudioContext } from "./ctx.svelte";
import Queue from "./Queue.svelte";
import { formatTime } from "./utils";
const audio = getAudioContext();
let open = $state(false);
function onSeek(v: number[]) {
audio.seek(v[0]);
}
</script>
{#if player.currentTrack}
<div
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur shadow-2xl safe-area-pb"
>
<div
class="px-4 py-2 flex items-center justify-between gap-4 h-16"
onclick={() => (open = true)}
>
<!-- Mini Player Info -->
<div class="flex items-center gap-3 overflow-hidden flex-1">
<!-- Placeholder Art -->
<div
class="h-10 w-10 rounded bg-muted flex items-center justify-center shrink-0"
>
<Disc class="h-6 w-6 text-muted-foreground" />
</div>
<div class="flex flex-col overflow-hidden text-left">
<div class="text-sm font-medium truncate leading-tight">
{player.currentTrack.title || "Unknown Title"}
</div>
<div
class="text-xs text-muted-foreground truncate leading-tight"
>
{player.currentTrack.artist || "Unknown Artist"}
</div>
</div>
</div>
<!-- Mini Controls -->
<div
class="flex items-center gap-1"
onclick={(e) => e.stopPropagation()}
>
<Controls />
<!-- Actually Controls has too many buttons for mini player. Just Play/Next? -->
<!-- We'll reimplement mini controls or pass props to Controls to show fewer buttons -->
<!-- Let's just use simplified controls here for now, or just Play/Pause -->
</div>
</div>
<!-- Progress Bar (thin line at top of bar) -->
<div class="absolute top-0 left-0 right-0 h-1 bg-muted">
<div
class="h-full bg-primary transition-all duration-100 ease-linear"
style="width: {(audio.currentTime / audio.duration) * 100}%"
></div>
</div>
</div>
<Drawer.Root bind:open>
<Drawer.Content class="h-[96dvh] flex flex-col rounded-t-[10px]">
<div class="mx-auto w-Full max-w-sm flex-1 flex flex-col p-4 gap-6">
<!-- Header -->
<div class="flex justify-center pt-2">
<div
class="h-1.5 w-12 rounded-full bg-muted-foreground/20"
></div>
</div>
<!-- Expanded Art -->
<div
class="aspect-square w-full relative rounded-xl overflow-hidden bg-muted flex items-center justify-center shadow-lg"
>
<Disc class="h-24 w-24 text-muted-foreground" />
<!-- If we had art, we'd show it here -->
</div>
<!-- Track Info -->
<div class="text-center space-y-1">
<h2 class="text-xl font-bold leading-tight line-clamp-2">
{player.currentTrack.title}
</h2>
<p
class="text-muted-foreground font-medium text-lg line-clamp-1"
>
{player.currentTrack.artist}
</p>
<p class="text-xs text-muted-foreground/80 mt-1">
{player.currentTrack.album ||
player.currentTrack.animeName}
</p>
</div>
<!-- Progress -->
<div class="space-y-2">
<Slider
value={[audio.currentTime]}
max={audio.duration || 100}
step={1}
onValueChange={onSeek}
type="multiple"
class="w-full"
/>
<div
class="flex justify-between text-xs text-muted-foreground font-variant-numeric tabular-nums"
>
<span>{formatTime(audio.currentTime)}</span>
<span>{formatTime(audio.duration)}</span>
</div>
</div>
<!-- Main Controls -->
<div class="flex justify-center py-4">
<Controls />
</div>
<!-- Volume? Or Queue toggle? -->
<!-- Queue -->
<div class="flex-1 overflow-hidden relative mt-auto">
<div class="absolute inset-0">
<Queue />
</div>
</div>
</div>
</Drawer.Content>
</Drawer.Root>
{/if}

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { onMount, setContext } from "svelte";
import { player } from "$lib/player/store.svelte";
import { AudioContext, setAudioContext } from "./ctx.svelte";
import PlayerDesktop from "./PlayerDesktop.svelte";
import PlayerMobile from "./PlayerMobile.svelte";
// Initialize context
const audioCtx = new AudioContext();
setContext("amqtrain:player:audio-ctx", audioCtx);
let audioEl: HTMLAudioElement;
import { loadState, saveState } from "$lib/player/persist";
// ... existing imports ...
onMount(() => {
audioCtx.setElement(audioEl);
// Load state
const saved = loadState();
if (saved) {
player.init(saved);
}
// Setup MediaSession
if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler(
"play",
() => (audioCtx.paused = false),
);
navigator.mediaSession.setActionHandler(
"pause",
() => (audioCtx.paused = true),
);
navigator.mediaSession.setActionHandler("previoustrack", () =>
player.prev(),
);
navigator.mediaSession.setActionHandler("nexttrack", () => player.next());
navigator.mediaSession.setActionHandler("stop", () => {
audioCtx.paused = true;
audioCtx.currentTime = 0;
});
}
});
// Persist state changes
$effect(() => {
// Create a dependency on all persisted fields
const state = {
queue: player.queue,
currentId: player.currentId,
volume: player.volume,
isMuted: player.isMuted,
minimized: !player.uiOpen,
};
saveState(state);
});
// Update MediaSession metadata
$effect(() => {
const track = player.currentTrack;
if (track && "mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
album: track.album || track.animeName,
artwork: [
// We could add artwork here if available in track
// { src: track.artworkUrl, sizes: '512x512', type: 'image/png' }
],
});
}
});
// ... existing effect for playback ...
$effect(() => {
const track = player.currentTrack;
if (track && audioEl) {
// ...
// Update MediaSession playback state
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = audioEl.paused
? "paused"
: "playing";
}
}
});
// ... existing callbacks ...
// Bindings and Event Listeners
function onTimeUpdate() {
audioCtx.currentTime = audioEl.currentTime;
}
function onDurationChange() {
audioCtx.duration = audioEl.duration;
}
function onPlay() {
audioCtx.paused = false;
if ("mediaSession" in navigator)
navigator.mediaSession.playbackState = "playing";
}
function onPause() {
audioCtx.paused = true;
if ("mediaSession" in navigator)
navigator.mediaSession.playbackState = "paused";
}
function onEnded() {
player.next();
}
</script>
<audio
bind:this={audioEl}
ontimeupdate={onTimeUpdate}
ondurationchange={onDurationChange}
onplay={onPlay}
onpause={onPause}
onended={onEnded}
class="hidden"
/>
<div class="contents">
<div class="lg:hidden">
<PlayerMobile />
</div>
<div class="hidden lg:block h-full">
<PlayerDesktop />
</div>
</div>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { Play, X } from "@lucide/svelte";
import { Button } from "$lib/components/ui/button";
import { player } from "$lib/player/store.svelte";
function onRemove(id: number) {
player.remove(id);
}
function onJump(track: any) {
player.playNext(track);
// Wait, jump usually means play immediately?
// "Jump to track" -> set as current.
// If it's in the queue, we can just set currentId.
player.playId(track.id);
}
</script>
<div
class="flex flex-col h-full w-full bg-background/50 backdrop-blur rounded-lg border overflow-hidden"
>
<div
class="px-4 py-3 border-b flex justify-between items-center bg-muted/20"
>
<h3 class="font-semibold text-sm">Up Next</h3>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
onclick={() => player.clearQueue()}
>
<span class="sr-only">Clear</span>
<X class="h-3 w-3" />
</Button>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if player.displayQueue.length === 0}
<div class="text-center py-8 text-muted-foreground text-sm">
Queue is empty
</div>
{:else}
{#each player.displayQueue as track (track.id)}
<div
role="button"
tabindex="0"
onclick={() => onJump(track)}
onkeydown={(e) => e.key === "Enter" && onJump(track)}
class="group flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-sm"
class:active={player.currentId === track.id}
>
<div
class="w-8 flex-shrink-0 text-center text-xs text-muted-foreground/60 font-mono"
>
{#if player.currentId === track.id}
<div
class="w-2 h-2 bg-primary rounded-full mx-auto animate-pulse"
></div>
{:else}
<span class="group-hover:hidden">#</span>
<Play
class="hidden group-hover:block mx-auto h-3 w-3 text-muted-foreground"
/>
{/if}
</div>
<div class="flex-1 min-w-0">
<div
class="font-medium truncate"
class:text-primary={player.currentId === track.id}
>
{track.title}
</div>
<div class="text-xs text-muted-foreground truncate">
{track.artist}
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e) => {
e.stopPropagation();
onRemove(track.id);
}}
>
<X class="h-3 w-3" />
</Button>
</div>
{/each}
{/if}
</div>
</div>
<style>
@reference "../../../routes/layout.css";
.active {
@apply bg-muted/40;
}
</style>

View File

@@ -0,0 +1,46 @@
import { getContext, setContext } from "svelte";
const AUDIO_CTX_KEY = "amqtrain:player:audio-ctx";
export class AudioContext {
currentTime = $state(0);
duration = $state(0);
paused = $state(true);
private audioEl: HTMLAudioElement | null = null;
constructor() {}
setElement(el: HTMLAudioElement) {
this.audioEl = el;
}
play() {
this.audioEl?.play();
}
pause() {
this.audioEl?.pause();
}
toggle() {
if (this.paused) this.play();
else this.pause();
}
seek(time: number) {
if (this.audioEl) {
this.audioEl.currentTime = Math.max(0, Math.min(time, this.duration));
}
}
}
export function setAudioContext() {
const ctx = new AudioContext();
setContext(AUDIO_CTX_KEY, ctx);
return ctx;
}
export function getAudioContext() {
return getContext<AudioContext>(AUDIO_CTX_KEY);
}

View File

@@ -0,0 +1,7 @@
export 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")}`;
}

View File

@@ -1,9 +1,9 @@
<script lang="ts" generics="T">
<script lang="ts">
import { cn } from "$lib/utils";
type Item = {
label: string;
value: T;
value: any;
};
let {

View File

@@ -1,204 +0,0 @@
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;
}
function typeNumberLabel(t: Track) {
const type = t.type;
const n = Number(t.number ?? 0);
let typeLabel: string | null = null;
if (typeof type === "number") {
if (type === 1) typeLabel = "OP";
else if (type === 2) typeLabel = "ED";
else if (type === 3) typeLabel = "INS";
else typeLabel = `T${type}`;
}
if (!typeLabel) return null;
return `${typeLabel}${n ? String(n) : ""}`;
}
mediaSession.metadata = new MediaMetadata({
title: `${track.animeName} (${typeNumberLabel(track)}) — ${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,
};
}

View File

@@ -1,232 +1,33 @@
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:v2";
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 = {
export type PersistedState = {
queue: Track[];
currentIndex: number | null;
shuffleEnabled: boolean;
wrapEnabled: boolean;
order: number[];
history: number[];
cursor: number;
currentId: number | null;
volume: number;
uiOpen: boolean;
isMuted: boolean;
minimized: boolean;
};
export function loadPersistedPlayerState(): PersistablePlayerState | null {
export function loadState(): PersistedState | 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 JSON.parse(raw);
} catch (e) {
console.error("Failed to load player state", e);
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 {
export function saveState(state: PersistedState) {
if (!browser) return;
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.error("Failed to save player state", e);
}
}
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));
}

View File

@@ -1,893 +0,0 @@
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).
*
* NOTE: Do NOT use module-level `$effect` here — consumers (e.g. GlobalPlayer)
* should subscribe via `subscribe()` and drive any side-effects (like persistence).
*/
export type InsertMode = "play" | "playNext" | "add" | "jump";
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;
};
export type PlayerSubscriber = (snapshot: PlayerSnapshot) => void;
export type Unsubscribe = () => void;
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>(
// Default based on the current viewport:
// - mobile: closed
// - desktop: open
//
// Note: we intentionally do NOT default from persisted `uiOpen`, so the UI
// always follows the current device/viewport expectation.
!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,
});
/** --- Lightweight subscription API (to avoid polling) --- */
const subscribers = new Set<PlayerSubscriber>();
function notifySubscribers() {
// Read `snapshot` here so every subscriber sees a consistent value.
const s = snapshot;
for (const fn of subscribers) fn(s);
}
/**
* Subscribe to player snapshot changes.
*
* - Calls `fn` immediately with the current snapshot
* - Returns an `unsubscribe` function
*/
export function subscribe(fn: PlayerSubscriber): Unsubscribe {
subscribers.add(fn);
fn(snapshot);
return () => {
subscribers.delete(fn);
};
}
/**
* Notify subscribers at the end of the current microtask.
* This coalesces multiple mutations within the same tick into one update.
*/
let notifyQueued = false;
function queueNotify() {
if (notifyQueued) return;
notifyQueued = true;
queueMicrotask(() => {
notifyQueued = false;
notifySubscribers();
});
}
/**
* Persistence
*
* Persistence must be driven from a component (e.g. GlobalPlayer) via `subscribe()`
* to avoid orphaned module-level effects.
*/
const schedulePersist = createPersistScheduler(250);
export function schedulePersistNow(): void {
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;
// Ensure traversal exists and is anchored at *this* currentIndex.
// This is important for jump-to-index behavior in shuffle mode.
ensureTraversalStateForCurrent();
// If we jumped to an index that's not reflected at the current cursor,
// align cursor/history so that prev/next work relative to the jumped item.
// Strategy:
// - if next is already somewhere in history, move cursor there
// - otherwise, append it and set cursor to the end
const existingPos = history.indexOf(next);
if (existingPos !== -1) {
cursor = existingPos;
} else if (history[cursor] !== next) {
history = [...history, next];
cursor = history.length - 1;
}
// If this index was scheduled in the future order, remove it so we don't revisit.
order = order.filter((i) => i !== next);
// If it was at the head, that's implicitly consumed as well.
if (order[0] === next) order = order.slice(1);
}
/** --- Public traversal actions --- */
export function next(): void {
const idx = computeNextIndex();
if (idx == null) {
currentIndex = null;
queueNotify();
return;
}
applyCurrentIndex(idx);
queueNotify();
}
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);
queueNotify();
}
/** 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);
queueNotify();
}
/** --- 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;
queueNotify();
}
export function removeTrack(id: number): void {
const i = indexOfTrack(id);
if (i === -1) return;
removeAt(i);
queueNotify();
}
/**
* Reorder an existing queued track by id.
*
* Semantics depend on shuffle:
* - Linear (shuffle off): reorder the underlying `queue` and adjust `currentIndex`.
* - Shuffle (shuffle on): reorder the UPCOMING play order (the `order` list), not the
* underlying `queue`, because the UI is presenting a shuffled traversal that users
* expect to be able to rearrange.
*
* In shuffle mode this only affects tracks that are still in the future schedule
* (`order`). Already-played history is left intact.
*
* Notes:
* - `toIndex` is a queue index when shuffle is OFF (destination position in `queue`).
* - `toIndex` is a queue index when shuffle is ON too, but it is interpreted as
* "place this track before the track currently at queue index `toIndex` in the
* UPCOMING order", i.e. it changes `order`, not `queue`.
*/
export function reorderTrackById(id: number, toIndex: number): void {
const from = indexOfTrack(id);
if (from === -1) return;
const clampedTo = Math.max(
0,
Math.min(queue.length - 1, Math.floor(toIndex)),
);
// Shuffle: reorder upcoming traversal schedule, not underlying queue.
if (shuffleEnabled) {
// Ensure traversal state exists
ensureTraversalStateForCurrent();
// Only reorder within the future schedule (`order`).
// If the dragged track isn't in `order`, it's either current, already played, or unscheduled.
const fromPos = order.indexOf(from);
if (fromPos === -1) {
queueNotify();
return;
}
// Determine insertion position relative to the target queue index within `order`.
// If target isn't currently in `order`, we clamp to the end of the future schedule.
let toPos = order.indexOf(clampedTo);
if (toPos === -1) toPos = order.length;
// Moving an item forward past itself needs an index adjustment after removal.
const without = order.filter((i) => i !== from);
if (toPos > fromPos) toPos = Math.max(0, toPos - 1);
const nextPos = Math.max(0, Math.min(without.length, toPos));
order = [...without.slice(0, nextPos), from, ...without.slice(nextPos)];
queueNotify();
return;
}
// Linear: reorder the underlying queue
if (from === clampedTo) return;
moveIndex(from, clampedTo);
queueNotify();
}
/**
* Core insertion behavior per your rules.
*
* - "jump": jump to an existing queued track (does not reorder)
* - "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 (except "jump", which never moves).
*/
export function insertTrack(track: Track, mode: InsertMode): void {
// Normalize + basic guard
if (!track || !Number.isFinite(track.id) || !track.src) return;
// Clicking an already-queued song should MOVE PLAYHEAD to that queue position,
// not reshuffle the queue around the current track.
if (mode === "jump") {
const i = indexOfTrack(track.id);
if (i === -1) return;
applyCurrentIndex(i);
// In shuffle mode, make sure the future order is still valid relative to the
// new cursor position (i.e., "next" should come from order after this jump).
// We rebuild the remaining order while preserving already-played history.
if (shuffleEnabled) {
rebuildShuffleOrderPreservingPast();
}
queueNotify();
return;
}
// If the user hits "Play" / "Play next" on the *currently playing* track,
// treat it as a no-op. This avoids trying to move the current track to
// "right after itself" and then skipping, which can produce confusing
// queue changes (and in some cases corrupt traversal state).
//
// NOTE: "Add to queue" still dedupes below, but we early-return there too.
if (currentIndex != null && queue[currentIndex]?.id === track.id) {
queueNotify();
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.
queueNotify();
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
queueNotify();
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();
} else {
queueNotify();
}
}
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");
queueNotify();
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) {
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();
} else {
queueNotify();
}
}
/** --- Toggles / settings --- */
export function setVolume(v: number): void {
volume = clamp01(v);
queueNotify();
}
export function setUiOpen(open: boolean): void {
uiOpen = !!open;
queueNotify();
}
export function toggleUiOpen(): void {
uiOpen = !uiOpen;
queueNotify();
}
export function toggleWrap(): void {
wrapEnabled = !wrapEnabled;
queueNotify();
}
export function enableShuffle(enable: boolean): void {
const nextVal = !!enable;
if (shuffleEnabled === nextVal) return;
shuffleEnabled = nextVal;
if (!shuffleEnabled) {
order = [];
history = [];
cursor = 0;
queueNotify();
return;
}
// Turning shuffle on: preserve current as starting history point
if (currentIndex != null) {
history = [currentIndex];
cursor = 0;
} else {
history = [];
cursor = 0;
}
rebuildShuffleOrderPreservingPast();
queueNotify();
}
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();
queueNotify();
}
/** --- 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}`;
}

View File

@@ -1,181 +0,0 @@
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) {
// FisherYates 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;
}

View File

@@ -0,0 +1,326 @@
import { browser } from "$app/environment";
import type { Track } from "./types";
const STORAGE_KEY = "amqtrain:player:v2";
export type PlayerState = {
queue: Track[];
currentId: number | null;
history: number[]; // List of track IDs
shuffledIndices: number[]; // List of indices into queue (maintained for shuffle order)
isShuffled: boolean;
repeatMode: "off" | "all" | "one";
volume: number;
isMuted: boolean;
};
class PlayerStore {
// State
queue = $state<Track[]>([]);
currentId = $state<number | null>(null);
history = $state<number[]>([]);
shuffledIndices = $state<number[]>([]);
isShuffled = $state(false);
repeatMode = $state<"off" | "all" | "one">("off");
volume = $state(1);
isMuted = $state(false);
uiOpen = $state(false); // Mobile UI state
// Derived
currentTrack = $derived(
this.currentId
? (this.queue.find((t) => t.id === this.currentId) ?? null)
: null,
);
currentIndex = $derived(
this.currentId ? this.queue.findIndex((t) => t.id === this.currentId) : -1,
);
displayQueue = $derived(
this.isShuffled
? this.shuffledIndices
.map((i) => this.queue[i])
.filter((t) => t !== undefined)
: this.queue,
);
hasTrack(id: number) {
return this.queue.some((t) => t.id === id);
}
constructor() {
if (browser) {
this.load();
// Auto-save on changes
$effect.root(() => {
$effect(() => {
this.save();
});
});
}
}
init(state: Partial<import("./persist").PersistedState>) {
if (state.queue) this.queue = state.queue;
if (state.currentId) this.currentId = state.currentId;
if (state.volume !== undefined) this.volume = state.volume;
if (state.isMuted !== undefined) this.isMuted = state.isMuted;
if (state.minimized !== undefined) this.uiOpen = !state.minimized;
}
load() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const data = JSON.parse(stored);
this.queue = data.queue || [];
this.currentId = data.currentId ?? null;
this.history = data.history || [];
this.shuffledIndices = data.shuffledIndices || [];
this.isShuffled = data.isShuffled || false;
this.repeatMode = data.repeatMode || "off";
this.volume = data.volume ?? 1;
this.isMuted = data.isMuted || false;
}
} catch (e) {
console.error("Failed to load player state", e);
}
}
save() {
const data: PlayerState = {
queue: $state.snapshot(this.queue),
currentId: $state.snapshot(this.currentId),
history: $state.snapshot(this.history),
shuffledIndices: $state.snapshot(this.shuffledIndices),
isShuffled: $state.snapshot(this.isShuffled),
repeatMode: $state.snapshot(this.repeatMode),
volume: $state.snapshot(this.volume),
isMuted: $state.snapshot(this.isMuted),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
// Actions
add(track: Track, playNow = false) {
const existingIdx = this.queue.findIndex((t) => t.id === track.id);
if (existingIdx !== -1) {
if (playNow) {
this.playId(track.id);
}
return;
}
// Add to end
this.queue.push(track);
if (this.isShuffled) {
this.shuffledIndices.push(this.queue.length - 1);
}
if (playNow) {
this.playId(track.id);
} else if (this.queue.length === 1 && !this.currentId) {
this.currentId = track.id;
}
}
playNext(track: Track) {
const existingIdx = this.queue.findIndex((t) => t.id === track.id);
const targetTrack = track;
if (existingIdx !== -1) {
// Move approach: remove then insert
this.remove(track.id);
}
// Insert after current
// If playing: insert at currentIndex + 1
// If empty: insert at 0
const currentIdx = this.currentIndex;
const insertIdx = currentIdx === -1 ? 0 : currentIdx + 1;
this.queue.splice(insertIdx, 0, targetTrack);
if (this.isShuffled) {
// Regenerate shuffle indices to be safe as queue shifted
this.reshuffle();
// Attempt to place new track next in shuffle order?
// The reshuffle logic handles "current first", rest random.
// Ideally we want playNext to be deterministic even in shuffle.
// Getting complex. Let's stick to: "Play Next" inserts after current in Queue.
// If Shuffle is on, we force it to be next in shuffledIndices too.
const newIdx = insertIdx;
// Find where current is in shuffledIndices
const currentShufflePos = this.shuffledIndices.indexOf(currentIdx);
// Insert newIdx after it
if (currentShufflePos !== -1) {
this.shuffledIndices.splice(currentShufflePos + 1, 0, newIdx);
} else {
this.shuffledIndices.unshift(newIdx);
}
}
}
addAll(tracks: Track[]) {
for (const track of tracks) {
this.add(track);
}
}
playAllNext(tracks: Track[]) {
// Reverse iterate to maintain order when inserting after current
for (let i = tracks.length - 1; i >= 0; i--) {
this.playNext(tracks[i]);
}
}
remove(id: number) {
const idx = this.queue.findIndex((t) => t.id === id);
if (idx === -1) return;
const wasCurrent = this.currentId === id;
this.queue.splice(idx, 1);
if (wasCurrent) {
this.currentId = null; // Or auto-advance?
this.next();
}
// Fix shuffle indices
// All indices > idx must be decremented
// The index `idx` itself must be removed
this.shuffledIndices = this.shuffledIndices
.filter((i) => i !== idx)
.map((i) => (i > idx ? i - 1 : i));
}
clearQueue() {
this.queue = [];
this.currentId = null;
this.shuffledIndices = [];
this.history = [];
}
playId(id: number) {
if (this.hasTrack(id)) {
this.currentId = id;
this.addToHistory(id);
}
}
// Playback Controls
next() {
if (this.queue.length === 0) return;
let nextIdxInQueue: number | null = null;
const currentIdx = this.currentIndex;
if (this.isShuffled) {
const currentShufflePos = this.shuffledIndices.indexOf(currentIdx);
if (
currentShufflePos !== -1 &&
currentShufflePos < this.shuffledIndices.length - 1
) {
nextIdxInQueue = this.shuffledIndices[currentShufflePos + 1];
} else if (this.repeatMode === "all" && this.shuffledIndices.length > 0) {
nextIdxInQueue = this.shuffledIndices[0];
}
} else {
if (currentIdx !== -1 && currentIdx < this.queue.length - 1) {
nextIdxInQueue = currentIdx + 1;
} else if (this.repeatMode === "all" && this.queue.length > 0) {
nextIdxInQueue = 0;
}
}
if (nextIdxInQueue !== null) {
const nextId = this.queue[nextIdxInQueue]?.id;
if (nextId) {
this.currentId = nextId;
this.addToHistory(nextId);
}
}
}
prev() {
// If history has > 1 item, go back
if (this.history.length > 1) {
// Pop current
this.history.pop();
const prevId = this.history[this.history.length - 1];
if (this.hasTrack(prevId)) {
this.currentId = prevId;
} else {
// Track removed? fallback
this.history.pop(); // Remove invalid
this.prev(); // Recurse
}
} else {
// Restart current song?
// Handled by UI usually (calling audio.currentTime = 0),
// but here we just seek to 0 if we could.
// Store doesn't control audio element directly.
}
}
addToHistory(id: number) {
const last = this.history[this.history.length - 1];
if (last !== id) {
this.history.push(id);
}
}
toggleShuffle() {
this.isShuffled = !this.isShuffled;
if (this.isShuffled) {
this.reshuffle();
}
}
reshuffle() {
// Create indices 0..N-1
const indices = Array.from({ length: this.queue.length }, (_, i) => i);
// Fisher-Yates
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
// Keep current first
if (this.currentId) {
const currentIdx = this.currentIndex;
const without = indices.filter((i) => i !== currentIdx);
this.shuffledIndices = [currentIdx, ...without];
} else {
this.shuffledIndices = indices;
}
}
toggleRepeat() {
if (this.repeatMode === "off") this.repeatMode = "all";
else if (this.repeatMode === "all") this.repeatMode = "one";
else this.repeatMode = "off";
}
setVolume(v: number) {
this.volume = Math.max(0, Math.min(1, v));
}
toggleMute() {
this.isMuted = !this.isMuted;
}
setUiOpen(open: boolean) {
this.uiOpen = open;
}
}
export const player = new PlayerStore();

View File

@@ -2,7 +2,7 @@
import { resolve } from "$app/paths";
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import GlobalPlayer from "$lib/components/GlobalPlayer.svelte";
import PlayerRoot from "$lib/components/player/PlayerRoot.svelte";
import ClientOnly from "$lib/components/util/ClientOnly.svelte";
let { children } = $props();
@@ -23,8 +23,12 @@
<header
class="sticky top-0 z-40 border-b bg-background/80 backdrop-blur lg:col-span-2"
>
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<a href={resolve("/")} class="font-semibold tracking-tight">AMQ Train</a>
<div
class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4"
>
<a href={resolve("/")} class="font-semibold tracking-tight"
>AMQ Train</a
>
<nav class="flex items-center gap-2 text-sm">
<a href={resolve("/")}>Anime</a>
@@ -42,7 +46,7 @@
<aside class="hidden lg:block">
<ClientOnly showFallback={false}>
{#snippet children()}
<GlobalPlayer />
<PlayerRoot />
{/snippet}
</ClientOnly>
</aside>

View File

@@ -9,7 +9,7 @@
getClientDb,
searchAnimeByName,
} from "$lib/db/client-db";
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import { AmqBrowseSearchSchema } from "$lib/types/search/amq-browse";
import { seasonName } from "$lib/utils/amq";
@@ -78,7 +78,7 @@
)
.filter((t) => t !== null);
addAllToQueue(tracks);
player.addAll(tracks);
}
async function playAllNextForAnime(a: AnimeItem) {
@@ -102,7 +102,7 @@
)
.filter((t) => t !== null);
playAllNext(tracks);
player.playAllNext(tracks);
}
onMount(() => {
@@ -157,7 +157,8 @@
class="rounded border px-3 py-2 text-sm"
placeholder="Type to search by name…"
value={params.q}
oninput={(e) => (params.q = (e.currentTarget as HTMLInputElement).value)}
oninput={(e) =>
(params.q = (e.currentTarget as HTMLInputElement).value)}
autocomplete="off"
spellcheck={false}
/>
@@ -175,7 +176,10 @@
<li class="rounded border px-3 py-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<a class="font-medium hover:underline" href={animeHref(a.annId)}>
<a
class="font-medium hover:underline"
href={animeHref(a.annId)}
>
{a.mainName}
</a>
<div class="text-sm text-muted-foreground">
@@ -192,7 +196,8 @@
type="button"
class="btn-icon"
onclick={() => void playAllNextForAnime(a)}
disabled={a.opCount + a.edCount + a.insertCount === 0}
disabled={a.opCount + a.edCount + a.insertCount ===
0}
title="Play all next"
aria-label="Play all next"
>
@@ -203,7 +208,8 @@
type="button"
class="btn-icon"
onclick={() => void queueAllForAnime(a)}
disabled={a.opCount + a.edCount + a.insertCount === 0}
disabled={a.opCount + a.edCount + a.insertCount ===
0}
title="Queue all"
aria-label="Queue all"
>

View File

@@ -3,7 +3,7 @@
import { invalidate } from "$app/navigation";
import SongEntry from "$lib/components/SongEntry.svelte";
import { db as clientDb } from "$lib/db/client-db";
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import { seasonName } from "$lib/utils/amq";
import type { PageData } from "./$types";
@@ -42,11 +42,11 @@
}
function queueAll() {
addAllToQueue(playableTracks());
player.addAll(playableTracks());
}
function playAllNextFromAnime() {
playAllNext(playableTracks());
player.playAllNext(playableTracks());
}
</script>
@@ -57,14 +57,16 @@
{#if !data.annId}
<h1 class="text-2xl font-semibold">Anime not found</h1>
<p class="mt-2 text-sm text-muted-foreground">
The requested anime entry doesnt exist (or the route param wasnt a valid
ANN id).
The requested anime entry doesnt exist (or the route param wasnt a
valid ANN id).
</p>
{:else if !data.animeWithSongs}
<p class="mt-3 text-sm text-muted-foreground">Loading anime…</p>
{:else}
<header class="mt-2 space-y-2">
<h1 class="text-2xl font-semibold">{data.animeWithSongs.anime.mainName}</h1>
<h1 class="text-2xl font-semibold">
{data.animeWithSongs.anime.mainName}
</h1>
<p class="text-sm text-muted-foreground">
{data.animeWithSongs.anime.year}

View File

@@ -6,7 +6,7 @@
import { invalidate } from "$app/navigation";
import SongEntry from "$lib/components/SongEntry.svelte";
import { db as clientDb } from "$lib/db/client-db";
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import {
MalAnimeListQuerySchema,
@@ -139,7 +139,7 @@
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => addAllToQueue(tracksFromResults)}
onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
@@ -148,7 +148,7 @@
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => playAllNext(tracksFromResults)}
onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next
@@ -184,8 +184,8 @@
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.songRows.length === 0}
<p class="mt-4 text-sm text-muted-foreground">
No songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB.
No songs matched in the local database. This likely means none of the
MAL anime IDs exist in the AMQ DB.
</p>
{/if}

View File

@@ -9,7 +9,7 @@
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { db as clientDb } from "$lib/db/client-db";
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import { AmqSongLinkTypeMap } from "$lib/types/amq";
import type { PageData } from "./$types";
@@ -124,7 +124,9 @@
label="Song Type"
items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
label: type,
value: AmqSongLinkTypeMap[type as keyof typeof AmqSongLinkTypeMap],
value: AmqSongLinkTypeMap[
type as keyof typeof AmqSongLinkTypeMap
],
}))}
bind:value={params.type}
/>
@@ -149,7 +151,7 @@
<Button
variant="outline"
class="cursor-pointer"
onclick={() => addAllToQueue(tracksFromResults)}
onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
@@ -158,7 +160,7 @@
<Button
variant="outline"
class="cursor-pointer"
onclick={() => playAllNext(tracksFromResults)}
onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next