diff --git a/src/lib/components/GlobalPlayer.svelte b/src/lib/components/GlobalPlayer.svelte
deleted file mode 100644
index 51c4572..0000000
--- a/src/lib/components/GlobalPlayer.svelte
+++ /dev/null
@@ -1,1209 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
toggleUiOpen()}
- aria-label={snap.uiOpen ? "Close player" : "Open player"}
- title={snap.uiOpen ? "Close player" : "Open player"}
- >
- {#if snap.uiOpen}
-
- {:else}
-
- {/if}
-
-
-
{
- const t = e.target as HTMLElement | null;
- // Only toggle when tapping the track info area. Avoid toggling from
- // the button zones (above/below/side) by not attaching handlers to the
- // whole bar.
- if (t?.closest("button,input,label,a")) return;
- toggleUiOpen();
- }}
- onkeydown={(e) => {
- if (e.key !== "Enter" && e.key !== " ") return;
- const t = e.target as HTMLElement | null;
- if (t?.closest("button,input,label,a")) return;
- e.preventDefault();
- toggleUiOpen();
- }}
- >
- {#if snap.currentTrack}
-
- {#if typeNumberLabel(snap.currentTrack)}
-
- {typeNumberLabel(snap.currentTrack)}
-
- {/if}
-
- {(
- snap.currentTrack.animeName ??
- snap.currentTrack.album ??
- ""
- ).trim()}
-
-
-
-
- {(snap.currentTrack.title ?? "").trim() || "Unknown title"}
-
- — {(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
-
-
- {:else}
-
{nowPlayingLabel()}
- {/if}
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
-
-
- Seek
-
-
-
-
-
{
- prev(currentTime);
- void syncAndAutoplay();
- }}
- >
-
-
-
-
{
- if (!audioEl || !snap.currentTrack) return;
-
- // If src is not set, it's an initial play action.
- // Delegate to syncAndAutoplay to ensure src is set and loaded.
- if (!audioEl.src) {
- void syncAndAutoplay();
- } else {
- if (audioEl.paused) void audioEl.play();
- else audioEl.pause();
- }
- }}
- disabled={!snap.currentTrack}
- >
- {#if isPlaying}
-
- {:else}
-
- {/if}
-
-
-
{
- next();
- void syncAndAutoplay();
- }}
- >
-
-
-
-
- {#if snap.uiOpen}
-
-
-
-
-
toggleShuffle()}
- aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
- title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
- >
-
-
-
toggleWrap()}
- aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
- title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
- >
-
-
-
-
-
-
-
-
-
-
- Clear queue?
-
- This will remove all queued tracks.
-
-
-
-
- Cancel
- {
- clearQueue();
- clearQueueDialogOpen = false;
- }}
- >
- Clear
-
-
-
-
-
-
-
-
-
- Volume
-
- setVolume(
- Number((e.currentTarget as HTMLInputElement).value),
- )}
- />
-
-
-
-
-
-
-
- {/if}
-
-
-
-
-
-
-
- {#if snap.currentTrack}
-
- {#if typeNumberLabel(snap.currentTrack)}
-
- {typeNumberLabel(snap.currentTrack)}
-
- {/if}
-
- {(
- snap.currentTrack.animeName ??
- snap.currentTrack.album ??
- ""
- ).trim()}
-
-
-
-
- {(snap.currentTrack.title ?? "").trim() || "Unknown title"}
-
- — {(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
-
-
- {:else}
-
- {nowPlayingLabel()}
-
- {/if}
-
-
-
toggleUiOpen()}
- aria-label={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
- title={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
- >
- {#if snap.uiOpen}
-
- {:else}
-
- {/if}
-
-
-
- {#if snap.uiOpen}
-
-
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
-
-
-
-
-
- Volume
-
- setVolume(Number((e.currentTarget as HTMLInputElement).value))}
- />
-
-
-
-
- Seek
-
-
-
-
-
{
- prev(currentTime);
- void syncAndAutoplay();
- }}
- >
-
-
-
-
{
- if (!audioEl || !snap.currentTrack) return;
-
- // If src is not set, it's an initial play action.
- // Delegate to syncAndAutoplay to ensure src is set and loaded.
- if (!audioEl.src) {
- void syncAndAutoplay();
- } else {
- if (audioEl.paused) void audioEl.play();
- else audioEl.pause();
- }
- }}
- disabled={!snap.currentTrack}
- >
- {#if isPlaying}
-
- {:else}
-
- {/if}
-
-
-
{
- next();
- void syncAndAutoplay();
- }}
- >
-
-
-
-
-
toggleShuffle()}
- aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
- title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
- >
-
-
-
toggleWrap()}
- aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
- title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
- >
-
-
-
-
-
-
-
-
-
-
- Clear queue?
-
- This will remove all queued tracks.
-
-
-
-
- Cancel
- {
- clearQueue();
- clearQueueDialogOpen = false;
- }}
- >
- Clear
-
-
-
-
-
-
-
-
-
Queue ({snap.queue.length})
- {#if snap.currentIndex !== null}
-
{
- const currentEl = document.querySelector(
- `[data-queue-id='${snap.currentTrack?.id}']`,
- );
- currentEl?.scrollIntoView({
- behavior: "smooth",
- block: "center",
- });
- }}
- title="Scroll to current track"
- >
- Scroll to current
-
- {/if}
-
-
-
-
- {#if snap.queue.length === 0}
-
Queue is empty.
- {:else}
-
- {#each queueDisplay as item (item.track.id)}
- onDragOver(item.track.id, e)}
- ondrop={(e) => onDrop(item.track.id, e)}
- >
- onDragStart(item.track.id, e)}
- ondragend={onDragEnd}
- >
-
-
-
- {
- jumpToTrack(item.track.id);
- void syncAndAutoplay();
- }}
- >
- {#if item.isCurrent}
- ▶
- {/if}
-
-
- {#if typeNumberLabel(item.track)}
-
- {typeNumberLabel(item.track)}
-
- {/if}
- {animeLabel(item.track)}
-
-
-
- {(item.track.title ?? "").trim() || "Unknown title"}
-
- — {(item.track.artist ?? "").trim() || "Unknown Artist"}
-
-
-
-
-
- removeTrack(item.track.id)}
- aria-label="Remove from queue"
- title="Remove from queue"
- >
-
-
-
- {/each}
-
- {/if}
-
-
- {/if}
-
-
-
diff --git a/src/lib/components/SongEntry.svelte b/src/lib/components/SongEntry.svelte
index 2d22a12..98fd5f6 100644
--- a/src/lib/components/SongEntry.svelte
+++ b/src/lib/components/SongEntry.svelte
@@ -6,13 +6,7 @@
SkipForward,
Trash2,
} from "@lucide/svelte";
- import {
- addToQueue,
- hasTrack,
- play,
- playNext,
- removeTrack,
- } from "$lib/player/player.svelte";
+ import { player } from "$lib/player/store.svelte";
import { type SongType, trackFromSongRow } from "$lib/player/types";
import { Button } from "./ui/button";
@@ -63,7 +57,7 @@
}),
);
- const isQueued = $derived(hasTrack(annSongId));
+ const isQueued = $derived(player.hasTrack(annSongId));
function requestGlobalAutoplay() {
if (typeof window === "undefined") return;
@@ -91,7 +85,8 @@
{animeName}
-
{displayTypeNumber}
@@ -115,7 +110,7 @@
class="btn-icon"
title="Remove from queue"
aria-label="Remove from queue"
- onclick={() => removeTrack(annSongId)}
+ onclick={() => player.remove(annSongId)}
>
@@ -128,7 +123,7 @@
aria-label="Play"
onclick={() => {
if (!track) return;
- play(track);
+ player.add(track, true);
requestGlobalAutoplay();
}}
>
@@ -143,7 +138,7 @@
aria-label="Play next"
onclick={() => {
if (!track) return;
- playNext(track);
+ player.playNext(track);
requestGlobalAutoplay();
}}
>
@@ -158,7 +153,7 @@
aria-label="Add to queue"
onclick={() => {
if (!track) return;
- addToQueue(track);
+ player.add(track);
}}
>
diff --git a/src/lib/components/player/Controls.svelte b/src/lib/components/player/Controls.svelte
new file mode 100644
index 0000000..4493742
--- /dev/null
+++ b/src/lib/components/player/Controls.svelte
@@ -0,0 +1,97 @@
+
+
+
+
+
player.toggleShuffle()}
+ title="Toggle Shuffle"
+ >
+
+
+
+
+
{
+ if (audio.currentTime > 3) {
+ audio.seek(0);
+ } else {
+ player.prev();
+ }
+ }}
+ disabled={player.history.length <= 1 && audio.currentTime <= 3}
+ title="Previous"
+ >
+
+
+
+
+
audio.toggle()}
+ disabled={!player.currentTrack}
+ title={isPlaying ? "Pause" : "Play"}
+ >
+ {#if isPlaying}
+
+ {:else}
+
+ {/if}
+
+
+
+
player.next()}
+ disabled={player.queue.length === 0}
+ title="Next"
+ >
+
+
+
+
+
player.toggleRepeat()}
+ title="Toggle Repeat"
+ >
+ {#if repeatMode === "one"}
+
+ {:else}
+
+ {/if}
+
+
diff --git a/src/lib/components/player/PlayerDesktop.svelte b/src/lib/components/player/PlayerDesktop.svelte
new file mode 100644
index 0000000..cccad8f
--- /dev/null
+++ b/src/lib/components/player/PlayerDesktop.svelte
@@ -0,0 +1,132 @@
+
+
+
+ {#if player.currentTrack}
+
+
+
+
+
+
+
+
+
+
+ {player.currentTrack.title}
+
+
+ {player.currentTrack.artist}
+
+
+ {player.currentTrack.album ||
+ player.currentTrack.animeName ||
+ ""}
+
+
+
+
+
+
+
+ {formatTime(audio.currentTime)}
+ {formatTime(audio.duration)}
+
+
+
+
+
+
+
+
+
+
+
+ {#if player.isMuted || player.volume === 0}
+
+ {:else if player.volume < 0.5}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {:else}
+
+
+
+
+
No track playing
+
+ Pick a song from the library to start listening.
+
+
+ {/if}
+
diff --git a/src/lib/components/player/PlayerMobile.svelte b/src/lib/components/player/PlayerMobile.svelte
new file mode 100644
index 0000000..05fa63b
--- /dev/null
+++ b/src/lib/components/player/PlayerMobile.svelte
@@ -0,0 +1,137 @@
+
+
+{#if player.currentTrack}
+
+
(open = true)}
+ >
+
+
+
+
+
+
+
+
+
+ {player.currentTrack.title || "Unknown Title"}
+
+
+ {player.currentTrack.artist || "Unknown Artist"}
+
+
+
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {player.currentTrack.title}
+
+
+ {player.currentTrack.artist}
+
+
+ {player.currentTrack.album ||
+ player.currentTrack.animeName}
+
+
+
+
+
+
+
+ {formatTime(audio.currentTime)}
+ {formatTime(audio.duration)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/player/PlayerRoot.svelte b/src/lib/components/player/PlayerRoot.svelte
new file mode 100644
index 0000000..eccaf3c
--- /dev/null
+++ b/src/lib/components/player/PlayerRoot.svelte
@@ -0,0 +1,136 @@
+
+
+
+
+
diff --git a/src/lib/components/player/Queue.svelte b/src/lib/components/player/Queue.svelte
new file mode 100644
index 0000000..330a66e
--- /dev/null
+++ b/src/lib/components/player/Queue.svelte
@@ -0,0 +1,101 @@
+
+
+
+
+
Up Next
+ player.clearQueue()}
+ >
+ Clear
+
+
+
+
+
+ {#if player.displayQueue.length === 0}
+
+ Queue is empty
+
+ {:else}
+ {#each player.displayQueue as track (track.id)}
+
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}
+ >
+
+ {#if player.currentId === track.id}
+
+ {:else}
+
#
+
+ {/if}
+
+
+
+
+ {track.title}
+
+
+ {track.artist}
+
+
+
+
{
+ e.stopPropagation();
+ onRemove(track.id);
+ }}
+ >
+
+
+
+ {/each}
+ {/if}
+
+
+
+
diff --git a/src/lib/components/player/ctx.svelte.ts b/src/lib/components/player/ctx.svelte.ts
new file mode 100644
index 0000000..f0bf43c
--- /dev/null
+++ b/src/lib/components/player/ctx.svelte.ts
@@ -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(AUDIO_CTX_KEY);
+}
diff --git a/src/lib/components/player/utils.ts b/src/lib/components/player/utils.ts
new file mode 100644
index 0000000..fdc2f09
--- /dev/null
+++ b/src/lib/components/player/utils.ts
@@ -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")}`;
+}
diff --git a/src/lib/components/ui/chip-group/chip-group.svelte b/src/lib/components/ui/chip-group/chip-group.svelte
index baf6c96..eb74218 100644
--- a/src/lib/components/ui/chip-group/chip-group.svelte
+++ b/src/lib/components/ui/chip-group/chip-group.svelte
@@ -1,9 +1,9 @@
-
@@ -57,14 +57,16 @@
{#if !data.annId}
Anime not found
- The requested anime entry doesn’t exist (or the route param wasn’t a valid
- ANN id).
+ The requested anime entry doesn’t exist (or the route param wasn’t a
+ valid ANN id).
{:else if !data.animeWithSongs}
Loading anime…
{:else}
- {data.animeWithSongs.anime.mainName}
+
+ {data.animeWithSongs.anime.mainName}
+
{data.animeWithSongs.anime.year}
diff --git a/src/routes/list/+page.svelte b/src/routes/list/+page.svelte
index 819f06f..72ba8b8 100644
--- a/src/routes/list/+page.svelte
+++ b/src/routes/list/+page.svelte
@@ -6,7 +6,7 @@
import { invalidate } from "$app/navigation";
import SongEntry from "$lib/components/SongEntry.svelte";
import { db as clientDb } from "$lib/db/client-db";
- import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
+ import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import {
MalAnimeListQuerySchema,
@@ -139,7 +139,7 @@
addAllToQueue(tracksFromResults)}
+ onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
@@ -148,7 +148,7 @@
playAllNext(tracksFromResults)}
+ onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next
@@ -184,8 +184,8 @@
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.songRows.length === 0}
- No songs matched in the local database. This likely means none of the MAL
- anime IDs exist in the AMQ DB.
+ No songs matched in the local database. This likely means none of the
+ MAL anime IDs exist in the AMQ DB.
{/if}
diff --git a/src/routes/songs/+page.svelte b/src/routes/songs/+page.svelte
index cc52ed9..29b39e4 100644
--- a/src/routes/songs/+page.svelte
+++ b/src/routes/songs/+page.svelte
@@ -9,7 +9,7 @@
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { db as clientDb } from "$lib/db/client-db";
- import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
+ import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import { AmqSongLinkTypeMap } from "$lib/types/amq";
import type { PageData } from "./$types";
@@ -124,7 +124,9 @@
label="Song Type"
items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
label: type,
- value: AmqSongLinkTypeMap[type as keyof typeof AmqSongLinkTypeMap],
+ value: AmqSongLinkTypeMap[
+ type as keyof typeof AmqSongLinkTypeMap
+ ],
}))}
bind:value={params.type}
/>
@@ -149,7 +151,7 @@
addAllToQueue(tracksFromResults)}
+ onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
@@ -158,7 +160,7 @@
playAllNext(tracksFromResults)}
+ onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next