WIP: global player refactor pt. 1
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,7 @@
|
|||||||
SkipForward,
|
SkipForward,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import {
|
import { player } from "$lib/player/store.svelte";
|
||||||
addToQueue,
|
|
||||||
hasTrack,
|
|
||||||
play,
|
|
||||||
playNext,
|
|
||||||
removeTrack,
|
|
||||||
} from "$lib/player/player.svelte";
|
|
||||||
import { type SongType, trackFromSongRow } from "$lib/player/types";
|
import { type SongType, trackFromSongRow } from "$lib/player/types";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
@@ -63,7 +57,7 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isQueued = $derived(hasTrack(annSongId));
|
const isQueued = $derived(player.hasTrack(annSongId));
|
||||||
|
|
||||||
function requestGlobalAutoplay() {
|
function requestGlobalAutoplay() {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -91,7 +85,8 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex flex-wrap w-fit items-baseline gap-x-2 gap-y-1">
|
<div class="flex flex-wrap w-fit items-baseline gap-x-2 gap-y-1">
|
||||||
{animeName}
|
{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
|
>{displayTypeNumber}</span
|
||||||
>
|
>
|
||||||
<span class=" text-muted-foreground">
|
<span class=" text-muted-foreground">
|
||||||
@@ -115,7 +110,7 @@
|
|||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
title="Remove from queue"
|
title="Remove from queue"
|
||||||
aria-label="Remove from queue"
|
aria-label="Remove from queue"
|
||||||
onclick={() => removeTrack(annSongId)}
|
onclick={() => player.remove(annSongId)}
|
||||||
>
|
>
|
||||||
<Trash2 class="icon-btn" />
|
<Trash2 class="icon-btn" />
|
||||||
</button>
|
</button>
|
||||||
@@ -128,7 +123,7 @@
|
|||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
play(track);
|
player.add(track, true);
|
||||||
requestGlobalAutoplay();
|
requestGlobalAutoplay();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -143,7 +138,7 @@
|
|||||||
aria-label="Play next"
|
aria-label="Play next"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
playNext(track);
|
player.playNext(track);
|
||||||
requestGlobalAutoplay();
|
requestGlobalAutoplay();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -158,7 +153,7 @@
|
|||||||
aria-label="Add to queue"
|
aria-label="Add to queue"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
addToQueue(track);
|
player.add(track);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListPlus class="icon-btn" />
|
<ListPlus class="icon-btn" />
|
||||||
|
|||||||
97
src/lib/components/player/Controls.svelte
Normal file
97
src/lib/components/player/Controls.svelte
Normal 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>
|
||||||
132
src/lib/components/player/PlayerDesktop.svelte
Normal file
132
src/lib/components/player/PlayerDesktop.svelte
Normal 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>
|
||||||
137
src/lib/components/player/PlayerMobile.svelte
Normal file
137
src/lib/components/player/PlayerMobile.svelte
Normal 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}
|
||||||
136
src/lib/components/player/PlayerRoot.svelte
Normal file
136
src/lib/components/player/PlayerRoot.svelte
Normal 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>
|
||||||
101
src/lib/components/player/Queue.svelte
Normal file
101
src/lib/components/player/Queue.svelte
Normal 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>
|
||||||
46
src/lib/components/player/ctx.svelte.ts
Normal file
46
src/lib/components/player/ctx.svelte.ts
Normal 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);
|
||||||
|
}
|
||||||
7
src/lib/components/player/utils.ts
Normal file
7
src/lib/components/player/utils.ts
Normal 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")}`;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts" generics="T">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,232 +1,33 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import type { Track } from "./types";
|
import type { Track } from "./types";
|
||||||
|
|
||||||
/**
|
const STORAGE_KEY = "amqtrain:player:v2";
|
||||||
* 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";
|
export type PersistedState = {
|
||||||
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[];
|
queue: Track[];
|
||||||
currentIndex: number | null;
|
currentId: number | null;
|
||||||
|
|
||||||
shuffleEnabled: boolean;
|
|
||||||
wrapEnabled: boolean;
|
|
||||||
|
|
||||||
order: number[];
|
|
||||||
history: number[];
|
|
||||||
cursor: number;
|
|
||||||
|
|
||||||
volume: number;
|
volume: number;
|
||||||
uiOpen: boolean;
|
isMuted: boolean;
|
||||||
|
minimized: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadPersistedPlayerState(): PersistablePlayerState | null {
|
export function loadState(): PersistedState | null {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
const parsed = PersistedSnapshotSchema.safeParse(JSON.parse(raw));
|
} catch (e) {
|
||||||
if (!parsed.success) return null;
|
console.error("Failed to load player state", e);
|
||||||
|
|
||||||
return sanitizePersistedState(parsed.data);
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function savePersistedPlayerState(state: PersistablePlayerState): void {
|
export function saveState(state: PersistedState) {
|
||||||
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;
|
if (!browser) return;
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ignore
|
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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -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) {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
326
src/lib/player/store.svelte.ts
Normal file
326
src/lib/player/store.svelte.ts
Normal 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();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
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";
|
import PlayerRoot from "$lib/components/player/PlayerRoot.svelte";
|
||||||
import ClientOnly from "$lib/components/util/ClientOnly.svelte";
|
import ClientOnly from "$lib/components/util/ClientOnly.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@@ -23,8 +23,12 @@
|
|||||||
<header
|
<header
|
||||||
class="sticky top-0 z-40 border-b bg-background/80 backdrop-blur lg:col-span-2"
|
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">
|
<div
|
||||||
<a href={resolve("/")} class="font-semibold tracking-tight">AMQ Train</a>
|
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">
|
<nav class="flex items-center gap-2 text-sm">
|
||||||
<a href={resolve("/")}>Anime</a>
|
<a href={resolve("/")}>Anime</a>
|
||||||
@@ -42,7 +46,7 @@
|
|||||||
<aside class="hidden lg:block">
|
<aside class="hidden lg:block">
|
||||||
<ClientOnly showFallback={false}>
|
<ClientOnly showFallback={false}>
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<GlobalPlayer />
|
<PlayerRoot />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
getClientDb,
|
getClientDb,
|
||||||
searchAnimeByName,
|
searchAnimeByName,
|
||||||
} from "$lib/db/client-db";
|
} 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 { trackFromSongRow } from "$lib/player/types";
|
||||||
import { AmqBrowseSearchSchema } from "$lib/types/search/amq-browse";
|
import { AmqBrowseSearchSchema } from "$lib/types/search/amq-browse";
|
||||||
import { seasonName } from "$lib/utils/amq";
|
import { seasonName } from "$lib/utils/amq";
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
)
|
)
|
||||||
.filter((t) => t !== null);
|
.filter((t) => t !== null);
|
||||||
|
|
||||||
addAllToQueue(tracks);
|
player.addAll(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playAllNextForAnime(a: AnimeItem) {
|
async function playAllNextForAnime(a: AnimeItem) {
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
)
|
)
|
||||||
.filter((t) => t !== null);
|
.filter((t) => t !== null);
|
||||||
|
|
||||||
playAllNext(tracks);
|
player.playAllNext(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -157,7 +157,8 @@
|
|||||||
class="rounded border px-3 py-2 text-sm"
|
class="rounded border px-3 py-2 text-sm"
|
||||||
placeholder="Type to search by name…"
|
placeholder="Type to search by name…"
|
||||||
value={params.q}
|
value={params.q}
|
||||||
oninput={(e) => (params.q = (e.currentTarget as HTMLInputElement).value)}
|
oninput={(e) =>
|
||||||
|
(params.q = (e.currentTarget as HTMLInputElement).value)}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
/>
|
/>
|
||||||
@@ -175,7 +176,10 @@
|
|||||||
<li class="rounded border px-3 py-2">
|
<li class="rounded border px-3 py-2">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<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.mainName}
|
||||||
</a>
|
</a>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
@@ -192,7 +196,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
onclick={() => void playAllNextForAnime(a)}
|
onclick={() => void playAllNextForAnime(a)}
|
||||||
disabled={a.opCount + a.edCount + a.insertCount === 0}
|
disabled={a.opCount + a.edCount + a.insertCount ===
|
||||||
|
0}
|
||||||
title="Play all next"
|
title="Play all next"
|
||||||
aria-label="Play all next"
|
aria-label="Play all next"
|
||||||
>
|
>
|
||||||
@@ -203,7 +208,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
onclick={() => void queueAllForAnime(a)}
|
onclick={() => void queueAllForAnime(a)}
|
||||||
disabled={a.opCount + a.edCount + a.insertCount === 0}
|
disabled={a.opCount + a.edCount + a.insertCount ===
|
||||||
|
0}
|
||||||
title="Queue all"
|
title="Queue all"
|
||||||
aria-label="Queue all"
|
aria-label="Queue all"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
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 { player } from "$lib/player/store.svelte";
|
||||||
import { trackFromSongRow } from "$lib/player/types";
|
import { trackFromSongRow } from "$lib/player/types";
|
||||||
import { seasonName } from "$lib/utils/amq";
|
import { seasonName } from "$lib/utils/amq";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function queueAll() {
|
function queueAll() {
|
||||||
addAllToQueue(playableTracks());
|
player.addAll(playableTracks());
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAllNextFromAnime() {
|
function playAllNextFromAnime() {
|
||||||
playAllNext(playableTracks());
|
player.playAllNext(playableTracks());
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,14 +57,16 @@
|
|||||||
{#if !data.annId}
|
{#if !data.annId}
|
||||||
<h1 class="text-2xl font-semibold">Anime not found</h1>
|
<h1 class="text-2xl font-semibold">Anime not found</h1>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
The requested anime entry doesn’t exist (or the route param wasn’t a valid
|
The requested anime entry doesn’t exist (or the route param wasn’t a
|
||||||
ANN id).
|
valid ANN id).
|
||||||
</p>
|
</p>
|
||||||
{:else if !data.animeWithSongs}
|
{:else if !data.animeWithSongs}
|
||||||
<p class="mt-3 text-sm text-muted-foreground">Loading anime…</p>
|
<p class="mt-3 text-sm text-muted-foreground">Loading anime…</p>
|
||||||
{:else}
|
{:else}
|
||||||
<header class="mt-2 space-y-2">
|
<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">
|
<p class="text-sm text-muted-foreground">
|
||||||
{data.animeWithSongs.anime.year}
|
{data.animeWithSongs.anime.year}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
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 { player } from "$lib/player/store.svelte";
|
||||||
import { trackFromSongRow } from "$lib/player/types";
|
import { trackFromSongRow } from "$lib/player/types";
|
||||||
import {
|
import {
|
||||||
MalAnimeListQuerySchema,
|
MalAnimeListQuerySchema,
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded border px-3 py-2 text-sm"
|
class="rounded border px-3 py-2 text-sm"
|
||||||
onclick={() => addAllToQueue(tracksFromResults)}
|
onclick={() => player.addAll(tracksFromResults)}
|
||||||
disabled={tracksFromResults.length === 0}
|
disabled={tracksFromResults.length === 0}
|
||||||
>
|
>
|
||||||
Add all to queue
|
Add all to queue
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded border px-3 py-2 text-sm"
|
class="rounded border px-3 py-2 text-sm"
|
||||||
onclick={() => playAllNext(tracksFromResults)}
|
onclick={() => player.playAllNext(tracksFromResults)}
|
||||||
disabled={tracksFromResults.length === 0}
|
disabled={tracksFromResults.length === 0}
|
||||||
>
|
>
|
||||||
Play all next
|
Play all next
|
||||||
@@ -184,8 +184,8 @@
|
|||||||
|
|
||||||
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.songRows.length === 0}
|
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.songRows.length === 0}
|
||||||
<p class="mt-4 text-sm text-muted-foreground">
|
<p class="mt-4 text-sm text-muted-foreground">
|
||||||
No songs matched in the local database. This likely means none of the MAL
|
No songs matched in the local database. This likely means none of the
|
||||||
anime IDs exist in the AMQ DB.
|
MAL anime IDs exist in the AMQ DB.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
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 { player } from "$lib/player/store.svelte";
|
||||||
import { trackFromSongRow } from "$lib/player/types";
|
import { trackFromSongRow } from "$lib/player/types";
|
||||||
import { AmqSongLinkTypeMap } from "$lib/types/amq";
|
import { AmqSongLinkTypeMap } from "$lib/types/amq";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
@@ -124,7 +124,9 @@
|
|||||||
label="Song Type"
|
label="Song Type"
|
||||||
items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
|
items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
|
||||||
label: type,
|
label: type,
|
||||||
value: AmqSongLinkTypeMap[type as keyof typeof AmqSongLinkTypeMap],
|
value: AmqSongLinkTypeMap[
|
||||||
|
type as keyof typeof AmqSongLinkTypeMap
|
||||||
|
],
|
||||||
}))}
|
}))}
|
||||||
bind:value={params.type}
|
bind:value={params.type}
|
||||||
/>
|
/>
|
||||||
@@ -149,7 +151,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
onclick={() => addAllToQueue(tracksFromResults)}
|
onclick={() => player.addAll(tracksFromResults)}
|
||||||
disabled={tracksFromResults.length === 0}
|
disabled={tracksFromResults.length === 0}
|
||||||
>
|
>
|
||||||
Add all to queue
|
Add all to queue
|
||||||
@@ -158,7 +160,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
onclick={() => playAllNext(tracksFromResults)}
|
onclick={() => player.playAllNext(tracksFromResults)}
|
||||||
disabled={tracksFromResults.length === 0}
|
disabled={tracksFromResults.length === 0}
|
||||||
>
|
>
|
||||||
Play all next
|
Play all next
|
||||||
|
|||||||
Reference in New Issue
Block a user