45 Commits

Author SHA1 Message Date
b37eef8f31 prototype anime list input 2026-02-13 16:56:12 -08:00
a144baba2b list: prototype without nested params 2026-02-13 01:04:49 -08:00
21d62f8c6f list: prototype with combined list field 2026-02-13 01:03:50 -08:00
f90cf66cc1 rename list page to mal 2026-02-12 23:44:02 -08:00
1a3ec7d84e ui: reduce gap in layout 2026-02-12 23:25:52 -08:00
7dc37d9eb7 ui: player: fix volume slider overflow in desktop player controls 2026-02-12 22:36:47 -08:00
ec3565078f feat(queue): auto-scroll to currently playing on queue open
Adds a visible prop to Queue that triggers auto-scroll to the currently
playing track when the queue becomes visible. PlayerMobile passes the
drawer open state, PlayerDesktop passes whether a track exists.
2026-02-12 22:19:12 -08:00
e3c0c6cade feat(queue): add scroll to currently playing button
Adds a scrollToIndex method to VirtualList and a locate button in the
Queue header that scrolls to center the currently playing track.
2026-02-12 22:19:12 -08:00
28643c38b8 feat(queue): add confirmation dialog when clearing the queue
Wraps the clear button in an AlertDialog that shows the number of songs
to be removed, requiring explicit confirmation before clearing.
2026-02-12 21:56:14 -08:00
3944cf6ff4 modify id to index map imperatively 2026-02-11 23:35:30 -08:00
6fca5bae39 perf(player): virtualize queue list rendering
Extract a generic VirtualList.svelte component that only renders
visible rows + overscan buffer using absolute positioning in a tall
sentinel div. Queue.svelte now uses VirtualList with 64px row height
(supporting 2-line titles) instead of rendering all items.

This reduces DOM nodes from 1000+ to ~20 for large queues.
2026-02-11 23:35:30 -08:00
cd443b974b perf(player): debounce localStorage persistence
Wrap save() in a 300ms debounce to prevent rapid serialization
when multiple state changes fire in quick succession (e.g. removing
several items or adjusting volume).
2026-02-11 23:35:20 -08:00
31414c5874 perf(player): add idToIndex Map for O(1) track lookups
Replace all linear find/findIndex/some scans with a derived Map
keyed by track.id. This makes currentTrack, currentIndex, hasTrack,
add, playNext, remove, and addAll all O(1) for ID lookups.
2026-02-11 22:56:49 -08:00
da3ab81ae6 perf(player): batch addAll/playAllNext to avoid cascading reactivity
addAll() now collects all new tracks and pushes them in a single
array operation instead of calling add() in a loop, which triggered
per-item reactive updates and O(n²) existence checks.
2026-02-11 22:56:00 -08:00
7e3a22f14b ui: queue: hide grab handle if hover available 2026-02-11 22:00:41 -08:00
86827777f4 testing with pgrok config 2026-02-11 21:28:06 -08:00
5e7534af35 ui: add placeholder artwork 2026-02-11 20:53:40 -08:00
11a4239fdc even more layout fixes 2026-02-10 05:41:18 -08:00
cdd90016f8 add extra song information to player 2026-02-10 05:25:28 -08:00
864a11fac4 stupid layout again 2026-02-10 04:46:16 -08:00
48e5719813 ui: add tags for dub and rebroadcast 2026-02-10 04:26:10 -08:00
e0d6e5bc32 a few more layout optimizations 2026-02-10 04:13:48 -08:00
fc0f04f29c more layout fixes 2026-02-10 03:53:53 -08:00
9e83b72139 remove artwork 2026-02-10 03:39:41 -08:00
f47f6e6cd8 actual layout improvements 2026-02-10 03:29:17 -08:00
197b9267c4 drag and drop but nicer 2026-02-10 01:58:33 -08:00
ed9fcbe116 drag and drop init 2026-02-10 01:55:49 -08:00
9f0234e00e fix play now 2026-02-10 01:48:02 -08:00
c8220eec02 fix play track in queue 2026-02-10 01:35:42 -08:00
99e6fd8eb4 fix shuffle 2026-02-10 01:28:36 -08:00
57320232a6 fix some ui 2026-02-10 01:17:38 -08:00
892f43381b fix stupid slider 2026-02-10 00:58:40 -08:00
bfae55afa3 fix 2026-02-10 00:30:38 -08:00
c8de948e7f WIP: global player pt. 6 fix playback finally 2026-02-10 00:22:52 -08:00
cfd45b6815 WIP: global player pt. 5 svelte bindings 2026-02-10 00:09:41 -08:00
4fb1d7865f WIP: global player pt. 4 fixed mobile 2026-02-10 00:01:32 -08:00
a4cf9356a8 WIP: global player pt. 3 monstrosity 2026-02-09 23:55:19 -08:00
f9fe6a2d11 WIP: global player refactor pt. 2 2026-02-09 23:42:08 -08:00
aea41df214 WIP: global player refactor pt. 1 2026-02-09 23:19:17 -08:00
9126e34f38 additional shadcn components 2026-02-09 22:30:48 -08:00
4e680c6697 ui: refactor chips into ChipGroup component 2026-02-09 21:51:10 -08:00
28d6231f21 ui: add styles for chip component on songs page 2026-02-09 21:28:39 -08:00
a9008627a1 ui: refactor some shadcn styles into utiltiy classes 2026-02-09 21:04:59 -08:00
72ee0260d9 refactor song type schema on search page 2026-02-09 20:52:49 -08:00
2bf5aeb1c0 db: change anime category number type to number 2026-02-09 18:53:42 -08:00
70 changed files with 2378 additions and 3133 deletions

View File

@@ -29,6 +29,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.3.1", "vite": "^7.3.1",
"wrangler": "^4.62.0", "wrangler": "^4.62.0",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -601,6 +602,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vaul-svelte": ["vaul-svelte@1.0.0-next.7", "", { "dependencies": { "runed": "^0.23.2", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-7zN7Bi3dFQixvvbUJY9uGDe7Ws/dGZeBQR2pXdXmzQiakjrxBvWo0QrmsX3HK+VH+SZOltz378cmgmCS9f9rSg=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@@ -643,6 +646,10 @@
"svelte-toolbelt/runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], "svelte-toolbelt/runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
"vaul-svelte/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
"vaul-svelte/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],

View File

@@ -7,6 +7,7 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "bun run build && wrangler dev", "preview": "bun run build && wrangler dev",
"db:push": "drizzle-kit push",
"db:import": "bun run src/lib/db/import-amq.ts", "db:import": "bun run src/lib/db/import-amq.ts",
"format": "biome check --write", "format": "biome check --write",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
@@ -40,6 +41,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.3.1", "vite": "^7.3.1",
"wrangler": "^4.62.0", "wrangler": "^4.62.0",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" /> <head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta charset="utf-8" />
%sveltekit.head% <meta name="viewport" content="width=device-width, initial-scale=1" />
</head> %sveltekit.head%
<body </head>
data-sveltekit-preload-data="hover"
class="flex min-h-screen flex-col items-center gap-8 p-4 max-sm:px-2" <body data-sveltekit-preload-data="hover" class="flex min-h-screen flex-col items-center gap-8">
> <div style="display: contents">%sveltekit.body%</div>
<div style="display: contents">%sveltekit.body%</div> </body>
</body>
</html> </html>

BIN
src/lib/assets/artwork.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,9 @@
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 { songTypeNumberLabel } from "$lib/utils/amq";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
type SongEntryProps = { type SongEntryProps = {
@@ -24,7 +19,9 @@
songName: string; songName: string;
artistName: string | null; artistName: string | null;
fileName?: string | null; fileName?: string | null;
globalPercent: number | null; globalPercent: number;
dub: boolean;
rebroadcast: boolean;
}; };
let { let {
@@ -36,16 +33,11 @@
artistName, artistName,
fileName = null, fileName = null,
globalPercent, globalPercent,
dub,
rebroadcast,
}: SongEntryProps = $props(); }: SongEntryProps = $props();
const typeLabelMap: Record<number, string> = { const displayTypeNumber = $derived(songTypeNumberLabel(type, number));
1: "OP",
2: "ED",
3: "INS",
};
const typeLabel = $derived(typeLabelMap[type] ?? `T${type}`);
const displayTypeNumber = $derived(`${typeLabel}${number || ""}`);
const artistDisplay = $derived.by( const artistDisplay = $derived.by(
() => artistName?.trim() || "Unknown Artist", () => artistName?.trim() || "Unknown Artist",
@@ -60,10 +52,13 @@
songName, songName,
artistName, artistName,
fileName, fileName,
dub,
rebroadcast,
globalPercent,
}), }),
); );
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,12 +86,16 @@
<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="tag">{displayTypeNumber}</span>
>{displayTypeNumber}</span <span class="text-muted-foreground">
>
<span class=" text-muted-foreground">
{globalPercent}% {globalPercent}%
</span> </span>
{#if dub}
<span title="Dub" class="tag">DUB</span>
{/if}
{#if rebroadcast}
<span title="Rebroadcast" class="tag">RB</span>
{/if}
</div> </div>
<div class="mt-1 w-fit text-foreground/80"> <div class="mt-1 w-fit text-foreground/80">
@@ -115,7 +114,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 +127,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 +142,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 +157,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" />

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { AnimeListCodec } from "./schema";
import { ChipGroup } from "$lib/components/ui/chip-group";
import { AnimeListWatchStatus } from "$lib/utils/list";
import NativeSelect from "$lib/components/ui/native-select/native-select.svelte";
import NativeSelectOption from "$lib/components/ui/native-select/native-select-option.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { Label } from "$lib/components/ui/label";
import { z } from "zod";
let { value = $bindable() }: { value: z.infer<typeof AnimeListCodec> } =
$props();
</script>
<div class="flex flex-col gap-2">
<Label for="list-kind">Kind</Label>
<NativeSelect id="list-kind" bind:value={value.kind}>
<NativeSelectOption value="mal">MAL</NativeSelectOption>
<NativeSelectOption value="anilist">AniList</NativeSelectOption>
<NativeSelectOption value="kitsu">Kitsu</NativeSelectOption>
</NativeSelect>
</div>
<div class="flex flex-col gap-2">
<Label for="list-username">Username</Label>
<Input id="list-username" bind:value={value.username} />
</div>
<div class="flex flex-col gap-2">
<Label for="list-status">Status</Label>
<ChipGroup
items={AnimeListWatchStatus.options.map((v) => ({
label: v.toUpperCase(),
value: v,
}))}
bind:value={value.status}
/>
</div>

View File

@@ -0,0 +1,2 @@
export { default as AnimeListInput } from "./AnimeListInput.svelte";
export * from "./schema";

View File

@@ -0,0 +1,16 @@
import { AnimeList, AnimeListWatchStatus } from "$lib/utils/list";
import { z } from "zod";
const SEP_FIELD = ":";
const SEP_VALUE = ",";
export const AnimeListCodec = z.codec(z.string(), AnimeList, {
decode: (s) => {
const [kind, ...rest] = decodeURIComponent(s).split(SEP_FIELD);
const statusStr = rest.pop();
const status = statusStr ? statusStr.split(SEP_VALUE).map((v) => AnimeListWatchStatus.parse(v)) : [];
const username = rest.join("");
return AnimeList.parse({ kind, username, status });
},
encode: (list) => encodeURIComponent(`${list.kind}${SEP_FIELD}${list.username}${SEP_FIELD}${list.status.join(SEP_VALUE)}`),
});

View File

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

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { Disc, Volume1, Volume2, VolumeX } from "@lucide/svelte";
import { player } from "$lib/player/store.svelte";
import { songTypeNumberLabel } from "$lib/utils/amq";
import Controls from "./Controls.svelte";
import { getAudioContext } from "./ctx.svelte";
import Queue from "./Queue.svelte";
import { formatTime } from "./utils";
const audio = getAudioContext();
const displayTypeNumber = $derived(
player.currentTrack
? songTypeNumberLabel(
player.currentTrack.type,
player.currentTrack.number,
)
: "",
);
</script>
<div
class="h-full flex flex-col border-l bg-background/50 backdrop-blur w-full"
>
{#if player.currentTrack}
<div class="p-4 space-y-4 shrink-0">
<!-- Track Info -->
<div class="space-y-1.5">
<h2 class="text-lg font-bold leading-tight">
{player.currentTrack.animeName}
<span class="tag">{displayTypeNumber}</span>
</h2>
<p class="text-foreground/80 font-medium text-l">
{player.currentTrack.title}
<span class="text-muted-foreground"> · </span>
<span class="text-muted-foreground"
>{player.currentTrack.globalPercent}%</span
>
</p>
<p class="text-sm text-muted-foreground">
{player.currentTrack.artist}
</p>
</div>
<!-- Progress -->
<div class="space-y-2">
<input
type="range"
bind:value={audio.currentTime}
max={audio.duration || 100}
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 gap-4 divide-x divide-accent">
<Controls />
<!-- Volume -->
<div class="flex items-center gap-3 min-w-0">
<button
onclick={() => player.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>
<input
type="range"
bind:value={player.volume}
max={1}
step={0.05}
class="flex-1 min-w-0"
/>
</div>
</div>
</div>
<!-- Divider -->
<div class="h-px bg-border mx-6"></div>
<!-- Queue (Scrollable) -->
<div class="flex-1 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 visible={!!player.currentTrack} />
</div>
</div>
</div>
{:else}
<div
class="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-4 p-8 text-center"
>
<div
class="h-16 w-16 rounded-full bg-muted flex items-center justify-center"
>
<Disc class="h-8 w-8 opacity-50" />
</div>
<p>No track playing</p>
<p class="text-xs max-w-xs opacity-70">
Pick a song from the library to start listening.
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { Disc } from "@lucide/svelte";
import * as Drawer from "$lib/components/ui/drawer";
import { player } from "$lib/player/store.svelte";
import { songTypeNumberLabel } from "$lib/utils/amq";
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);
const displayTypeNumber = $derived(
player.currentTrack
? songTypeNumberLabel(
player.currentTrack.type,
player.currentTrack.number,
)
: "",
);
</script>
<div class="z-50 border-t bg-background/95 backdrop-blur shadow-2xl">
<div class="px-4 py-2 flex items-center justify-between gap-4 h-16">
<!-- Mini Player Info -->
<button
type="button"
class="flex items-center gap-3 overflow-hidden flex-1 text-left bg-transparent border-none p-0 cursor-pointer"
onclick={() => (open = true)}
>
<div class="flex flex-col overflow-hidden">
<div class="text-sm font-medium truncate leading-tight">
{player.currentTrack?.animeName || "Unknown"}
<span class="tag">{displayTypeNumber}</span>
</div>
<div class="text-xs text-muted-foreground truncate leading-tight">
{player.currentTrack?.title || "Unknown Title"}
<span class="text-muted-foreground"> · </span>
<span class="text-muted-foreground"
>{player.currentTrack?.globalPercent}%</span
>
</div>
</div>
</button>
<!-- Mini Controls -->
<div class="flex items-center gap-1">
<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-xl flex-1 flex flex-col p-4 gap-4">
<!-- Track Info -->
<div class="text-center space-y-1">
<h2 class="text-xl font-bold leading-tight">
{player.currentTrack?.animeName}
<span class="tag">{displayTypeNumber}</span>
</h2>
<p class="text-foreground/80 font-medium text-lg line-clamp-1">
{player.currentTrack?.title}
<span class="text-muted-foreground"> · </span>
<span class="text-muted-foreground"
>{player.currentTrack?.globalPercent}%</span
>
</p>
<p class="text-sm text-muted-foreground">
{player.currentTrack?.artist}
</p>
</div>
<!-- Progress -->
<div class="space-y-2">
<input
type="range"
bind:value={audio.currentTime}
max={audio.duration || 100}
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">
<Controls />
</div>
<!-- Volume? Or Queue toggle? -->
<!-- Queue -->
<div class="flex-1 overflow-hidden relative mt-auto">
<div class="absolute inset-0">
<Queue visible={open} />
</div>
</div>
</div>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { onMount, setContext } from "svelte";
import artwork from "$lib/assets/artwork.jpg";
import { player } from "$lib/player/store.svelte";
import { songTypeNumberLabel } from "$lib/utils/amq";
import { AudioContext } 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";
onMount(() => {
audioCtx.setElement(audioEl);
// Load state
const saved = loadState();
if (saved) {
player.init(saved);
}
// Setup MediaSession actions
if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler("play", () => audioCtx.play());
navigator.mediaSession.setActionHandler("pause", () => audioCtx.pause());
navigator.mediaSession.setActionHandler("previoustrack", () =>
player.prev(),
);
navigator.mediaSession.setActionHandler("nexttrack", () => player.next());
}
});
// Update MediaSession metadata when track changes
$effect(() => {
const track = player.currentTrack;
if ("mediaSession" in navigator) {
if (track) {
const typeLabel = songTypeNumberLabel(track.type, track.number);
navigator.mediaSession.metadata = new MediaMetadata({
title: `${track.animeName} (${typeLabel}) — ${track.title}`,
artist: track.artist,
album: track.animeName ?? "",
artwork: [{ src: artwork }],
});
} else {
navigator.mediaSession.metadata = null;
}
}
});
$effect(() => {
const track = player.currentTrack;
if (audioEl) {
if (track) {
const newSrc = track.src;
const currentSrc = audioEl.currentSrc;
if (currentSrc !== newSrc) {
audioEl.src = newSrc;
audioEl.play().catch((e) => {
console.warn("Autoplay blocked or failed", e);
});
}
} else {
audioEl.removeAttribute("src");
}
// Update MediaSession playback state
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = audioEl.paused
? "paused"
: "playing";
}
}
});
// ... existing callbacks ...
// Bindings and Event Listeners
function onEnded() {
player.next();
}
// Sync MediaSession playback state with bound paused state
$effect(() => {
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = audioCtx.paused
? "paused"
: "playing";
}
});
</script>
<audio
bind:this={audioEl}
bind:currentTime={audioCtx.currentTime}
bind:duration={audioCtx.duration}
bind:paused={audioCtx.paused}
bind:volume={player.volume}
bind:muted={player.isMuted}
onended={onEnded}
class="hidden"
></audio>
<div class="contents">
<div class="lg:hidden w-full sticky bottom-0">
<PlayerMobile />
</div>
<div class="hidden lg:block sticky top-12 h-[calc(100dvh-4rem)]">
<PlayerDesktop />
</div>
</div>

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { GripVertical, LocateFixed, Play, X } from "@lucide/svelte";
import { tick } from "svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { Button } from "$lib/components/ui/button";
import VirtualList from "$lib/components/ui/VirtualList.svelte";
import { player } from "$lib/player/store.svelte";
import type { Track } from "$lib/player/types";
import { songTypeNumberLabel } from "$lib/utils/amq";
let { visible = true }: { visible?: boolean } = $props();
let virtualList: ReturnType<typeof VirtualList>;
function scrollToCurrentlyPlaying() {
if (player.currentId == null) return;
const index = player.displayQueue.findIndex(
(t) => t.id === player.currentId,
);
if (index !== -1) virtualList?.scrollToIndex(index);
}
$effect(() => {
if (visible) {
tick().then(() => scrollToCurrentlyPlaying());
}
});
const ITEM_HEIGHT = 64;
function onRemove(id: number) {
player.remove(id);
}
function onJump(track: Track) {
player.playId(track.id);
}
let dragOverIndex = $state<number | null>(null);
function onDragStart(e: DragEvent, index: number) {
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", index.toString());
}
function onDragOver(e: DragEvent, index: number) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
dragOverIndex = index;
}
function onDrop(e: DragEvent, toIndex: number) {
e.preventDefault();
dragOverIndex = null;
const fromIndexStr = e.dataTransfer?.getData("text/plain");
if (fromIndexStr) {
const fromIndex = parseInt(fromIndexStr, 10);
player.move(fromIndex, toIndex);
}
}
</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 text-sm items-center justify-between bg-muted/20"
>
<div class="flex items-center gap-1">
<h3 class="font-semibold">Up Next</h3>
{#if player.displayQueue.length > 0}
<span class="text-muted-foreground font-normal ml-1"
>({player.displayQueue.length})</span
>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
aria-label="Scroll to currently playing"
onclick={scrollToCurrentlyPlaying}
>
<LocateFixed class="h-3 w-3" />
</Button>
{/if}
</div>
<AlertDialog.Root>
<AlertDialog.Trigger>
{#snippet child({ props })}
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" {...props}>
<X class="h-3 w-3" aria-label="Clear" />
</Button>
{/snippet}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Clear queue?</AlertDialog.Title>
<AlertDialog.Description>
This will remove all {player.displayQueue.length} songs from the queue.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => player.clearQueue()}
>Clear</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
<VirtualList
bind:this={virtualList}
items={player.displayQueue}
itemHeight={ITEM_HEIGHT}
overscan={5}
class="p-2"
key={(track) => track.id}
>
{#snippet row({ item: track, index: i })}
<div
role="button"
tabindex="0"
draggable="true"
ondragstart={(e) => onDragStart(e, i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={(e) => onDrop(e, i)}
onclick={() => onJump(track)}
onkeydown={(e) => e.key === "Enter" && onJump(track)}
class="group flex items-center gap-2 px-3 h-full rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-sm"
class:active={player.currentId === track.id}
class:border-t-2={dragOverIndex === i}
class:border-primary={dragOverIndex === i}
>
<div
class="w-6 shrink-0 flex items-center justify-center text-xs text-muted-foreground/60 font-mono"
>
<div
class="group-hover:hidden flex items-center justify-center w-full h-full"
>
{#if player.currentId === track.id}
<div class="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
{:else}
<span>{i + 1}</span>
{/if}
</div>
<div
class="[@media(hover:hover)]:hidden group-hover:flex items-center justify-center w-full h-full cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-foreground"
aria-label="Drag to reorder"
>
<GripVertical class="h-4 w-4" />
</div>
</div>
<div class="flex-1 min-w-0">
<div
class="font-medium line-clamp-2 leading-tight"
class:text-primary={player.currentId === track.id}
>
{track.animeName}
<span class="tag"
>{songTypeNumberLabel(track.type, track.number)}</span
>
<span class="text-muted-foreground font-normal"
>{track.globalPercent}%</span
>
</div>
<div class="text-xs text-foreground/80 truncate">
{track.title}
<span class="text-muted-foreground">{track.artist}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 opacity-50 group-hover:opacity-100 transition-opacity shrink-0"
onclick={(e) => {
e.stopPropagation();
onRemove(track.id);
}}
>
<X class="h-3 w-3" />
</Button>
</div>
{/snippet}
{#snippet empty()}
<div class="text-center py-8 text-muted-foreground text-sm">
Queue is empty
</div>
{/snippet}
</VirtualList>
</div>
<style>
@reference "../../../routes/layout.css";
.active {
@apply bg-muted/40;
}
</style>

View File

@@ -0,0 +1,46 @@
import { getContext, setContext } from "svelte";
const AUDIO_CTX_KEY = "amqtrain:player:audio-ctx";
export class AudioContext {
currentTime = $state(0);
duration = $state(0);
paused = $state(true);
private audioEl: HTMLAudioElement | null = null;
setElement(el: HTMLAudioElement) {
this.audioEl = el;
}
// Bindings will handle state updates, but we need methods to control play/pause
// from other components.
// Since we bind to `this.paused`, toggling it here will trigger the audio element.
play() {
this.paused = false;
}
pause() {
this.paused = true;
}
toggle() {
this.paused = !this.paused;
}
seek(time: number) {
// Seeking is done by updating currentTime, which is bound to the audio element.
this.currentTime = Math.max(0, Math.min(time, this.duration));
}
}
export function setAudioContext() {
const ctx = new AudioContext();
setContext(AUDIO_CTX_KEY, ctx);
return ctx;
}
export function getAudioContext() {
return getContext<AudioContext>(AUDIO_CTX_KEY);
}

View File

@@ -0,0 +1,7 @@
export function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,109 @@
<!--
Generic fixed-height virtual list.
Usage:
<VirtualList items={myArray} itemHeight={64} overscan={5}>
{#snippet row({ item, index })}
<div>…</div>
{/snippet}
</VirtualList>
-->
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
type Props = {
items: T[];
itemHeight: number;
overscan?: number;
class?: string;
row: Snippet<[{ item: T; index: number }]>;
empty?: Snippet;
key?: (item: T, index: number) => unknown;
};
let {
items,
itemHeight,
overscan = 5,
class: className = "",
row,
empty,
key,
}: Props = $props();
let containerEl = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
const totalHeight = $derived(items.length * itemHeight);
const startIndex = $derived(
Math.max(0, Math.floor(scrollTop / itemHeight) - overscan),
);
const endIndex = $derived(
Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan,
),
);
const visibleItems = $derived(
items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
})),
);
function onScroll(e: Event) {
scrollTop = (e.target as HTMLDivElement).scrollTop;
}
export function scrollToIndex(index: number) {
if (!containerEl) return;
containerEl.scrollTop = Math.max(
0,
index * itemHeight - containerHeight / 2 + itemHeight / 2,
);
}
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
}
});
ro.observe(containerEl);
return () => ro.disconnect();
});
</script>
<div
class="virtual-list-container {className}"
bind:this={containerEl}
onscroll={onScroll}
>
{#if items.length === 0}
{@render empty?.()}
{:else}
<div
class="virtual-list-sentinel"
style="height: {totalHeight}px; position: relative;"
>
{#each visibleItems as entry (key ? key(entry.item, entry.index) : entry.index)}
<div
class="virtual-list-item"
style="position: absolute; top: {entry.index *
itemHeight}px; left: 0; right: 0; height: {itemHeight}px;"
>
{@render row(entry)}
</div>
{/each}
</div>
{/if}
</div>
<style>
.virtual-list-container {
overflow-y: auto;
flex: 1;
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { cn } from "$lib/utils";
type Item = {
label: string;
value: any;
};
let {
label,
items,
value = $bindable(),
type = "checkbox",
class: className,
...rest
}: {
label?: string;
items: Item[];
value: any;
type?: "checkbox" | "radio";
class?: string;
} = $props();
</script>
<div class={cn("flex flex-col gap-2", className)}>
{#if label}
<span class="scn-label">{label}</span>
{/if}
<div class="chip-group" {...rest}>
{#each items as item}
<label class="chip">
{#if type === "checkbox"}
<input
type="checkbox"
bind:group={value}
value={item.value}
/>
{:else}
<input type="radio" bind:group={value} value={item.value} />
{/if}
<span>{item.label}</span>
</label>
{/each}
</div>
</div>

View File

@@ -0,0 +1 @@
export { default as ChipGroup } from "./chip-group.svelte";

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
</script>
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import DrawerPortal from "./drawer-portal.svelte";
import DrawerOverlay from "./drawer-overlay.svelte";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
} = $props();
</script>
<DrawerPortal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
data-slot="drawer-content"
class={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...restProps}
>
<div
class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
></div>
{@render children?.()}
</DrawerPrimitive.Content>
</DrawerPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.DescriptionProps = $props();
</script>
<DrawerPrimitive.Description
bind:ref
data-slot="drawer-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.OverlayProps = $props();
</script>
<DrawerPrimitive.Overlay
bind:ref
data-slot="drawer-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
</script>
<DrawerPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.TitleProps = $props();
</script>
<DrawerPrimitive.Title
bind:ref
data-slot="drawer-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
</script>
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -0,0 +1,38 @@
import Root from "./drawer.svelte";
import Content from "./drawer-content.svelte";
import Description from "./drawer-description.svelte";
import Overlay from "./drawer-overlay.svelte";
import Footer from "./drawer-footer.svelte";
import Header from "./drawer-header.svelte";
import Title from "./drawer-title.svelte";
import NestedRoot from "./drawer-nested.svelte";
import Close from "./drawer-close.svelte";
import Trigger from "./drawer-trigger.svelte";
import Portal from "./drawer-portal.svelte";
export {
Root,
NestedRoot,
Content,
Description,
Overlay,
Footer,
Header,
Title,
Trigger,
Portal,
Close,
//
Root as Drawer,
NestedRoot as DrawerNestedRoot,
Content as DrawerContent,
Description as DrawerDescription,
Overlay as DrawerOverlay,
Footer as DrawerFooter,
Header as DrawerHeader,
Title as DrawerTitle,
Trigger as DrawerTrigger,
Portal as DrawerPortal,
Close as DrawerClose,
};

View File

@@ -12,9 +12,6 @@
<LabelPrimitive.Root <LabelPrimitive.Root
bind:ref bind:ref
data-slot="label" data-slot="label"
class={cn( class={cn("scn-label", className)}
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps} {...restProps}
/> />

View File

@@ -118,6 +118,8 @@ export async function getAnimeWithSongsByAnnId(db: ClientDb, annId: number) {
annSongId: animeSongLinks.annSongId, annSongId: animeSongLinks.annSongId,
type: animeSongLinks.type, type: animeSongLinks.type,
number: animeSongLinks.number, number: animeSongLinks.number,
dub: animeSongLinks.dub,
rebroadcast: animeSongLinks.rebroadcast,
songName: songs.name, songName: songs.name,
fileName: songs.fileName, fileName: songs.fileName,
@@ -139,6 +141,8 @@ export async function getAnimeWithSongsByAnnId(db: ClientDb, annId: number) {
annSongId: r.annSongId, annSongId: r.annSongId,
type: r.type, type: r.type,
number: r.number, number: r.number,
dub: r.dub,
rebroadcast: r.rebroadcast,
songName: r.songName, songName: r.songName,
fileName: r.fileName, fileName: r.fileName,
globalPercent: r.globalPercent, globalPercent: r.globalPercent,
@@ -173,6 +177,8 @@ export async function getSongsForMalAnimeIds(
annSongId: animeSongLinks.annSongId, annSongId: animeSongLinks.annSongId,
type: animeSongLinks.type, type: animeSongLinks.type,
number: animeSongLinks.number, number: animeSongLinks.number,
dub: animeSongLinks.dub,
rebroadcast: animeSongLinks.rebroadcast,
songName: songs.name, songName: songs.name,
fileName: songs.fileName, fileName: songs.fileName,
@@ -233,6 +239,8 @@ export async function getSongsWithFilters(
type: animeSongLinks.type, type: animeSongLinks.type,
number: animeSongLinks.number, number: animeSongLinks.number,
dub: animeSongLinks.dub,
rebroadcast: animeSongLinks.rebroadcast,
animeAnnId: anime.annId, animeAnnId: anime.annId,
animeMainName: anime.mainName, animeMainName: anime.mainName,

View File

@@ -14,7 +14,7 @@ import { getClientDb } from "$lib/db/client-db";
* This is intended for READ-ONLY browsing. Bump the version when you ship a new * This is intended for READ-ONLY browsing. Bump the version when you ship a new
* snapshot so clients refresh. * snapshot so clients refresh.
*/ */
export const AMQ_DB_SEED_VERSION = 3; export const AMQ_DB_SEED_VERSION = 4;
const SEED_ASSET_PATH = "/data/amq.sqlite"; const SEED_ASSET_PATH = "/data/amq.sqlite";
const seededStorageKey = (version: number) => `amq.sqlocal.seeded.v${version}`; const seededStorageKey = (version: number) => `amq.sqlocal.seeded.v${version}`;

View File

@@ -90,7 +90,7 @@ function zodErrorSummary(prefix: string, err: z.ZodError): string {
return `${prefix}\n${lines.join("\n")}${more}`; return `${prefix}\n${lines.join("\n")}${more}`;
} }
function categoryNumberToText( function _categoryNumberToText(
v: number | string | null | undefined, v: number | string | null | undefined,
): string | null { ): string | null {
if (v === null || v === undefined) return null; if (v === null || v === undefined) return null;
@@ -375,7 +375,7 @@ export async function importAmqData(
malId: a.malId, malId: a.malId,
kitsuId: a.kitsuId, kitsuId: a.kitsuId,
categoryName: a.category.name, categoryName: a.category.name,
categoryNumber: categoryNumberToText(a.category.number), categoryNumber: a.category.number,
mainName: a.mainName, mainName: a.mainName,
mainNameEn: a.mainNames.EN, mainNameEn: a.mainNames.EN,
mainNameJa: a.mainNames.JA, mainNameJa: a.mainNames.JA,

View File

@@ -1,4 +1,10 @@
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import {
index,
integer,
real,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
/** /**
* Core `anime` table. * Core `anime` table.
@@ -21,7 +27,7 @@ export const anime = sqliteTable(
// Category object (name + number that can be number|string|null in source) // Category object (name + number that can be number|string|null in source)
categoryName: text("category_name").notNull(), categoryName: text("category_name").notNull(),
categoryNumber: text("category_number"), categoryNumber: real("category_number"),
// Names // Names
mainName: text("main_name").notNull(), mainName: text("main_name").notNull(),

View File

@@ -37,7 +37,7 @@ export const songs = sqliteTable(
fileName: text("file_name"), fileName: text("file_name"),
fileName480: text("file_name_480"), fileName480: text("file_name_480"),
fileName720: text("file_name_720"), fileName720: text("file_name_720"),
globalPercent: integer("global_percent"), globalPercent: integer("global_percent").notNull(),
meanVolume: real("mean_volume"), meanVolume: real("mean_volume"),
/** /**

View File

@@ -1,204 +0,0 @@
import { browser } from "$app/environment";
import type { Track } from "./types";
export type MediaSessionHandlers = {
play: () => void;
pause: () => void;
next: () => void;
prev: () => void;
seekTo?: (timeSeconds: number) => void;
seekBy?: (deltaSeconds: number) => void;
};
export type MediaSessionBindings = {
/**
* Call whenever the current track changes.
*/
setTrack: (track: Track | null) => void;
/**
* Call on play/pause changes if you want to keep Media Session "active" state
* aligned with your app (handlers still work regardless).
*/
setPlaybackState: (state: "none" | "paused" | "playing") => void;
/**
* Call reasonably often (e.g. on `timeupdate`, `loadedmetadata`, `ratechange`)
* to keep lockscreen / OS UI in sync.
*/
updatePositionState: (args: {
duration: number;
position: number;
playbackRate?: number;
}) => void;
/**
* Unregisters handlers. Optional — layout-scoped players typically never unmount.
*/
destroy: () => void;
};
function canUseMediaSession() {
return (
browser &&
typeof navigator !== "undefined" &&
"mediaSession" in navigator &&
typeof (navigator as Navigator).mediaSession !== "undefined"
);
}
function canUseMetadata() {
return typeof MediaMetadata !== "undefined";
}
export function createMediaSessionBindings(
handlers: MediaSessionHandlers,
): MediaSessionBindings {
const mediaSession = canUseMediaSession()
? (navigator as Navigator).mediaSession
: null;
const setActionHandler = (
action: MediaSessionAction,
handler: MediaSessionActionHandler | null,
) => {
if (!mediaSession) return;
try {
mediaSession.setActionHandler(action, handler);
} catch {
// Some browsers throw for unsupported actions; ignore.
}
};
const safeNumber = (n: number) => (Number.isFinite(n) ? n : 0);
const setTrack = (track: Track | null) => {
if (!mediaSession) return;
if (!canUseMetadata()) return;
if (!track) {
// Keep it simple: clear metadata.
mediaSession.metadata = null;
return;
}
function typeNumberLabel(t: Track) {
const type = t.type;
const n = Number(t.number ?? 0);
let typeLabel: string | null = null;
if (typeof type === "number") {
if (type === 1) typeLabel = "OP";
else if (type === 2) typeLabel = "ED";
else if (type === 3) typeLabel = "INS";
else typeLabel = `T${type}`;
}
if (!typeLabel) return null;
return `${typeLabel}${n ? String(n) : ""}`;
}
mediaSession.metadata = new MediaMetadata({
title: `${track.animeName} (${typeNumberLabel(track)}) — ${track.title}`,
artist: track.artist,
album: track.album,
// You can add artwork later if/when you have it:
// artwork: [{ src: "/some.png", sizes: "512x512", type: "image/png" }]
});
};
const setPlaybackState = (state: "none" | "paused" | "playing") => {
if (!mediaSession) return;
try {
mediaSession.playbackState = state;
} catch {
// Some browsers may not implement playbackState; ignore.
}
};
const updatePositionState = (args: {
duration: number;
position: number;
playbackRate?: number;
}) => {
if (!mediaSession) return;
const anySession = mediaSession as unknown as {
setPositionState?: (state: {
duration: number;
playbackRate?: number;
position: number;
}) => void;
};
if (typeof anySession.setPositionState !== "function") return;
const duration = Math.max(0, safeNumber(args.duration));
const position = Math.max(0, safeNumber(args.position));
const playbackRate = args.playbackRate ?? 1;
try {
anySession.setPositionState({
duration,
position: Math.min(position, duration || position),
playbackRate,
});
} catch {
// iOS Safari and some Chromium variants can throw on invalid values.
}
};
const installHandlers = () => {
if (!mediaSession) return;
setActionHandler("play", () => handlers.play());
setActionHandler("pause", () => handlers.pause());
setActionHandler("previoustrack", () => handlers.prev());
setActionHandler("nexttrack", () => handlers.next());
// Seeking (optional)
setActionHandler("seekto", (details) => {
if (!handlers.seekTo) return;
const d = details as MediaSessionActionDetails & { seekTime?: number };
if (typeof d.seekTime !== "number") return;
handlers.seekTo(d.seekTime);
});
setActionHandler("seekbackward", (details) => {
const d = details as MediaSessionActionDetails & { seekOffset?: number };
const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
if (handlers.seekBy) handlers.seekBy(-offset);
else if (handlers.seekTo) handlers.seekTo(0); // fallback-ish
});
setActionHandler("seekforward", (details) => {
const d = details as MediaSessionActionDetails & { seekOffset?: number };
const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
if (handlers.seekBy) handlers.seekBy(offset);
});
// Stop isn't as universally supported; map to pause.
setActionHandler("stop", () => handlers.pause());
};
const destroy = () => {
if (!mediaSession) return;
// Clear handlers we set.
setActionHandler("play", null);
setActionHandler("pause", null);
setActionHandler("previoustrack", null);
setActionHandler("nexttrack", null);
setActionHandler("seekto", null);
setActionHandler("seekbackward", null);
setActionHandler("seekforward", null);
setActionHandler("stop", null);
};
installHandlers();
return {
setTrack,
setPlaybackState,
updatePositionState,
destroy,
};
}

View File

@@ -1,232 +1,33 @@
import { z } from "zod";
import { browser } from "$app/environment"; import { 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));
}

View File

@@ -1,893 +0,0 @@
import { browser } from "$app/environment";
import {
createPersistScheduler,
loadPersistedPlayerState,
type PersistablePlayerState,
} from "./persist";
import {
buildInitialShuffleOrder,
injectNextIntoShuffleOrder,
reindexAfterMoveOrRemove,
} from "./shuffle";
import type { Track } from "./types";
/**
* Global audio player state + queue/shuffle actions (Svelte 5 runes).
*
* This module is intended to be imported from UI components and pages.
* The actual <audio> element lives in a single GlobalPlayer component, which
* binds to the state here and calls actions.
*
* Canonical dedupe id: Track.id === annSongId (number).
*
* NOTE: Do NOT use module-level `$effect` here — consumers (e.g. GlobalPlayer)
* should subscribe via `subscribe()` and drive any side-effects (like persistence).
*/
export type InsertMode = "play" | "playNext" | "add" | "jump";
export type PlayerSnapshot = {
queue: Track[];
currentIndex: number | null;
// derived-ish convenience
currentTrack: Track | null;
shuffleEnabled: boolean;
wrapEnabled: boolean;
// shuffle traversal
order: number[];
history: number[];
cursor: number;
volume: number;
// UI
uiOpen: boolean;
};
export type PlayerSubscriber = (snapshot: PlayerSnapshot) => void;
export type Unsubscribe = () => void;
const DEFAULT_VOLUME = 1;
function isMobileLike() {
if (!browser) return false;
return window.matchMedia?.("(max-width: 1023px)")?.matches ?? false;
}
function clamp01(n: number) {
if (!Number.isFinite(n)) return DEFAULT_VOLUME;
return Math.max(0, Math.min(1, n));
}
function arrayRemoveAt<T>(arr: T[], index: number) {
if (index < 0 || index >= arr.length) return arr;
return [...arr.slice(0, index), ...arr.slice(index + 1)];
}
function arrayInsertAt<T>(arr: T[], index: number, item: T) {
const i = Math.max(0, Math.min(index, arr.length));
return [...arr.slice(0, i), item, ...arr.slice(i)];
}
function arrayMove<T>(arr: T[], from: number, to: number) {
if (from === to) return arr;
if (from < 0 || from >= arr.length) return arr;
if (to < 0 || to >= arr.length) return arr;
const copy = [...arr];
const [item] = copy.splice(from, 1);
copy.splice(to, 0, item);
return copy;
}
function dedupeTracks(tracks: Track[]) {
const seen = new Set<number>();
const out: Track[] = [];
for (const t of tracks) {
const id = Number(t.id);
if (!Number.isFinite(id)) continue;
if (seen.has(id)) continue;
seen.add(id);
out.push(t);
}
return out;
}
function visitedSet(history: number[], cursor: number) {
if (history.length === 0) return new Set<number>();
const end = Math.max(0, Math.min(cursor, history.length - 1));
return new Set(history.slice(0, end + 1));
}
function persistableState(): PersistablePlayerState {
return {
queue,
currentIndex,
shuffleEnabled,
wrapEnabled,
order,
history,
cursor,
volume,
uiOpen,
};
}
/** --- Initialize state (restore persisted if present) --- */
const persisted = browser ? loadPersistedPlayerState() : null;
let queue = $state<Track[]>(persisted?.queue ?? []);
let currentIndex = $state<number | null>(persisted?.currentIndex ?? null);
let shuffleEnabled = $state<boolean>(persisted?.shuffleEnabled ?? false);
let wrapEnabled = $state<boolean>(persisted?.wrapEnabled ?? false);
let order = $state<number[]>(persisted?.order ?? []);
let history = $state<number[]>(persisted?.history ?? []);
let cursor = $state<number>(persisted?.cursor ?? 0);
let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME));
let uiOpen = $state<boolean>(
// Default based on the current viewport:
// - mobile: closed
// - desktop: open
//
// Note: we intentionally do NOT default from persisted `uiOpen`, so the UI
// always follows the current device/viewport expectation.
!isMobileLike(),
);
const currentTrack = $derived<Track | null>(
currentIndex == null ? null : (queue[currentIndex] ?? null),
);
const snapshot = $derived<PlayerSnapshot>({
queue,
currentIndex,
currentTrack,
shuffleEnabled,
wrapEnabled,
order,
history,
cursor,
volume,
uiOpen,
});
/** --- Lightweight subscription API (to avoid polling) --- */
const subscribers = new Set<PlayerSubscriber>();
function notifySubscribers() {
// Read `snapshot` here so every subscriber sees a consistent value.
const s = snapshot;
for (const fn of subscribers) fn(s);
}
/**
* Subscribe to player snapshot changes.
*
* - Calls `fn` immediately with the current snapshot
* - Returns an `unsubscribe` function
*/
export function subscribe(fn: PlayerSubscriber): Unsubscribe {
subscribers.add(fn);
fn(snapshot);
return () => {
subscribers.delete(fn);
};
}
/**
* Notify subscribers at the end of the current microtask.
* This coalesces multiple mutations within the same tick into one update.
*/
let notifyQueued = false;
function queueNotify() {
if (notifyQueued) return;
notifyQueued = true;
queueMicrotask(() => {
notifyQueued = false;
notifySubscribers();
});
}
/**
* Persistence
*
* Persistence must be driven from a component (e.g. GlobalPlayer) via `subscribe()`
* to avoid orphaned module-level effects.
*/
const schedulePersist = createPersistScheduler(250);
export function schedulePersistNow(): void {
if (!browser) return;
schedulePersist(persistableState());
}
/** --- Public reads --- */
export function getSnapshot(): PlayerSnapshot {
return snapshot;
}
export function hasTrack(id: number): boolean {
const wanted = Number(id);
return queue.some((t) => t.id === wanted);
}
export function indexOfTrack(id: number): number {
const wanted = Number(id);
return queue.findIndex((t) => t.id === wanted);
}
/** --- Queue traversal helpers --- */
function ensureTraversalStateForCurrent() {
// Ensure history/cursor reflect currentIndex when possible.
if (currentIndex == null) {
history = [];
cursor = 0;
return;
}
if (history.length === 0) {
history = [currentIndex];
cursor = 0;
return;
}
const cur = history[cursor];
if (cur !== currentIndex) {
// Repair without rewriting past: append and move cursor to it.
history = [...history, currentIndex];
cursor = history.length - 1;
}
}
function rebuildShuffleOrderPreservingPast() {
if (!shuffleEnabled) {
order = [];
return;
}
ensureTraversalStateForCurrent();
const visited = visitedSet(history, cursor);
order = buildInitialShuffleOrder(queue.length, visited);
}
/**
* Get the next queue index according to shuffle/linear mode.
* Returns null if there is no next item (and wrap is disabled).
*/
function computeNextIndex(): number | null {
if (queue.length === 0) return null;
if (currentIndex == null) return 0;
if (!shuffleEnabled) {
const next = currentIndex + 1;
if (next < queue.length) return next;
return wrapEnabled ? 0 : null;
}
ensureTraversalStateForCurrent();
// If user went backwards in history and presses next, prefer moving forward in history.
if (cursor < history.length - 1) {
return history[cursor + 1] ?? null;
}
if (order.length === 0) {
if (!wrapEnabled) return null;
// Wrap in shuffle mode: keep past history, but allow revisiting by
// generating a fresh future order excluding "past" only up through cursor.
// Since at end, visited is all history; to wrap, we treat visited as empty
// and start a new cycle, but we MUST NOT mutate history.
// Easiest: generate an order containing all indices except current first,
// then inject current out.
const all = new Set<number>();
// visited empty for wrap cycle
order = buildInitialShuffleOrder(queue.length, all);
order = order.filter((i) => i !== history[cursor]);
}
return order[0] ?? null;
}
/**
* Get the previous queue index according to shuffle history or linear mode.
* Returns null if there is no previous item (and wrap is disabled).
*/
function computePrevIndex(currentTimeSeconds = 0): number | null {
if (queue.length === 0) return null;
if (currentIndex == null) return null;
// Standard behavior: if you've listened a bit, restart track.
if (currentTimeSeconds > 3) return currentIndex;
if (!shuffleEnabled) {
const prev = currentIndex - 1;
if (prev >= 0) return prev;
return wrapEnabled ? queue.length - 1 : null;
}
ensureTraversalStateForCurrent();
if (cursor > 0) return history[cursor - 1] ?? null;
if (!wrapEnabled) return null;
// Wrap backwards in shuffle mode:
// We can jump to the last item in history if it exists, otherwise pick any.
if (history.length > 0) return history[history.length - 1] ?? null;
return 0;
}
function applyCurrentIndex(next: number | null) {
currentIndex = next;
if (next == null) {
// stop traversal, but keep queue
return;
}
if (!shuffleEnabled) return;
// Ensure traversal exists and is anchored at *this* currentIndex.
// This is important for jump-to-index behavior in shuffle mode.
ensureTraversalStateForCurrent();
// If we jumped to an index that's not reflected at the current cursor,
// align cursor/history so that prev/next work relative to the jumped item.
// Strategy:
// - if next is already somewhere in history, move cursor there
// - otherwise, append it and set cursor to the end
const existingPos = history.indexOf(next);
if (existingPos !== -1) {
cursor = existingPos;
} else if (history[cursor] !== next) {
history = [...history, next];
cursor = history.length - 1;
}
// If this index was scheduled in the future order, remove it so we don't revisit.
order = order.filter((i) => i !== next);
// If it was at the head, that's implicitly consumed as well.
if (order[0] === next) order = order.slice(1);
}
/** --- Public traversal actions --- */
export function next(): void {
const idx = computeNextIndex();
if (idx == null) {
currentIndex = null;
queueNotify();
return;
}
applyCurrentIndex(idx);
queueNotify();
}
export function prev(currentTimeSeconds = 0): void {
const idx = computePrevIndex(currentTimeSeconds);
if (idx == null) return;
// If idx === currentIndex, we interpret that as "restart track"
applyCurrentIndex(idx);
queueNotify();
}
/** Jump to an existing queued track by id (does not reorder). */
export function jumpToTrack(id: number): void {
const i = indexOfTrack(id);
if (i === -1) return;
applyCurrentIndex(i);
queueNotify();
}
/** --- Queue mutation primitives (keep traversal state consistent) --- */
function removeAt(index: number) {
if (index < 0 || index >= queue.length) return;
queue = arrayRemoveAt(queue, index);
// Fix currentIndex
if (currentIndex != null) {
if (currentIndex === index) {
// Removing current track -> advance to next if possible else stop
currentIndex = null;
} else if (currentIndex > index) {
currentIndex = currentIndex - 1;
}
}
if (shuffleEnabled) {
const re = reindexAfterMoveOrRemove({
order,
history,
cursor,
currentIndex,
fromIndex: index,
toIndex: null,
});
order = re.order;
history = re.history;
cursor = re.cursor;
currentIndex = re.currentIndex;
}
// If current was removed, attempt to advance
if (currentIndex == null && queue.length > 0) {
const idx = computeNextIndex();
if (idx != null) applyCurrentIndex(idx);
}
}
function moveIndex(from: number, to: number) {
if (from === to) return;
if (from < 0 || from >= queue.length) return;
if (to < 0 || to >= queue.length) return;
queue = arrayMove(queue, from, to);
// Fix currentIndex (linear)
if (currentIndex != null) {
if (currentIndex === from) currentIndex = to;
else if (to < from) {
// moved earlier
if (currentIndex >= to && currentIndex < from) currentIndex += 1;
} else {
// moved later
if (currentIndex > from && currentIndex <= to) currentIndex -= 1;
}
}
if (shuffleEnabled) {
const re = reindexAfterMoveOrRemove({
order,
history,
cursor,
currentIndex,
fromIndex: from,
toIndex: to,
});
order = re.order;
history = re.history;
cursor = re.cursor;
currentIndex = re.currentIndex;
}
}
/** Insert a new track (that is NOT currently in queue) at index. */
function insertNewAt(index: number, track: Track) {
queue = arrayInsertAt(queue, index, track);
// Fix currentIndex if insertion occurs before/at current
if (currentIndex != null && index <= currentIndex) {
currentIndex += 1;
}
if (!shuffleEnabled) return;
// When shuffle is enabled, new item should be eligible for future play.
// We do not touch past history.
ensureTraversalStateForCurrent();
// We inserted at `index`, which shifts all existing indices >= index by +1.
// Reindex traversal state for a conceptual move "fromIndex = -1" doesn't fit,
// so perform a manual shift.
const shiftUp = (i: number) => (i >= index ? i + 1 : i);
history = history.map(shiftUp);
order = order.map(shiftUp);
// New track is at `index`. By default it should appear in the remaining order.
// We'll append; specific actions (play/playNext) will inject it as needed.
order = [...order, index];
}
/** --- Public queue actions --- */
export function clearQueue(): void {
queue = [];
currentIndex = null;
order = [];
history = [];
cursor = 0;
queueNotify();
}
export function removeTrack(id: number): void {
const i = indexOfTrack(id);
if (i === -1) return;
removeAt(i);
queueNotify();
}
/**
* Reorder an existing queued track by id.
*
* Semantics depend on shuffle:
* - Linear (shuffle off): reorder the underlying `queue` and adjust `currentIndex`.
* - Shuffle (shuffle on): reorder the UPCOMING play order (the `order` list), not the
* underlying `queue`, because the UI is presenting a shuffled traversal that users
* expect to be able to rearrange.
*
* In shuffle mode this only affects tracks that are still in the future schedule
* (`order`). Already-played history is left intact.
*
* Notes:
* - `toIndex` is a queue index when shuffle is OFF (destination position in `queue`).
* - `toIndex` is a queue index when shuffle is ON too, but it is interpreted as
* "place this track before the track currently at queue index `toIndex` in the
* UPCOMING order", i.e. it changes `order`, not `queue`.
*/
export function reorderTrackById(id: number, toIndex: number): void {
const from = indexOfTrack(id);
if (from === -1) return;
const clampedTo = Math.max(
0,
Math.min(queue.length - 1, Math.floor(toIndex)),
);
// Shuffle: reorder upcoming traversal schedule, not underlying queue.
if (shuffleEnabled) {
// Ensure traversal state exists
ensureTraversalStateForCurrent();
// Only reorder within the future schedule (`order`).
// If the dragged track isn't in `order`, it's either current, already played, or unscheduled.
const fromPos = order.indexOf(from);
if (fromPos === -1) {
queueNotify();
return;
}
// Determine insertion position relative to the target queue index within `order`.
// If target isn't currently in `order`, we clamp to the end of the future schedule.
let toPos = order.indexOf(clampedTo);
if (toPos === -1) toPos = order.length;
// Moving an item forward past itself needs an index adjustment after removal.
const without = order.filter((i) => i !== from);
if (toPos > fromPos) toPos = Math.max(0, toPos - 1);
const nextPos = Math.max(0, Math.min(without.length, toPos));
order = [...without.slice(0, nextPos), from, ...without.slice(nextPos)];
queueNotify();
return;
}
// Linear: reorder the underlying queue
if (from === clampedTo) return;
moveIndex(from, clampedTo);
queueNotify();
}
/**
* Core insertion behavior per your rules.
*
* - "jump": jump to an existing queued track (does not reorder)
* - "play": move/insert to right-after-current and then skip to it
* - "playNext": move/insert to right-after-current but don't skip
* - "add": append (deduped)
*
* Dedupe semantics:
* - If exists, we MOVE it instead of duplicating (except "jump", which never moves).
*/
export function insertTrack(track: Track, mode: InsertMode): void {
// Normalize + basic guard
if (!track || !Number.isFinite(track.id) || !track.src) return;
// Clicking an already-queued song should MOVE PLAYHEAD to that queue position,
// not reshuffle the queue around the current track.
if (mode === "jump") {
const i = indexOfTrack(track.id);
if (i === -1) return;
applyCurrentIndex(i);
// In shuffle mode, make sure the future order is still valid relative to the
// new cursor position (i.e., "next" should come from order after this jump).
// We rebuild the remaining order while preserving already-played history.
if (shuffleEnabled) {
rebuildShuffleOrderPreservingPast();
}
queueNotify();
return;
}
// If the user hits "Play" / "Play next" on the *currently playing* track,
// treat it as a no-op. This avoids trying to move the current track to
// "right after itself" and then skipping, which can produce confusing
// queue changes (and in some cases corrupt traversal state).
//
// NOTE: "Add to queue" still dedupes below, but we early-return there too.
if (currentIndex != null && queue[currentIndex]?.id === track.id) {
queueNotify();
return;
}
// Empty queue behavior
if (queue.length === 0) {
queue = [track];
currentIndex = 0;
// Initialize traversal state
if (shuffleEnabled) {
history = [0];
cursor = 0;
order = [];
} else {
history = [];
cursor = 0;
order = [];
}
if (mode === "add" || mode === "playNext") {
// If user only adds, do not auto-start; but we still set currentIndex?
// Per your desired behavior: "start the queue if it's empty" occurs on Play,
// not necessarily on Add/PlayNext. We'll keep "currentIndex = 0" so it shows
// a selected track, but GlobalPlayer should remain paused until user hits play.
// NOTE: If you prefer currentIndex to remain null for add/playNext on empty,
// we can tweak later.
}
// For play: skipping to inserted is effectively current track already.
queueNotify();
return;
}
// Determine insertion target: right after current, or at end if nothing selected.
const base =
currentIndex == null
? -1
: Math.max(-1, Math.min(currentIndex, queue.length - 1));
const targetIndex = base + 1;
const existingIndex = indexOfTrack(track.id);
if (mode === "add") {
if (existingIndex !== -1) return;
insertNewAt(queue.length, track);
// No traversal tweaks required
queueNotify();
return;
}
// play / playNext:
if (existingIndex === -1) {
// Insert as a new item at targetIndex
insertNewAt(targetIndex, track);
if (shuffleEnabled) {
// Ensure it becomes next in shuffled traversal
ensureTraversalStateForCurrent();
order = injectNextIntoShuffleOrder({
order,
history,
cursor,
nextIndex: targetIndex,
});
}
} else {
// Move existing to targetIndex (account for removal shifting)
// If existing is before target, the "real" target after removal shifts by -1
const adjustedTarget =
existingIndex < targetIndex ? targetIndex - 1 : targetIndex;
if (existingIndex !== adjustedTarget) {
moveIndex(existingIndex, adjustedTarget);
}
if (shuffleEnabled) {
ensureTraversalStateForCurrent();
order = injectNextIntoShuffleOrder({
order,
history,
cursor,
nextIndex: adjustedTarget,
});
}
}
if (mode === "play") {
// Skip current -> go to that "next"
next();
} else {
queueNotify();
}
}
export function insertTracks(tracks: Track[], mode: InsertMode): void {
const incoming = dedupeTracks(tracks).filter((t) => t?.src);
if (incoming.length === 0) return;
if (mode === "add") {
// Append in order
for (const t of incoming) insertTrack(t, "add");
queueNotify();
return;
}
// For play/playNext with multiple tracks:
// Place them sequentially after current in the order provided.
// - playNext: do not skip
// - play: skip to first inserted (becomes next), but keep subsequent ones after it
const base =
queue.length === 0
? -1
: currentIndex == null
? queue.length - 1
: currentIndex;
let insertPos = base + 1;
for (const t of incoming) {
const existing = indexOfTrack(t.id);
if (existing === -1) {
insertNewAt(insertPos, t);
if (shuffleEnabled) {
ensureTraversalStateForCurrent();
order = injectNextIntoShuffleOrder({
order,
history,
cursor,
nextIndex: insertPos,
});
}
insertPos += 1;
} else {
const adjustedTarget = existing < insertPos ? insertPos - 1 : insertPos;
if (existing !== adjustedTarget) moveIndex(existing, adjustedTarget);
if (shuffleEnabled) {
ensureTraversalStateForCurrent();
order = injectNextIntoShuffleOrder({
order,
history,
cursor,
nextIndex: adjustedTarget,
});
}
insertPos += 1;
}
}
if (mode === "play") {
next();
} else {
queueNotify();
}
}
/** --- Toggles / settings --- */
export function setVolume(v: number): void {
volume = clamp01(v);
queueNotify();
}
export function setUiOpen(open: boolean): void {
uiOpen = !!open;
queueNotify();
}
export function toggleUiOpen(): void {
uiOpen = !uiOpen;
queueNotify();
}
export function toggleWrap(): void {
wrapEnabled = !wrapEnabled;
queueNotify();
}
export function enableShuffle(enable: boolean): void {
const nextVal = !!enable;
if (shuffleEnabled === nextVal) return;
shuffleEnabled = nextVal;
if (!shuffleEnabled) {
order = [];
history = [];
cursor = 0;
queueNotify();
return;
}
// Turning shuffle on: preserve current as starting history point
if (currentIndex != null) {
history = [currentIndex];
cursor = 0;
} else {
history = [];
cursor = 0;
}
rebuildShuffleOrderPreservingPast();
queueNotify();
}
export function toggleShuffle(): void {
enableShuffle(!shuffleEnabled);
}
/**
* Ensure traversal state is sane if queue was externally replaced.
* Not expected in normal usage, but handy if you implement "replace queue" later.
*/
export function setQueue(
tracks: Track[],
opts?: { startIndex?: number | null },
) {
queue = dedupeTracks(tracks);
currentIndex =
opts?.startIndex == null
? null
: opts.startIndex >= 0 && opts.startIndex < queue.length
? opts.startIndex
: null;
order = [];
history = [];
cursor = 0;
if (shuffleEnabled) rebuildShuffleOrderPreservingPast();
queueNotify();
}
/** --- Convenience wrappers that match UI wording --- */
export function play(track: Track): void {
insertTrack(track, "play");
}
export function playNext(track: Track): void {
insertTrack(track, "playNext");
}
export function addToQueue(track: Track): void {
insertTrack(track, "add");
}
export function addAllToQueue(tracks: Track[]): void {
insertTracks(tracks, "add");
}
export function playAllNext(tracks: Track[]): void {
insertTracks(tracks, "playNext");
}
/**
* Minimal "now playing" string helpers for UI.
* Keeping here avoids repeated null checks in templates.
*/
export function nowPlayingLabel(): string {
if (!currentTrack) return "Nothing playing";
const t = currentTrack;
const title = (t.title ?? "").trim() || "Unknown title";
const artist = (t.artist ?? "").trim() || "Unknown Artist";
return `${title}${artist}`;
}

View File

@@ -1,181 +0,0 @@
export type ShuffleState = {
/**
* Upcoming indices into the *current queue* in the order they will be visited
* when calling `next()` while shuffle is enabled.
*/
order: number[];
/**
* Visited indices into the *current queue* in visit order.
* `cursor` points at the currently active entry within this array.
*/
history: number[];
/**
* Index into `history` that represents the current item.
*/
cursor: number;
};
export type ReindexResult = {
order: number[];
history: number[];
cursor: number;
currentIndex: number | null;
};
/**
* Build a shuffled play order for the *future* without affecting past history.
*
* - `queueLength`: length of the queue.
* - `visited`: indices that are considered "already visited" (typically history[0..cursor]).
* - `rng`: optional deterministic RNG for tests.
*/
export function buildInitialShuffleOrder(
queueLength: number,
visited: Set<number>,
rng: () => number = Math.random,
): number[] {
const remaining: number[] = [];
for (let i = 0; i < queueLength; i += 1) {
if (!visited.has(i)) remaining.push(i);
}
return shuffleArray(remaining, rng);
}
/**
* Insert a track so it becomes the "next" item, even while shuffle is enabled.
*
* This modifies ONLY the future order. Past history is preserved.
*
* - If `nextIndex` is already in `order`, it is moved to the front.
* - If `nextIndex` is currently in "visited" history (<= cursor), we do not
* reschedule it as next (that would violate history semantics). In that case,
* this function is a no-op.
* - If `track` is the current item (history[cursor]), no-op.
*/
export function injectNextIntoShuffleOrder(args: {
order: number[];
history: number[];
cursor: number;
nextIndex: number;
}): number[] {
const { order, history, cursor, nextIndex } = args;
if (!Number.isInteger(nextIndex) || nextIndex < 0) return order;
const current = history[cursor];
if (current === nextIndex) return order;
// Preserve past: don't schedule already-visited entries as "next"
const visited = new Set(history.slice(0, cursor + 1));
if (visited.has(nextIndex)) return order;
const nextOrder: number[] = [];
nextOrder.push(nextIndex);
for (const i of order) {
if (i === nextIndex) continue;
nextOrder.push(i);
}
return nextOrder;
}
/**
* Reindex a shuffle traversal state after a queue mutation that changes indices.
*
* Provide:
* - `fromIndex`: original index of the moved/removed item
* - `toIndex`: new index of the moved item, or `null` if the item was removed
* - `currentIndex`: the current queue index before reindexing (may be null)
*
* This updates `history`, `order`, and `currentIndex` so they point to correct
* indices in the new queue.
*
* Notes:
* - This assumes a *single-item* move or remove.
* - For "insert new item" (not previously present), you typically don't need
* this; instead you just insert the index into `order` as desired.
*/
export function reindexAfterMoveOrRemove(args: {
order: number[];
history: number[];
cursor: number;
currentIndex: number | null;
fromIndex: number;
toIndex: number | null;
}): ReindexResult {
const { fromIndex, toIndex } = args;
const remap = (i: number): number | null => {
if (!Number.isInteger(i) || i < 0) return null;
// Removal
if (toIndex == null) {
if (i === fromIndex) return null;
if (i > fromIndex) return i - 1;
return i;
}
// Move
if (fromIndex === toIndex) return i;
// Moving earlier: items between [toIndex .. fromIndex-1] shift +1
if (toIndex < fromIndex) {
if (i === fromIndex) return toIndex;
if (i >= toIndex && i < fromIndex) return i + 1;
return i;
}
// Moving later: items between [fromIndex+1 .. toIndex] shift -1
// (because we remove fromIndex then insert at toIndex)
if (i === fromIndex) return toIndex;
if (i > fromIndex && i <= toIndex) return i - 1;
return i;
};
const history = compactAndDedupePreservingOrder(
args.history.map((i) => remap(i)).filter((i): i is number => i != null),
);
// Cursor points into history; keep it at the same logical "current" where possible.
// If the current item was removed, clamp.
let cursor = args.cursor;
if (history.length === 0) cursor = 0;
else cursor = Math.max(0, Math.min(cursor, history.length - 1));
const order = compactAndDedupePreservingOrder(
args.order.map((i) => remap(i)).filter((i): i is number => i != null),
);
const currentIndex =
args.currentIndex == null ? null : remap(args.currentIndex);
return { order, history, cursor, currentIndex };
}
function shuffleArray<T>(arr: T[], rng: () => number) {
// FisherYates shuffle (in place), return copy for convenience
const a = [...arr];
for (let i = a.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
return a;
}
function compactAndDedupePreservingOrder(indices: number[]) {
const seen = new Set<number>();
const out: number[] = [];
for (const i of indices) {
if (!Number.isInteger(i) || i < 0) continue;
if (seen.has(i)) continue;
seen.add(i);
out.push(i);
}
return out;
}

View File

@@ -0,0 +1,412 @@
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
// Debounce timer for save()
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
// O(1) index: track.id → index in queue (maintained imperatively)
private idToIndex = $state(new Map<number, number>());
/** Rebuild the full index from the queue array. */
private rebuildIndex() {
const map = new Map<number, number>();
for (let i = 0; i < this.queue.length; i++) {
map.set(this.queue[i].id, i);
}
this.idToIndex = map;
}
// Derived
currentTrack = $derived.by(() => {
if (this.currentId == null) return null;
const idx = this.idToIndex.get(this.currentId);
return idx !== undefined ? this.queue[idx] : null;
});
currentIndex = $derived.by(() => {
if (this.currentId == null) return -1;
return this.idToIndex.get(this.currentId) ?? -1;
});
displayQueue = $derived(
this.isShuffled
? this.shuffledIndices.map((i) => this.queue[i])
: this.queue,
);
hasTrack(id: number) {
return this.idToIndex.has(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;
this.rebuildIndex();
}
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;
this.rebuildIndex();
}
} catch (e) {
console.error("Failed to load player state", e);
}
}
save() {
// Read snapshots synchronously so $effect tracks reactive deps
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),
};
// Debounce only the serialization + write
if (this._saveTimer) clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}, 300);
}
// Actions
add(track: Track, playNow = false) {
const exists = this.hasTrack(track.id);
if (exists) {
if (playNow) {
this.playNext(track);
this.playId(track.id);
}
return;
}
if (playNow) {
this.playNext(track);
this.playId(track.id);
} else {
// Add to end
this.queue.push(track);
this.idToIndex.set(track.id, this.queue.length - 1);
if (this.isShuffled) {
this.shuffledIndices.push(this.queue.length - 1);
}
if (this.queue.length === 1 && !this.currentId) {
this.currentId = track.id;
}
}
}
playNext(track: Track) {
const existingIdx = this.idToIndex.get(track.id) ?? -1;
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);
// Rebuild index — splice shifts everything after insertIdx
this.rebuildIndex();
if (this.isShuffled) {
// Shift indices that are >= insertIdx because we inserted a new item
this.shuffledIndices = this.shuffledIndices.map((i) =>
i >= insertIdx ? i + 1 : i,
);
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[]) {
// Batch: collect new tracks, push all at once
const newTracks: Track[] = [];
for (const track of tracks) {
// Check existence inline to avoid O(n) per-track via add()
if (!this.hasTrack(track.id)) {
newTracks.push(track);
}
}
if (newTracks.length === 0) return;
const startIdx = this.queue.length;
this.queue.push(...newTracks);
// Only index the newly added tracks
for (let i = 0; i < newTracks.length; i++) {
this.idToIndex.set(newTracks[i].id, startIdx + i);
}
if (this.isShuffled) {
const newIndices = newTracks.map((_, i) => startIdx + i);
this.shuffledIndices.push(...newIndices);
}
if (startIdx === 0 && !this.currentId) {
this.currentId = newTracks[0].id;
}
}
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.idToIndex.get(id);
if (idx === undefined) return;
const wasCurrent = this.currentId === id;
this.queue.splice(idx, 1);
// Rebuild index — splice shifts everything after idx
this.rebuildIndex();
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.idToIndex = new Map();
this.currentId = null;
this.shuffledIndices = [];
this.history = [];
}
playId(id: number) {
if (this.hasTrack(id)) {
this.currentId = id;
this.addToHistory(id);
}
}
move(fromIdx: number, toIdx: number) {
if (fromIdx === toIdx) return;
if (this.isShuffled) {
const indices = [...this.shuffledIndices];
if (fromIdx < 0 || fromIdx >= indices.length) return;
if (toIdx < 0 || toIdx >= indices.length) return;
const [item] = indices.splice(fromIdx, 1);
indices.splice(toIdx, 0, item);
this.shuffledIndices = indices;
} else {
const q = [...this.queue];
if (fromIdx < 0 || fromIdx >= q.length) return;
if (toIdx < 0 || toIdx >= q.length) return;
const [item] = q.splice(fromIdx, 1);
q.splice(toIdx, 0, item);
this.queue = q;
this.rebuildIndex();
}
}
// 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 (this.queue.length === 0) return;
let prevIdxInQueue: number | null = null;
const currentIdx = this.currentIndex;
if (this.isShuffled) {
const currentShufflePos = this.shuffledIndices.indexOf(currentIdx);
if (currentShufflePos > 0) {
prevIdxInQueue = this.shuffledIndices[currentShufflePos - 1];
} else if (this.repeatMode === "all" && this.shuffledIndices.length > 0) {
// Wrap to end? Or just stop.
// For now let's stop at start if not wrapping.
// If repeat all, wrap to end?
prevIdxInQueue = this.shuffledIndices[this.shuffledIndices.length - 1];
}
} else {
if (currentIdx > 0) {
prevIdxInQueue = currentIdx - 1;
} else if (this.repeatMode === "all" && this.queue.length > 0) {
prevIdxInQueue = this.queue.length - 1;
}
}
if (prevIdxInQueue !== null) {
this.playId(this.queue[prevIdxInQueue]?.id);
} else {
// At start of queue.
// Just seek to 0? Store doesn't control audio.
// If we can't go back, we do nothing (UI likely handles the seek-to-0 comparison).
}
}
addToHistory(id: number) {
const last = this.history[this.history.length - 1];
if (last !== id) {
this.history.push(id);
}
}
toggleShuffle() {
this.isShuffled = !this.isShuffled;
if (this.isShuffled) {
this.reshuffle();
}
}
reshuffle() {
// Create indices 0..N-1
const indices = Array.from({ length: this.queue.length }, (_, i) => i);
// Fisher-Yates
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
// Keep current first
if (this.currentId) {
const currentIdx = this.currentIndex;
const without = indices.filter((i) => i !== currentIdx);
this.shuffledIndices = [currentIdx, ...without];
} else {
this.shuffledIndices = indices;
}
}
toggleRepeat() {
if (this.repeatMode === "off") this.repeatMode = "all";
else if (this.repeatMode === "all") this.repeatMode = "one";
else this.repeatMode = "off";
}
setVolume(v: number) {
this.volume = Math.max(0, Math.min(1, v));
}
toggleMute() {
this.isMuted = !this.isMuted;
}
setUiOpen(open: boolean) {
this.uiOpen = open;
}
}
export const player = new PlayerStore();

View File

@@ -18,9 +18,12 @@ export type Track = {
/** Optional extra context for rendering/debugging */ /** Optional extra context for rendering/debugging */
animeName?: string; animeName?: string;
type?: SongType; type: SongType;
number?: number; number: number;
fileName?: string | null; fileName?: string | null;
dub: boolean;
rebroadcast: boolean;
globalPercent: number;
}; };
export type SongRowLike = { export type SongRowLike = {
@@ -31,6 +34,9 @@ export type SongRowLike = {
songName: string; songName: string;
artistName: string | null; artistName: string | null;
fileName?: string | null; fileName?: string | null;
dub: boolean;
rebroadcast: boolean;
globalPercent: number;
}; };
/** /**
@@ -55,5 +61,8 @@ export function trackFromSongRow(row: SongRowLike): Track | null {
type: row.type, type: row.type,
number: row.number, number: row.number,
fileName, fileName,
dub: row.dub,
rebroadcast: row.rebroadcast,
globalPercent: row.globalPercent,
}; };
} }

View File

@@ -2,18 +2,28 @@ import { z } from "zod";
import { AmqAnimeCategory, AmqAnimeGenre, AmqAnimeTag } from "./anime-extended"; import { AmqAnimeCategory, AmqAnimeGenre, AmqAnimeTag } from "./anime-extended";
export const Season = z.enum({ export const AmqSeasonMap = {
Winter: 0, Winter: 0,
Spring: 1, Spring: 1,
Summer: 2, Summer: 2,
Fall: 3, Fall: 3,
} as const); } as const;
export const SongLinkType = z.enum({ export const AmqSeason = z.enum(AmqSeasonMap);
export const AmqSongLinkTypeMap = {
OP: 1, OP: 1,
ED: 2, ED: 2,
INS: 3, INS: 3,
} as const); } as const;
export const AmqSongLinkTypeMapReverse = {
1: "OP",
2: "ED",
3: "INS",
} as const;
export const AmqSongLinkType = z.enum(AmqSongLinkTypeMap);
const BooleanInt = z.enum({ const BooleanInt = z.enum({
false: 0, false: 0,
@@ -23,7 +33,7 @@ const BooleanInt = z.enum({
export const AmqSongLink = z.object({ export const AmqSongLink = z.object({
songId: z.int().positive(), songId: z.int().positive(),
number: z.int().nonnegative(), number: z.int().nonnegative(),
type: SongLinkType, type: AmqSongLinkType,
annSongId: z.int().positive(), annSongId: z.int().positive(),
uploaded: BooleanInt, uploaded: BooleanInt,
rebroadcast: BooleanInt, rebroadcast: BooleanInt,
@@ -37,7 +47,7 @@ export const AmqAnimeSchema = z.object({
kitsuId: z.int().positive().nullable(), kitsuId: z.int().positive().nullable(),
category: z.object({ category: z.object({
name: AmqAnimeCategory, name: AmqAnimeCategory,
number: z.union([z.number(), z.string()]).nullable(), number: z.coerce.number().nullable(),
}), }),
genres: z.array(AmqAnimeGenre), genres: z.array(AmqAnimeGenre),
tags: z.array(AmqAnimeTag), tags: z.array(AmqAnimeTag),
@@ -53,7 +63,7 @@ export const AmqAnimeSchema = z.object({
}), }),
), ),
year: z.int().positive(), year: z.int().positive(),
seasonId: Season, seasonId: AmqSeason,
songLinks: z.array(AmqSongLink), songLinks: z.array(AmqSongLink),
opCount: z.int().nonnegative(), opCount: z.int().nonnegative(),
edCount: z.int().nonnegative(), edCount: z.int().nonnegative(),

View File

@@ -1,20 +1,5 @@
export const SongTypeMap: Record<string, number> = { import { AmqSongLinkTypeMapReverse } from "$lib/types/amq";
OP: 1,
ED: 2,
INS: 3,
};
// Map number back to string for displaying in UI export function songTypeNumberLabel(type: number, number: number) {
export const SongTypeReverseMap: Record<number, string> = { return `${AmqSongLinkTypeMapReverse[type as keyof typeof AmqSongLinkTypeMapReverse] ?? `T${type}`}${number ? number : ""}`;
1: "OP", }
2: "ED",
3: "INS",
};
export const SongCategoryMap: Record<number, string> = {
0: "None",
1: "Instrumental",
2: "Chanting",
3: "Character",
4: "Standard",
};

View File

@@ -0,0 +1,36 @@
import z from "zod";
export const MAL_URL = "https://myanimelist.net";
export const ANILIST_URL = "https://anilist.co";
export const KITSU_URL = "https://kitsu.io";
export const AnimeListKind = z.enum([
"mal",
"anilist",
"kitsu",
])
export const AnimeListWatchStatus = z.enum({
"completed": "c",
"watching": "w",
"plan_to_watch": "p",
"on_hold": "h",
"dropped": "d",
} as const)
export const AnimeList = z.object({
kind: AnimeListKind,
username: z.string(),
status: z.array(AnimeListWatchStatus),
});
export function listExternalUrl(list: z.infer<typeof AnimeList>) {
switch (list.kind) {
case "mal":
return `${MAL_URL}/profile/${encodeURIComponent(list.username)}`;
case "anilist":
return `${ANILIST_URL}/user/${encodeURIComponent(list.username)}`;
case "kitsu":
return `${KITSU_URL}/username/${encodeURIComponent(list.username)}`;
}
}

View File

@@ -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();
@@ -18,7 +18,7 @@
- Desktop: 2-column grid, right column reserved for the in-flow player sidebar - Desktop: 2-column grid, right column reserved for the in-flow player sidebar
--> -->
<div <div
class="min-h-dvh not-xl:min-w-[80dvw] lg:grid lg:grid-rows-[auto_1fr] gap-16 lg:grid-cols-[1fr_420px]" class="flex flex-col min-h-dvh not-lg:w-full not-xl:min-w-[80dvw] lg:grid lg:grid-rows-[auto_1fr] gap-4 lg:grid-cols-[1fr_420px]"
> >
<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"
@@ -30,22 +30,24 @@
<a href={resolve("/")}>Anime</a> <a href={resolve("/")}>Anime</a>
<a href={resolve("/songs")}>Songs</a> <a href={resolve("/songs")}>Songs</a>
<a href={resolve("/list")}>List</a> <a href={resolve("/list")}>List</a>
<a href={resolve("/mal")}>MAL</a>
</nav> </nav>
</div> </div>
</header> </header>
<main class="flex flex-col items-center]"> <main class="flex flex-col flex-1 p-4 max-sm:px-2">
{@render children()} {@render children()}
</main> </main>
<!-- Desktop sidebar column (in normal flow) --> <!-- Desktop sidebar column (in normal flow) -->
<aside class="hidden lg:block"> <!-- PlayerRoot uses contents to inject its children into this grid -->
<ClientOnly showFallback={false}> <ClientOnly showFallback={false}>
{#snippet children()} {#snippet children()}
<GlobalPlayer /> <div class="contents">
{/snippet} <PlayerRoot />
</ClientOnly> </div>
</aside> {/snippet}
</ClientOnly>
</div> </div>
<!-- Mobile player UI is rendered via a portal from the single GlobalPlayer instance <!-- Mobile player UI is rendered via a portal from the single GlobalPlayer instance

View File

@@ -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";
@@ -74,11 +74,14 @@
songName: s.songName, songName: s.songName,
artistName: s.artistName, artistName: s.artistName,
fileName: s.fileName ?? null, fileName: s.fileName ?? null,
dub: Boolean(s.dub),
rebroadcast: Boolean(s.rebroadcast),
globalPercent: s.globalPercent,
}), }),
) )
.filter((t) => t !== null); .filter((t) => t !== null);
addAllToQueue(tracks); player.addAll(tracks);
} }
async function playAllNextForAnime(a: AnimeItem) { async function playAllNextForAnime(a: AnimeItem) {
@@ -98,11 +101,14 @@
songName: s.songName, songName: s.songName,
artistName: s.artistName, artistName: s.artistName,
fileName: s.fileName ?? null, fileName: s.fileName ?? null,
dub: Boolean(s.dub),
rebroadcast: Boolean(s.rebroadcast),
globalPercent: s.globalPercent,
}), }),
) )
.filter((t) => t !== null); .filter((t) => t !== null);
playAllNext(tracks); player.playAllNext(tracks);
} }
onMount(() => { onMount(() => {

View File

@@ -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";
@@ -36,17 +36,20 @@
songName: s.songName, songName: s.songName,
artistName: s.artistName, artistName: s.artistName,
fileName: s.fileName ?? null, fileName: s.fileName ?? null,
dub: Boolean(s.dub),
rebroadcast: Boolean(s.rebroadcast),
globalPercent: s.globalPercent,
}), }),
) )
.filter((t) => t !== null); .filter((t) => t !== null);
} }
function queueAll() { function queueAll() {
addAllToQueue(playableTracks()); player.addAll(playableTracks());
} }
function playAllNextFromAnime() { function playAllNextFromAnime() {
playAllNext(playableTracks()); player.playAllNext(playableTracks());
} }
</script> </script>
@@ -64,7 +67,9 @@
<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}
@@ -164,6 +169,8 @@
artistName={s.artistName} artistName={s.artistName}
fileName={s.fileName} fileName={s.fileName}
globalPercent={s.globalPercent} globalPercent={s.globalPercent}
dub={Boolean(s.dub)}
rebroadcast={Boolean(s.rebroadcast)}
/> />
</li> </li>
{/each} {/each}

View File

@@ -134,4 +134,40 @@
.btn-icon-sm { .btn-icon-sm {
@apply inline-flex h-8 w-8 items-center justify-center rounded border; @apply inline-flex h-8 w-8 items-center justify-center rounded border;
} }
.chip-group {
@apply flex flex-wrap items-stretch border border-input rounded-md overflow-hidden bg-background w-fit h-fit;
}
.chip-group legend {
@apply flex items-center px-3 py-1.5 text-sm font-semibold border-r border-input;
}
.chip {
@apply relative inline-flex items-center px-4 py-2 border-r border-input last:border-r-0 bg-background cursor-pointer text-sm font-medium transition-colors hover:bg-muted/50;
}
.chip input {
@apply appearance-none;
cursor: inherit;
}
.chip:has(input:checked) {
@apply bg-primary text-primary-foreground;
}
.chip input:checked + span {
@apply font-semibold;
}
.tag {
@apply rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground;
}
}
@layer scn {
/* Stolen styles from svelte-shadcn to make the final css look less tailwindy */
.scn-label {
@apply flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50;
}
} }

View File

@@ -1,221 +1,44 @@
<script lang="ts"> <script lang="ts">
import { useSearchParams } from "runed/kit"; import { useSearchParams } from "runed/kit";
import { onMount } from "svelte"; import type { PageData } from "./$types";
import { z } from "zod"; import { SearchParamsSchema } from "./schema";
import { browser } from "$app/environment"; import { Button } from "$lib/components/ui/button";
import { invalidate } from "$app/navigation"; import { z } from "zod";
import SongEntry from "$lib/components/SongEntry.svelte"; import {
import { db as clientDb } from "$lib/db/client-db"; AnimeListCodec,
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte"; AnimeListInput,
import { trackFromSongRow } from "$lib/player/types"; } from "$lib/components/inputs/anime-list-input";
import {
MalAnimeListQuerySchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageData } from "./$types";
const ListSearchSchema = MalAnimeListQuerySchema.extend({ let { data }: { data: PageData } = $props();
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
// URL param `mal` is updated only on Search
mal: z.string().default(""),
}).strict();
const params = useSearchParams(ListSearchSchema, { const params = useSearchParams(SearchParamsSchema, {
pushHistory: false, pushHistory: false,
showDefaults: false, });
});
let { data }: { data: PageData } = $props(); let formState: z.infer<typeof AnimeListCodec> = $state({
kind: "mal",
username: "",
status: [],
});
// Local username field that does NOT update the URL as you type. // $inspect(formState);
let formMal = $state<string>(params.mal);
// If SSR returned no songRows (because client DB wasn't available), $effect(() => {
// re-run load on the client once the DB is ready by invalidating. console.log("formState", formState);
onMount(() => { });
if (data.songRows.length > 0) return;
if (!data.username || !data.malResponse) return;
if (clientDb) {
void invalidate("clientdb:songs");
return;
}
});
function songArtistLabel(r: (typeof data.songRows)[number]) {
return r.artistName ?? r.groupName ?? null;
}
function makeMalHref(username: string) {
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
}
const tracksFromResults = $derived.by(() =>
data.songRows
.map((r) =>
trackFromSongRow({
annSongId: r.annSongId,
animeName: r.animeName,
type: r.type,
number: r.number,
songName: r.songName,
artistName: songArtistLabel(r),
fileName: r.fileName,
}),
)
.filter((t) => t !== null),
);
</script> </script>
<h1 class="text-2xl font-semibold">MAL List → Songs</h1> <h1 class="text-2xl font-semibold">List Search WIP</h1>
<p class="mt-2 text-sm text-muted-foreground">
{#if !clientDb}
Loading DB...
{/if}
</p>
<form <form
class="mt-4 flex flex-col gap-2" onsubmit={(e) => {
onsubmit={(e) => { e.preventDefault();
e.preventDefault(); params.kind = formState.kind;
params.mal = formMal; params.username = formState.username;
}} params.status = formState.status;
}}
class="flex flex-wrap items-end gap-2"
> >
<div class="flex flex-wrap gap-2"> <AnimeListInput bind:value={formState} />
<div class="flex flex-col gap-2"> <Button type="submit">Search</Button>
<label class="text-sm text-muted-foreground" for="mal-user"
>MAL username</label
>
<input
id="mal-user"
class="rounded border px-3 py-2 text-sm"
placeholder="e.g. CaZzzer"
bind:value={formMal}
autocomplete="off"
spellcheck={false}
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-status"
>Status</label
>
<select
id="mal-status"
class="rounded border px-3 py-2 text-sm"
bind:value={params.status}
>
<option value="">All</option>
<option value="watching">Watching</option>
<option value="completed">Completed</option>
<option value="on_hold">On hold</option>
<option value="dropped">Dropped</option>
<option value="plan_to_watch">Plan to watch</option>
</select>
</div>
<div class="flex flex-col justify-end">
<button
type="submit"
class="rounded border px-3 py-2 text-sm"
disabled={!(formMal ?? "").trim()}
>
Search
</button>
</div>
</div>
<div class="text-sm text-muted-foreground">
{#if data.username}
MAL entries: {data.malResponse?.data.length ?? 0} (limited to {data.LIST_QUERY_LIMIT})
• Songs found: {data.songRows.length}
{/if}
</div>
{#if data.songRows.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => addAllToQueue(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
</button>
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next
</button>
{#if tracksFromResults.length !== data.songRows.length}
<span class="self-center text-sm text-muted-foreground">
({tracksFromResults.length} playable)
</span>
{/if}
</div>
{/if}
{#if data.username}
<div class="text-sm">
<a
class="hover:underline"
href={makeMalHref(data.username)}
target="_blank"
rel="noreferrer"
>
View {data.username} on MAL
</a>
</div>
{/if}
</form> </form>
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) === 0}
<p class="mt-4 text-sm text-muted-foreground">
No anime returned from MAL (did you set a restrictive status?).
</p>
{/if}
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.songRows.length === 0}
<p class="mt-4 text-sm text-muted-foreground">
No songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB.
</p>
{/if}
{#if data.songRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2">
{#each data.songRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li>
<SongEntry
annSongId={r.annSongId}
animeName={r.animeName}
type={r.type}
number={r.number}
songName={r.songName}
artistName={songArtistLabel(r)}
fileName={r.fileName}
globalPercent={r.globalPercent}
/>
</li>
{/each}
</ul>
{/if}
{#if browser && data.malResponse?.paging?.next}
<p class="mt-6 text-sm text-muted-foreground">
More results exist on MAL, but pagination is not wired yet.
</p>
{/if}
{#if !browser}
Loading stuff...
{/if}

View File

@@ -1,100 +1,7 @@
import { z } from "zod";
// Import client-db index directly as requested.
// On the server, `db` will be null (because `browser` is false in that module).
import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
import {
MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { SearchParamsSchema } from "./schema";
const LIST_QUERY_LIMIT = 1000;
const SearchSchema = MalAnimeListQuerySchema.extend({
// Username
mal: z.string().optional(),
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).optional(),
}).strict();
type StatusParam = z.infer<typeof SearchSchema>["status"];
function normalizeStatus(
status: StatusParam,
): z.infer<typeof MalAnimeListStatusEnum> | undefined {
if (status == null || status === "") return undefined;
return status;
}
export const load: PageLoad = async ({ url, fetch, depends }) => { export const load: PageLoad = async ({ url, fetch, depends }) => {
depends("mal:animelist"); const parsed = SearchParamsSchema.safeParse(url.searchParams);
depends("clientdb:songs"); console.log(parsed);
}
const parsed = SearchSchema.safeParse(
Object.fromEntries(url.searchParams.entries()),
);
const mal = parsed.success ? parsed.data.mal : undefined;
const status = parsed.success
? normalizeStatus(parsed.data.status)
: undefined;
const username = (mal ?? "").trim();
// Always return a stable shape for hydration
if (!username) {
return {
LIST_QUERY_LIMIT,
username: "",
status: status ?? null,
malResponse: null as z.infer<typeof MalAnimeListResponseSchema> | null,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// This endpoint proxies MAL and works server-side.
const malUrl = new URL(
`/api/mal/animelist/${encodeURIComponent(username)}`,
url.origin,
);
malUrl.searchParams.set("limit", String(LIST_QUERY_LIMIT));
if (status) malUrl.searchParams.set("status", status);
// NOTE: If you later want to support sort/offset, add them here from SearchSchema too.
const malRes = await fetch(malUrl);
if (!malRes.ok) {
// Let +page.svelte decide how to display errors; throw to use SvelteKit error page
throw new Error(`MAL request failed (${malRes.status})`);
}
const malJson: unknown = await malRes.json();
const malResponse = MalAnimeListResponseSchema.parse(malJson);
// Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser.
if (!db) {
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// Browser path: seed then query local DB for songs by MAL ids
await ensureSeeded({ fetch });
const malIds = malResponse.data.map((e) => e.node.id);
const songRows = await getSongsForMalAnimeIds(db, malIds);
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows,
};
};

View File

@@ -0,0 +1,8 @@
import { AnimeListKind, AnimeListWatchStatus } from "$lib/utils/list";
import { z } from "zod";
export const SearchParamsSchema = z.object({
kind: AnimeListKind.default("mal"),
username: z.string().default(""),
status: z.array(AnimeListWatchStatus).default([]),
})

226
src/routes/mal/+page.svelte Normal file
View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { useSearchParams } from "runed/kit";
import { onMount } from "svelte";
import { z } from "zod";
import { browser } from "$app/environment";
import { invalidate } from "$app/navigation";
import SongEntry from "$lib/components/SongEntry.svelte";
import { db as clientDb } from "$lib/db/client-db";
import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import {
MalAnimeListQuerySchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageData } from "./$types";
const ListSearchSchema = MalAnimeListQuerySchema.extend({
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
// URL param `mal` is updated only on Search
mal: z.string().default(""),
}).strict();
const params = useSearchParams(ListSearchSchema, {
pushHistory: false,
showDefaults: false,
});
let { data }: { data: PageData } = $props();
// Local username field that does NOT update the URL as you type.
let formMal = $state<string>(params.mal);
// If SSR returned no songRows (because client DB wasn't available),
// re-run load on the client once the DB is ready by invalidating.
onMount(() => {
if (data.songRows.length > 0) return;
if (!data.username || !data.malResponse) return;
if (clientDb) {
void invalidate("clientdb:songs");
return;
}
});
function songArtistLabel(r: (typeof data.songRows)[number]) {
return r.artistName ?? r.groupName ?? null;
}
function makeMalHref(username: string) {
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
}
const tracksFromResults = $derived.by(() =>
data.songRows
.map((r) =>
trackFromSongRow({
annSongId: r.annSongId,
animeName: r.animeName,
type: r.type,
number: r.number,
songName: r.songName,
artistName: songArtistLabel(r),
fileName: r.fileName,
dub: Boolean(r.dub),
rebroadcast: Boolean(r.rebroadcast),
globalPercent: r.globalPercent,
}),
)
.filter((t) => t !== null),
);
</script>
<h1 class="text-2xl font-semibold">MAL List → Songs</h1>
<p class="mt-2 text-sm text-muted-foreground">
{#if !clientDb}
Loading DB...
{/if}
</p>
<form
class="mt-4 flex flex-col gap-2"
onsubmit={(e) => {
e.preventDefault();
params.mal = formMal;
}}
>
<div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-user"
>MAL username</label
>
<input
id="mal-user"
class="rounded border px-3 py-2 text-sm"
placeholder="e.g. CaZzzer"
bind:value={formMal}
autocomplete="off"
spellcheck={false}
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-status"
>Status</label
>
<select
id="mal-status"
class="rounded border px-3 py-2 text-sm"
bind:value={params.status}
>
<option value="">All</option>
<option value="watching">Watching</option>
<option value="completed">Completed</option>
<option value="on_hold">On hold</option>
<option value="dropped">Dropped</option>
<option value="plan_to_watch">Plan to watch</option>
</select>
</div>
<div class="flex flex-col justify-end">
<button
type="submit"
class="rounded border px-3 py-2 text-sm"
disabled={!(formMal ?? "").trim()}
>
Search
</button>
</div>
</div>
<div class="text-sm text-muted-foreground">
{#if data.username}
MAL entries: {data.malResponse?.data.length ?? 0} (limited to {data.LIST_QUERY_LIMIT})
• Songs found: {data.songRows.length}
{/if}
</div>
{#if data.songRows.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
</button>
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next
</button>
{#if tracksFromResults.length !== data.songRows.length}
<span class="self-center text-sm text-muted-foreground">
({tracksFromResults.length} playable)
</span>
{/if}
</div>
{/if}
{#if data.username}
<div class="text-sm">
<a
class="hover:underline"
href={makeMalHref(data.username)}
target="_blank"
rel="noreferrer"
>
View {data.username} on MAL
</a>
</div>
{/if}
</form>
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) === 0}
<p class="mt-4 text-sm text-muted-foreground">
No anime returned from MAL (did you set a restrictive status?).
</p>
{/if}
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.songRows.length === 0}
<p class="mt-4 text-sm text-muted-foreground">
No songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB.
</p>
{/if}
{#if data.songRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2">
{#each data.songRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li>
<SongEntry
annSongId={r.annSongId}
animeName={r.animeName}
type={r.type}
number={r.number}
songName={r.songName}
artistName={songArtistLabel(r)}
fileName={r.fileName}
globalPercent={r.globalPercent}
dub={Boolean(r.dub)}
rebroadcast={Boolean(r.rebroadcast)}
/>
</li>
{/each}
</ul>
{/if}
{#if browser && data.malResponse?.paging?.next}
<p class="mt-6 text-sm text-muted-foreground">
More results exist on MAL, but pagination is not wired yet.
</p>
{/if}
{#if !browser}
Loading stuff...
{/if}

100
src/routes/mal/+page.ts Normal file
View File

@@ -0,0 +1,100 @@
import { z } from "zod";
// Import client-db index directly as requested.
// On the server, `db` will be null (because `browser` is false in that module).
import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
import {
MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageLoad } from "./$types";
const LIST_QUERY_LIMIT = 1000;
const SearchSchema = MalAnimeListQuerySchema.extend({
// Username
mal: z.string().optional(),
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).optional(),
}).strict();
type StatusParam = z.infer<typeof SearchSchema>["status"];
function normalizeStatus(
status: StatusParam,
): z.infer<typeof MalAnimeListStatusEnum> | undefined {
if (status == null || status === "") return undefined;
return status;
}
export const load: PageLoad = async ({ url, fetch, depends }) => {
depends("mal:animelist");
depends("clientdb:songs");
const parsed = SearchSchema.safeParse(
Object.fromEntries(url.searchParams.entries()),
);
const mal = parsed.success ? parsed.data.mal : undefined;
const status = parsed.success
? normalizeStatus(parsed.data.status)
: undefined;
const username = (mal ?? "").trim();
// Always return a stable shape for hydration
if (!username) {
return {
LIST_QUERY_LIMIT,
username: "",
status: status ?? null,
malResponse: null as z.infer<typeof MalAnimeListResponseSchema> | null,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// This endpoint proxies MAL and works server-side.
const malUrl = new URL(
`/api/mal/animelist/${encodeURIComponent(username)}`,
url.origin,
);
malUrl.searchParams.set("limit", String(LIST_QUERY_LIMIT));
if (status) malUrl.searchParams.set("status", status);
// NOTE: If you later want to support sort/offset, add them here from SearchSchema too.
const malRes = await fetch(malUrl);
if (!malRes.ok) {
// Let +page.svelte decide how to display errors; throw to use SvelteKit error page
throw new Error(`MAL request failed (${malRes.status})`);
}
const malJson: unknown = await malRes.json();
const malResponse = MalAnimeListResponseSchema.parse(malJson);
// Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser.
if (!db) {
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// Browser path: seed then query local DB for songs by MAL ids
await ensureSeeded({ fetch });
const malIds = malResponse.data.map((e) => e.node.id);
const songRows = await getSongsForMalAnimeIds(db, malIds);
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows,
};
};

View File

@@ -5,19 +5,17 @@
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 { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { ChipGroup } from "$lib/components/ui/chip-group";
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 {
NativeSelect,
NativeSelectOption,
} from "$lib/components/ui/native-select";
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 type { PageData } from "./$types"; import type { PageData } from "./$types";
import { SearchParamsSchemaClient } from "./schema"; import { SearchParamsSchema } from "./schema";
const params = useSearchParams(SearchParamsSchemaClient, { const params = useSearchParams(SearchParamsSchema, {
pushHistory: false, pushHistory: false,
showDefaults: false, showDefaults: false,
}); });
@@ -51,6 +49,9 @@
songName: r.songName, songName: r.songName,
artistName: songArtistLabel(r), artistName: songArtistLabel(r),
fileName: r.fileName, fileName: r.fileName,
dub: Boolean(r.dub),
rebroadcast: Boolean(r.rebroadcast),
globalPercent: r.globalPercent,
}), }),
) )
.filter((t) => t !== null), .filter((t) => t !== null),
@@ -122,15 +123,14 @@
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-2"> <ChipGroup
<Label for="song-type">Song Type</Label> label="Song Type"
<NativeSelect id="song-type" bind:value={params.songType}> items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
<NativeSelectOption value="0">All</NativeSelectOption> label: type,
<NativeSelectOption value="1">OP</NativeSelectOption> value: AmqSongLinkTypeMap[type as keyof typeof AmqSongLinkTypeMap],
<NativeSelectOption value="2">ED</NativeSelectOption> }))}
<NativeSelectOption value="3">INS</NativeSelectOption> bind:value={params.type}
</NativeSelect> />
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Label for="songs-limit">Limit</Label> <Label for="songs-limit">Limit</Label>
<Input <Input
@@ -140,7 +140,7 @@
max="200" max="200"
step="20" step="20"
class="w-1/2" class="w-1/2"
bind:value={params.songsLimit} bind:value={params.limit}
/> />
</div> </div>
</div> </div>
@@ -152,7 +152,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
@@ -161,7 +161,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
@@ -191,6 +191,8 @@
artistName={songArtistLabel(r)} artistName={songArtistLabel(r)}
fileName={r.fileName} fileName={r.fileName}
globalPercent={r.globalPercent} globalPercent={r.globalPercent}
dub={Boolean(r.dub)}
rebroadcast={Boolean(r.rebroadcast)}
/> />
</li> </li>
{/each} {/each}

View File

@@ -1,12 +1,12 @@
import type { SongFilters } from "$lib/db/client-db"; import type { SongFilters } from "$lib/db/client-db";
import { db, ensureSeeded, getSongsWithFilters } from "$lib/db/client-db"; import { db, ensureSeeded, getSongsWithFilters } from "$lib/db/client-db";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { SearchParamsSchemaServer } from "./schema"; import { SearchParamsSchema } from "./schema";
export const load: PageLoad = async ({ url, fetch, depends }) => { export const load: PageLoad = async ({ url, fetch, depends }) => {
depends("clientdb:songs"); depends("clientdb:songs");
const parsed = SearchParamsSchemaServer.safeParse( const parsed = SearchParamsSchema.safeParse(
Object.fromEntries(url.searchParams.entries()), Object.fromEntries(url.searchParams.entries()),
); );
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ url, fetch, depends }) => {
filters.globalPercentMin = parsed.data.gpm; filters.globalPercentMin = parsed.data.gpm;
if (parsed.data.gpx !== undefined) if (parsed.data.gpx !== undefined)
filters.globalPercentMax = parsed.data.gpx; filters.globalPercentMax = parsed.data.gpx;
if (parsed.data.songType) filters.songTypes = [parsed.data.songType]; if (parsed.data.type) filters.songTypes = parsed.data.type;
} }
// Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser. // Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser.
@@ -32,11 +32,7 @@ export const load: PageLoad = async ({ url, fetch, depends }) => {
await ensureSeeded({ fetch }); await ensureSeeded({ fetch });
const songRows = await getSongsWithFilters( const songRows = await getSongsWithFilters(db, filters, parsed.data?.limit);
db,
filters,
parsed.data?.songsLimit,
);
return { return {
filters: parsed.success ? parsed.data : {}, // Return original parsed data for form state filters: parsed.success ? parsed.data : {}, // Return original parsed data for form state

View File

@@ -1,18 +1,33 @@
import { z } from "zod"; import { z } from "zod";
import {
AmqSongLinkType,
AmqSongLinkTypeMap,
AmqSongLinkTypeMapReverse,
} from "$lib/types/amq";
const SEP = ",";
const songTypesCodec = z.codec(z.string(), z.array(AmqSongLinkType), {
decode: (str) =>
str
? decodeURIComponent(str)
.split(SEP)
.map((s) => AmqSongLinkTypeMap[s as keyof typeof AmqSongLinkTypeMap])
: [],
encode: (arr) =>
arr
? encodeURIComponent(
arr.map((a) => AmqSongLinkTypeMapReverse[a]).join(SEP),
)
: "",
});
export const SearchParamsSchema = z.object({ export const SearchParamsSchema = z.object({
songsLimit: z.coerce.number().int().default(20), limit: z.coerce.number().int().default(20),
song: z.string().optional().default(""), song: z.string().optional().default(""),
artist: z.string().optional().default(""), artist: z.string().optional().default(""),
anime: z.string().optional().default(""), anime: z.string().optional().default(""),
gpm: z.coerce.number().int().optional().default(0), gpm: z.coerce.number().int().optional().default(0),
gpx: z.coerce.number().int().optional().default(100), gpx: z.coerce.number().int().optional().default(100),
}); type: songTypesCodec.default([]),
export const SearchParamsSchemaClient = SearchParamsSchema.extend({
songType: z.string().optional().default("0"),
});
export const SearchParamsSchemaServer = SearchParamsSchema.extend({
songType: z.coerce.number().int().optional(),
}); });

View File

@@ -5,4 +5,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit(), sqlocal()], plugins: [tailwindcss(), sveltekit(), sqlocal()],
server: {
allowedHosts: ["cazzzer.pgrok.cazzzer.com"],
},
}); });