Compare commits
45 Commits
tomfoolery
...
ui/player
| Author | SHA1 | Date | |
|---|---|---|---|
| b37eef8f31 | |||
| a144baba2b | |||
| 21d62f8c6f | |||
| f90cf66cc1 | |||
| 1a3ec7d84e | |||
| 7dc37d9eb7 | |||
| ec3565078f | |||
| e3c0c6cade | |||
|
28643c38b8
|
|||
|
3944cf6ff4
|
|||
|
6fca5bae39
|
|||
|
cd443b974b
|
|||
|
31414c5874
|
|||
|
da3ab81ae6
|
|||
|
7e3a22f14b
|
|||
|
86827777f4
|
|||
|
5e7534af35
|
|||
|
11a4239fdc
|
|||
|
cdd90016f8
|
|||
|
864a11fac4
|
|||
|
48e5719813
|
|||
|
e0d6e5bc32
|
|||
|
fc0f04f29c
|
|||
|
9e83b72139
|
|||
|
f47f6e6cd8
|
|||
|
197b9267c4
|
|||
|
ed9fcbe116
|
|||
|
9f0234e00e
|
|||
|
c8220eec02
|
|||
|
99e6fd8eb4
|
|||
|
57320232a6
|
|||
|
892f43381b
|
|||
|
bfae55afa3
|
|||
|
c8de948e7f
|
|||
|
cfd45b6815
|
|||
|
4fb1d7865f
|
|||
|
a4cf9356a8
|
|||
|
f9fe6a2d11
|
|||
|
aea41df214
|
|||
|
9126e34f38
|
|||
|
4e680c6697
|
|||
|
28d6231f21
|
|||
|
a9008627a1
|
|||
|
72ee0260d9
|
|||
|
2bf5aeb1c0
|
7
bun.lock
7
bun.lock
@@ -29,6 +29,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.62.0",
|
||||
"zod": "^4.3.6",
|
||||
@@ -601,6 +602,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "bun run build && wrangler dev",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:import": "bun run src/lib/db/import-amq.ts",
|
||||
"format": "biome check --write",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
@@ -40,6 +41,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.62.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
22
src/app.html
22
src/app.html
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="flex min-h-screen flex-col items-center gap-8 p-4 max-sm:px-2"
|
||||
>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" class="flex min-h-screen flex-col items-center gap-8">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
src/lib/assets/artwork.jpg
Normal file
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
@@ -6,14 +6,9 @@
|
||||
SkipForward,
|
||||
Trash2,
|
||||
} from "@lucide/svelte";
|
||||
import {
|
||||
addToQueue,
|
||||
hasTrack,
|
||||
play,
|
||||
playNext,
|
||||
removeTrack,
|
||||
} from "$lib/player/player.svelte";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import { type SongType, trackFromSongRow } from "$lib/player/types";
|
||||
import { songTypeNumberLabel } from "$lib/utils/amq";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
type SongEntryProps = {
|
||||
@@ -24,7 +19,9 @@
|
||||
songName: string;
|
||||
artistName: string | null;
|
||||
fileName?: string | null;
|
||||
globalPercent: number | null;
|
||||
globalPercent: number;
|
||||
dub: boolean;
|
||||
rebroadcast: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -36,16 +33,11 @@
|
||||
artistName,
|
||||
fileName = null,
|
||||
globalPercent,
|
||||
dub,
|
||||
rebroadcast,
|
||||
}: SongEntryProps = $props();
|
||||
|
||||
const typeLabelMap: Record<number, string> = {
|
||||
1: "OP",
|
||||
2: "ED",
|
||||
3: "INS",
|
||||
};
|
||||
|
||||
const typeLabel = $derived(typeLabelMap[type] ?? `T${type}`);
|
||||
const displayTypeNumber = $derived(`${typeLabel}${number || ""}`);
|
||||
const displayTypeNumber = $derived(songTypeNumberLabel(type, number));
|
||||
|
||||
const artistDisplay = $derived.by(
|
||||
() => artistName?.trim() || "Unknown Artist",
|
||||
@@ -60,10 +52,13 @@
|
||||
songName,
|
||||
artistName,
|
||||
fileName,
|
||||
dub,
|
||||
rebroadcast,
|
||||
globalPercent,
|
||||
}),
|
||||
);
|
||||
|
||||
const isQueued = $derived(hasTrack(annSongId));
|
||||
const isQueued = $derived(player.hasTrack(annSongId));
|
||||
|
||||
function requestGlobalAutoplay() {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -91,12 +86,16 @@
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-wrap w-fit items-baseline gap-x-2 gap-y-1">
|
||||
{animeName}
|
||||
<span class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
|
||||
>{displayTypeNumber}</span
|
||||
>
|
||||
<span class=" text-muted-foreground">
|
||||
<span class="tag">{displayTypeNumber}</span>
|
||||
<span class="text-muted-foreground">
|
||||
{globalPercent}%
|
||||
</span>
|
||||
{#if dub}
|
||||
<span title="Dub" class="tag">DUB</span>
|
||||
{/if}
|
||||
{#if rebroadcast}
|
||||
<span title="Rebroadcast" class="tag">RB</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-1 w-fit text-foreground/80">
|
||||
@@ -115,7 +114,7 @@
|
||||
class="btn-icon"
|
||||
title="Remove from queue"
|
||||
aria-label="Remove from queue"
|
||||
onclick={() => removeTrack(annSongId)}
|
||||
onclick={() => player.remove(annSongId)}
|
||||
>
|
||||
<Trash2 class="icon-btn" />
|
||||
</button>
|
||||
@@ -128,7 +127,7 @@
|
||||
aria-label="Play"
|
||||
onclick={() => {
|
||||
if (!track) return;
|
||||
play(track);
|
||||
player.add(track, true);
|
||||
requestGlobalAutoplay();
|
||||
}}
|
||||
>
|
||||
@@ -143,7 +142,7 @@
|
||||
aria-label="Play next"
|
||||
onclick={() => {
|
||||
if (!track) return;
|
||||
playNext(track);
|
||||
player.playNext(track);
|
||||
requestGlobalAutoplay();
|
||||
}}
|
||||
>
|
||||
@@ -158,7 +157,7 @@
|
||||
aria-label="Add to queue"
|
||||
onclick={() => {
|
||||
if (!track) return;
|
||||
addToQueue(track);
|
||||
player.add(track);
|
||||
}}
|
||||
>
|
||||
<ListPlus class="icon-btn" />
|
||||
|
||||
@@ -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>
|
||||
2
src/lib/components/inputs/anime-list-input/index.ts
Normal file
2
src/lib/components/inputs/anime-list-input/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AnimeListInput } from "./AnimeListInput.svelte";
|
||||
export * from "./schema";
|
||||
16
src/lib/components/inputs/anime-list-input/schema.ts
Normal file
16
src/lib/components/inputs/anime-list-input/schema.ts
Normal 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)}`),
|
||||
});
|
||||
97
src/lib/components/player/Controls.svelte
Normal file
97
src/lib/components/player/Controls.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
} from "@lucide/svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import { getAudioContext } from "./ctx.svelte";
|
||||
|
||||
let audio = getAudioContext();
|
||||
|
||||
// Derived state for icons/labels
|
||||
let isPlaying = $derived(!audio.paused);
|
||||
let shuffleMode = $derived(player.isShuffled);
|
||||
let repeatMode = $derived(player.repeatMode);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Shuffle -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={shuffleMode
|
||||
? "text-primary hover:bg-primary/20 hover:text-primary"
|
||||
: "text-muted-foreground"}
|
||||
onclick={() => player.toggleShuffle()}
|
||||
title="Toggle Shuffle"
|
||||
>
|
||||
<Shuffle class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Prev -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
if (audio.currentTime > 3) {
|
||||
audio.seek(0);
|
||||
} else {
|
||||
player.prev();
|
||||
}
|
||||
}}
|
||||
disabled={player.history.length <= 1 && audio.currentTime <= 3}
|
||||
title="Previous"
|
||||
>
|
||||
<SkipBack class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<!-- Play/Pause -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-10 w-10 rounded-full"
|
||||
onclick={() => audio.toggle()}
|
||||
disabled={!player.currentTrack}
|
||||
title={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<Pause class="h-5 w-5" />
|
||||
{:else}
|
||||
<Play class="h-5 w-5 ml-0.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<!-- Next -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => player.next()}
|
||||
disabled={player.queue.length === 0}
|
||||
title="Next"
|
||||
>
|
||||
<SkipForward class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<!-- Repeat -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={repeatMode !== "off"
|
||||
? "text-primary hover:bg-primary/20 hover:text-primary"
|
||||
: "text-muted-foreground"}
|
||||
onclick={() => player.toggleRepeat()}
|
||||
title="Toggle Repeat"
|
||||
>
|
||||
{#if repeatMode === "one"}
|
||||
<Repeat1 class="h-4 w-4" />
|
||||
{:else}
|
||||
<Repeat class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
117
src/lib/components/player/PlayerDesktop.svelte
Normal file
117
src/lib/components/player/PlayerDesktop.svelte
Normal 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>
|
||||
116
src/lib/components/player/PlayerMobile.svelte
Normal file
116
src/lib/components/player/PlayerMobile.svelte
Normal 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>
|
||||
117
src/lib/components/player/PlayerRoot.svelte
Normal file
117
src/lib/components/player/PlayerRoot.svelte
Normal 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>
|
||||
202
src/lib/components/player/Queue.svelte
Normal file
202
src/lib/components/player/Queue.svelte
Normal 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>
|
||||
46
src/lib/components/player/ctx.svelte.ts
Normal file
46
src/lib/components/player/ctx.svelte.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
|
||||
const AUDIO_CTX_KEY = "amqtrain:player:audio-ctx";
|
||||
|
||||
export class AudioContext {
|
||||
currentTime = $state(0);
|
||||
duration = $state(0);
|
||||
paused = $state(true);
|
||||
|
||||
private audioEl: HTMLAudioElement | null = null;
|
||||
|
||||
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);
|
||||
}
|
||||
7
src/lib/components/player/utils.ts
Normal file
7
src/lib/components/player/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function formatTime(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const s = Math.floor(seconds);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, "0")}`;
|
||||
}
|
||||
109
src/lib/components/ui/VirtualList.svelte
Normal file
109
src/lib/components/ui/VirtualList.svelte
Normal 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>
|
||||
45
src/lib/components/ui/chip-group/chip-group.svelte
Normal file
45
src/lib/components/ui/chip-group/chip-group.svelte
Normal 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>
|
||||
1
src/lib/components/ui/chip-group/index.ts
Normal file
1
src/lib/components/ui/chip-group/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ChipGroup } from "./chip-group.svelte";
|
||||
7
src/lib/components/ui/dialog/dialog-close.svelte
Normal file
7
src/lib/components/ui/dialog/dialog-close.svelte
Normal 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} />
|
||||
45
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
17
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
17
src/lib/components/ui/dialog/dialog-description.svelte
Normal 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}
|
||||
/>
|
||||
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
20
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
7
src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
src/lib/components/ui/dialog/dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
7
src/lib/components/ui/dialog/dialog-trigger.svelte
Normal 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} />
|
||||
7
src/lib/components/ui/dialog/dialog.svelte
Normal file
7
src/lib/components/ui/dialog/dialog.svelte
Normal 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} />
|
||||
34
src/lib/components/ui/dialog/index.ts
Normal file
34
src/lib/components/ui/dialog/index.ts
Normal 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,
|
||||
};
|
||||
7
src/lib/components/ui/drawer/drawer-close.svelte
Normal file
7
src/lib/components/ui/drawer/drawer-close.svelte
Normal 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} />
|
||||
40
src/lib/components/ui/drawer/drawer-content.svelte
Normal file
40
src/lib/components/ui/drawer/drawer-content.svelte
Normal 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>
|
||||
17
src/lib/components/ui/drawer/drawer-description.svelte
Normal file
17
src/lib/components/ui/drawer/drawer-description.svelte
Normal 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}
|
||||
/>
|
||||
20
src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
20
src/lib/components/ui/drawer/drawer-footer.svelte
Normal 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>
|
||||
20
src/lib/components/ui/drawer/drawer-header.svelte
Normal file
20
src/lib/components/ui/drawer/drawer-header.svelte
Normal 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>
|
||||
12
src/lib/components/ui/drawer/drawer-nested.svelte
Normal file
12
src/lib/components/ui/drawer/drawer-nested.svelte
Normal 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} />
|
||||
20
src/lib/components/ui/drawer/drawer-overlay.svelte
Normal file
20
src/lib/components/ui/drawer/drawer-overlay.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/drawer/drawer-portal.svelte
Normal file
7
src/lib/components/ui/drawer/drawer-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Portal {...restProps} />
|
||||
17
src/lib/components/ui/drawer/drawer-title.svelte
Normal file
17
src/lib/components/ui/drawer/drawer-title.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/drawer/drawer-trigger.svelte
Normal file
7
src/lib/components/ui/drawer/drawer-trigger.svelte
Normal 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} />
|
||||
12
src/lib/components/ui/drawer/drawer.svelte
Normal file
12
src/lib/components/ui/drawer/drawer.svelte
Normal 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} />
|
||||
38
src/lib/components/ui/drawer/index.ts
Normal file
38
src/lib/components/ui/drawer/index.ts
Normal 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,
|
||||
};
|
||||
@@ -12,9 +12,6 @@
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"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
|
||||
)}
|
||||
class={cn("scn-label", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -118,6 +118,8 @@ export async function getAnimeWithSongsByAnnId(db: ClientDb, annId: number) {
|
||||
annSongId: animeSongLinks.annSongId,
|
||||
type: animeSongLinks.type,
|
||||
number: animeSongLinks.number,
|
||||
dub: animeSongLinks.dub,
|
||||
rebroadcast: animeSongLinks.rebroadcast,
|
||||
|
||||
songName: songs.name,
|
||||
fileName: songs.fileName,
|
||||
@@ -139,6 +141,8 @@ export async function getAnimeWithSongsByAnnId(db: ClientDb, annId: number) {
|
||||
annSongId: r.annSongId,
|
||||
type: r.type,
|
||||
number: r.number,
|
||||
dub: r.dub,
|
||||
rebroadcast: r.rebroadcast,
|
||||
songName: r.songName,
|
||||
fileName: r.fileName,
|
||||
globalPercent: r.globalPercent,
|
||||
@@ -173,6 +177,8 @@ export async function getSongsForMalAnimeIds(
|
||||
annSongId: animeSongLinks.annSongId,
|
||||
type: animeSongLinks.type,
|
||||
number: animeSongLinks.number,
|
||||
dub: animeSongLinks.dub,
|
||||
rebroadcast: animeSongLinks.rebroadcast,
|
||||
|
||||
songName: songs.name,
|
||||
fileName: songs.fileName,
|
||||
@@ -233,6 +239,8 @@ export async function getSongsWithFilters(
|
||||
|
||||
type: animeSongLinks.type,
|
||||
number: animeSongLinks.number,
|
||||
dub: animeSongLinks.dub,
|
||||
rebroadcast: animeSongLinks.rebroadcast,
|
||||
|
||||
animeAnnId: anime.annId,
|
||||
animeMainName: anime.mainName,
|
||||
|
||||
@@ -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
|
||||
* 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 seededStorageKey = (version: number) => `amq.sqlocal.seeded.v${version}`;
|
||||
|
||||
@@ -90,7 +90,7 @@ function zodErrorSummary(prefix: string, err: z.ZodError): string {
|
||||
return `${prefix}\n${lines.join("\n")}${more}`;
|
||||
}
|
||||
|
||||
function categoryNumberToText(
|
||||
function _categoryNumberToText(
|
||||
v: number | string | null | undefined,
|
||||
): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
@@ -375,7 +375,7 @@ export async function importAmqData(
|
||||
malId: a.malId,
|
||||
kitsuId: a.kitsuId,
|
||||
categoryName: a.category.name,
|
||||
categoryNumber: categoryNumberToText(a.category.number),
|
||||
categoryNumber: a.category.number,
|
||||
mainName: a.mainName,
|
||||
mainNameEn: a.mainNames.EN,
|
||||
mainNameJa: a.mainNames.JA,
|
||||
|
||||
@@ -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.
|
||||
@@ -21,7 +27,7 @@ export const anime = sqliteTable(
|
||||
|
||||
// Category object (name + number that can be number|string|null in source)
|
||||
categoryName: text("category_name").notNull(),
|
||||
categoryNumber: text("category_number"),
|
||||
categoryNumber: real("category_number"),
|
||||
|
||||
// Names
|
||||
mainName: text("main_name").notNull(),
|
||||
|
||||
@@ -37,7 +37,7 @@ export const songs = sqliteTable(
|
||||
fileName: text("file_name"),
|
||||
fileName480: text("file_name_480"),
|
||||
fileName720: text("file_name_720"),
|
||||
globalPercent: integer("global_percent"),
|
||||
globalPercent: integer("global_percent").notNull(),
|
||||
meanVolume: real("mean_volume"),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { browser } from "$app/environment";
|
||||
import type { Track } from "./types";
|
||||
|
||||
export type MediaSessionHandlers = {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
seekTo?: (timeSeconds: number) => void;
|
||||
seekBy?: (deltaSeconds: number) => void;
|
||||
};
|
||||
|
||||
export type MediaSessionBindings = {
|
||||
/**
|
||||
* Call whenever the current track changes.
|
||||
*/
|
||||
setTrack: (track: Track | null) => void;
|
||||
|
||||
/**
|
||||
* Call on play/pause changes if you want to keep Media Session "active" state
|
||||
* aligned with your app (handlers still work regardless).
|
||||
*/
|
||||
setPlaybackState: (state: "none" | "paused" | "playing") => void;
|
||||
|
||||
/**
|
||||
* Call reasonably often (e.g. on `timeupdate`, `loadedmetadata`, `ratechange`)
|
||||
* to keep lockscreen / OS UI in sync.
|
||||
*/
|
||||
updatePositionState: (args: {
|
||||
duration: number;
|
||||
position: number;
|
||||
playbackRate?: number;
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Unregisters handlers. Optional — layout-scoped players typically never unmount.
|
||||
*/
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
function canUseMediaSession() {
|
||||
return (
|
||||
browser &&
|
||||
typeof navigator !== "undefined" &&
|
||||
"mediaSession" in navigator &&
|
||||
typeof (navigator as Navigator).mediaSession !== "undefined"
|
||||
);
|
||||
}
|
||||
|
||||
function canUseMetadata() {
|
||||
return typeof MediaMetadata !== "undefined";
|
||||
}
|
||||
|
||||
export function createMediaSessionBindings(
|
||||
handlers: MediaSessionHandlers,
|
||||
): MediaSessionBindings {
|
||||
const mediaSession = canUseMediaSession()
|
||||
? (navigator as Navigator).mediaSession
|
||||
: null;
|
||||
|
||||
const setActionHandler = (
|
||||
action: MediaSessionAction,
|
||||
handler: MediaSessionActionHandler | null,
|
||||
) => {
|
||||
if (!mediaSession) return;
|
||||
try {
|
||||
mediaSession.setActionHandler(action, handler);
|
||||
} catch {
|
||||
// Some browsers throw for unsupported actions; ignore.
|
||||
}
|
||||
};
|
||||
|
||||
const safeNumber = (n: number) => (Number.isFinite(n) ? n : 0);
|
||||
|
||||
const setTrack = (track: Track | null) => {
|
||||
if (!mediaSession) return;
|
||||
if (!canUseMetadata()) return;
|
||||
|
||||
if (!track) {
|
||||
// Keep it simple: clear metadata.
|
||||
mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
|
||||
function typeNumberLabel(t: Track) {
|
||||
const type = t.type;
|
||||
const n = Number(t.number ?? 0);
|
||||
|
||||
let typeLabel: string | null = null;
|
||||
if (typeof type === "number") {
|
||||
if (type === 1) typeLabel = "OP";
|
||||
else if (type === 2) typeLabel = "ED";
|
||||
else if (type === 3) typeLabel = "INS";
|
||||
else typeLabel = `T${type}`;
|
||||
}
|
||||
|
||||
if (!typeLabel) return null;
|
||||
return `${typeLabel}${n ? String(n) : ""}`;
|
||||
}
|
||||
mediaSession.metadata = new MediaMetadata({
|
||||
title: `${track.animeName} (${typeNumberLabel(track)}) — ${track.title}`,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
// You can add artwork later if/when you have it:
|
||||
// artwork: [{ src: "/some.png", sizes: "512x512", type: "image/png" }]
|
||||
});
|
||||
};
|
||||
|
||||
const setPlaybackState = (state: "none" | "paused" | "playing") => {
|
||||
if (!mediaSession) return;
|
||||
try {
|
||||
mediaSession.playbackState = state;
|
||||
} catch {
|
||||
// Some browsers may not implement playbackState; ignore.
|
||||
}
|
||||
};
|
||||
|
||||
const updatePositionState = (args: {
|
||||
duration: number;
|
||||
position: number;
|
||||
playbackRate?: number;
|
||||
}) => {
|
||||
if (!mediaSession) return;
|
||||
|
||||
const anySession = mediaSession as unknown as {
|
||||
setPositionState?: (state: {
|
||||
duration: number;
|
||||
playbackRate?: number;
|
||||
position: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
if (typeof anySession.setPositionState !== "function") return;
|
||||
|
||||
const duration = Math.max(0, safeNumber(args.duration));
|
||||
const position = Math.max(0, safeNumber(args.position));
|
||||
const playbackRate = args.playbackRate ?? 1;
|
||||
|
||||
try {
|
||||
anySession.setPositionState({
|
||||
duration,
|
||||
position: Math.min(position, duration || position),
|
||||
playbackRate,
|
||||
});
|
||||
} catch {
|
||||
// iOS Safari and some Chromium variants can throw on invalid values.
|
||||
}
|
||||
};
|
||||
|
||||
const installHandlers = () => {
|
||||
if (!mediaSession) return;
|
||||
|
||||
setActionHandler("play", () => handlers.play());
|
||||
setActionHandler("pause", () => handlers.pause());
|
||||
setActionHandler("previoustrack", () => handlers.prev());
|
||||
setActionHandler("nexttrack", () => handlers.next());
|
||||
|
||||
// Seeking (optional)
|
||||
setActionHandler("seekto", (details) => {
|
||||
if (!handlers.seekTo) return;
|
||||
const d = details as MediaSessionActionDetails & { seekTime?: number };
|
||||
if (typeof d.seekTime !== "number") return;
|
||||
handlers.seekTo(d.seekTime);
|
||||
});
|
||||
|
||||
setActionHandler("seekbackward", (details) => {
|
||||
const d = details as MediaSessionActionDetails & { seekOffset?: number };
|
||||
const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
|
||||
if (handlers.seekBy) handlers.seekBy(-offset);
|
||||
else if (handlers.seekTo) handlers.seekTo(0); // fallback-ish
|
||||
});
|
||||
|
||||
setActionHandler("seekforward", (details) => {
|
||||
const d = details as MediaSessionActionDetails & { seekOffset?: number };
|
||||
const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
|
||||
if (handlers.seekBy) handlers.seekBy(offset);
|
||||
});
|
||||
|
||||
// Stop isn't as universally supported; map to pause.
|
||||
setActionHandler("stop", () => handlers.pause());
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (!mediaSession) return;
|
||||
// Clear handlers we set.
|
||||
setActionHandler("play", null);
|
||||
setActionHandler("pause", null);
|
||||
setActionHandler("previoustrack", null);
|
||||
setActionHandler("nexttrack", null);
|
||||
setActionHandler("seekto", null);
|
||||
setActionHandler("seekbackward", null);
|
||||
setActionHandler("seekforward", null);
|
||||
setActionHandler("stop", null);
|
||||
};
|
||||
|
||||
installHandlers();
|
||||
|
||||
return {
|
||||
setTrack,
|
||||
setPlaybackState,
|
||||
updatePositionState,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -1,232 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { browser } from "$app/environment";
|
||||
import type { Track } from "./types";
|
||||
|
||||
/**
|
||||
* Persistence for the global player.
|
||||
*
|
||||
* Persisted:
|
||||
* - queue
|
||||
* - currentIndex
|
||||
* - shuffleEnabled
|
||||
* - wrapEnabled
|
||||
* - shuffle traversal bookkeeping (order/history/cursor)
|
||||
* - volume
|
||||
* - uiOpen
|
||||
*
|
||||
* Not persisted by design:
|
||||
* - currentTime / playback position
|
||||
* - isPlaying (we always restore paused)
|
||||
*/
|
||||
const STORAGE_KEY = "amqtrain:player:v2";
|
||||
|
||||
const STORAGE_KEY = "amqtrain:player:v1";
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
const TrackSchema = z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
src: z.string().min(1),
|
||||
title: z.string().default(""),
|
||||
artist: z.string().default(""),
|
||||
album: z.string().default(""),
|
||||
|
||||
animeName: z.string().optional(),
|
||||
type: z.number().optional(),
|
||||
number: z.number().optional(),
|
||||
fileName: z.string().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PersistedSnapshotSchema = z
|
||||
.object({
|
||||
version: z.literal(STORAGE_VERSION),
|
||||
|
||||
queue: z.array(TrackSchema).default([]),
|
||||
currentIndex: z.number().int().nullable().default(null),
|
||||
|
||||
shuffleEnabled: z.boolean().default(false),
|
||||
wrapEnabled: z.boolean().default(false),
|
||||
|
||||
/**
|
||||
* Shuffle traversal:
|
||||
* - order: upcoming indices into `queue` in the order they will be visited
|
||||
* - history: visited indices into `queue` in visit order
|
||||
* - cursor: index into `history` pointing at the current item
|
||||
*/
|
||||
order: z.array(z.number().int().nonnegative()).default([]),
|
||||
history: z.array(z.number().int().nonnegative()).default([]),
|
||||
cursor: z.number().int().default(0),
|
||||
|
||||
volume: z.number().min(0).max(1).default(1),
|
||||
uiOpen: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PersistedSnapshot = z.infer<typeof PersistedSnapshotSchema>;
|
||||
|
||||
export type PersistablePlayerState = {
|
||||
export type PersistedState = {
|
||||
queue: Track[];
|
||||
currentIndex: number | null;
|
||||
|
||||
shuffleEnabled: boolean;
|
||||
wrapEnabled: boolean;
|
||||
|
||||
order: number[];
|
||||
history: number[];
|
||||
cursor: number;
|
||||
|
||||
currentId: number | null;
|
||||
volume: number;
|
||||
uiOpen: boolean;
|
||||
isMuted: boolean;
|
||||
minimized: boolean;
|
||||
};
|
||||
|
||||
export function loadPersistedPlayerState(): PersistablePlayerState | null {
|
||||
export function loadState(): PersistedState | null {
|
||||
if (!browser) return null;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = PersistedSnapshotSchema.safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return null;
|
||||
|
||||
return sanitizePersistedState(parsed.data);
|
||||
} catch {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error("Failed to load player state", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePersistedPlayerState(state: PersistablePlayerState): void {
|
||||
if (!browser) return;
|
||||
|
||||
const snapshot: PersistedSnapshot = {
|
||||
version: STORAGE_VERSION,
|
||||
|
||||
queue: state.queue,
|
||||
currentIndex: state.currentIndex,
|
||||
|
||||
shuffleEnabled: state.shuffleEnabled,
|
||||
wrapEnabled: state.wrapEnabled,
|
||||
|
||||
order: state.order,
|
||||
history: state.history,
|
||||
cursor: state.cursor,
|
||||
|
||||
volume: clamp01(state.volume),
|
||||
uiOpen: state.uiOpen,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Ignore quota/security errors; persistence is a best-effort feature.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled saver (simple debounce). Call this from reactive effects.
|
||||
*/
|
||||
export function createPersistScheduler(delayMs = 250) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (state: PersistablePlayerState) => {
|
||||
if (!browser) return;
|
||||
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
savePersistedPlayerState(state);
|
||||
}, delayMs);
|
||||
};
|
||||
}
|
||||
|
||||
export function clearPersistedPlayerState(): void {
|
||||
export function saveState(state: PersistedState) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
console.error("Failed to save player state", e);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePersistedState(
|
||||
snapshot: PersistedSnapshot,
|
||||
): PersistablePlayerState {
|
||||
const queue = dedupeById(snapshot.queue);
|
||||
|
||||
const maxIndex = queue.length - 1;
|
||||
const currentIndex =
|
||||
snapshot.currentIndex == null
|
||||
? null
|
||||
: snapshot.currentIndex >= 0 && snapshot.currentIndex <= maxIndex
|
||||
? snapshot.currentIndex
|
||||
: null;
|
||||
|
||||
const order = filterValidIndices(snapshot.order, queue.length);
|
||||
const history = filterValidIndices(snapshot.history, queue.length);
|
||||
|
||||
// cursor points into history; if history is empty, cursor should be 0
|
||||
const cursor =
|
||||
history.length === 0
|
||||
? 0
|
||||
: Math.max(0, Math.min(snapshot.cursor, history.length - 1));
|
||||
|
||||
// If we have a currentIndex but history doesn't reflect it, try to repair:
|
||||
// put currentIndex at end and point cursor there.
|
||||
let repairedHistory = history;
|
||||
let repairedCursor = cursor;
|
||||
|
||||
if (currentIndex != null) {
|
||||
if (history.length === 0 || history[cursor] !== currentIndex) {
|
||||
repairedHistory = [...history, currentIndex];
|
||||
repairedCursor = repairedHistory.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure order doesn't contain items already visited up through cursor.
|
||||
const visitedSet =
|
||||
repairedHistory.length === 0
|
||||
? new Set<number>()
|
||||
: new Set(repairedHistory.slice(0, repairedCursor + 1));
|
||||
const repairedOrder = order.filter((i) => !visitedSet.has(i));
|
||||
|
||||
return {
|
||||
queue,
|
||||
currentIndex,
|
||||
|
||||
shuffleEnabled: snapshot.shuffleEnabled,
|
||||
wrapEnabled: snapshot.wrapEnabled,
|
||||
|
||||
order: repairedOrder,
|
||||
history: repairedHistory,
|
||||
cursor: repairedCursor,
|
||||
|
||||
volume: clamp01(snapshot.volume),
|
||||
uiOpen: snapshot.uiOpen,
|
||||
};
|
||||
}
|
||||
|
||||
function filterValidIndices(indices: number[], length: number) {
|
||||
const out: number[] = [];
|
||||
for (const i of indices) {
|
||||
if (Number.isInteger(i) && i >= 0 && i < length) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupeById(tracks: Track[]) {
|
||||
const seen = new Set<number>();
|
||||
const out: Track[] = [];
|
||||
for (const t of tracks) {
|
||||
const id = Number(t.id);
|
||||
if (!Number.isFinite(id)) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clamp01(n: number) {
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
@@ -1,893 +0,0 @@
|
||||
import { browser } from "$app/environment";
|
||||
import {
|
||||
createPersistScheduler,
|
||||
loadPersistedPlayerState,
|
||||
type PersistablePlayerState,
|
||||
} from "./persist";
|
||||
import {
|
||||
buildInitialShuffleOrder,
|
||||
injectNextIntoShuffleOrder,
|
||||
reindexAfterMoveOrRemove,
|
||||
} from "./shuffle";
|
||||
import type { Track } from "./types";
|
||||
|
||||
/**
|
||||
* Global audio player state + queue/shuffle actions (Svelte 5 runes).
|
||||
*
|
||||
* This module is intended to be imported from UI components and pages.
|
||||
* The actual <audio> element lives in a single GlobalPlayer component, which
|
||||
* binds to the state here and calls actions.
|
||||
*
|
||||
* Canonical dedupe id: Track.id === annSongId (number).
|
||||
*
|
||||
* NOTE: Do NOT use module-level `$effect` here — consumers (e.g. GlobalPlayer)
|
||||
* should subscribe via `subscribe()` and drive any side-effects (like persistence).
|
||||
*/
|
||||
|
||||
export type InsertMode = "play" | "playNext" | "add" | "jump";
|
||||
|
||||
export type PlayerSnapshot = {
|
||||
queue: Track[];
|
||||
currentIndex: number | null;
|
||||
|
||||
// derived-ish convenience
|
||||
currentTrack: Track | null;
|
||||
|
||||
shuffleEnabled: boolean;
|
||||
wrapEnabled: boolean;
|
||||
|
||||
// shuffle traversal
|
||||
order: number[];
|
||||
history: number[];
|
||||
cursor: number;
|
||||
|
||||
volume: number;
|
||||
|
||||
// UI
|
||||
uiOpen: boolean;
|
||||
};
|
||||
|
||||
export type PlayerSubscriber = (snapshot: PlayerSnapshot) => void;
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
const DEFAULT_VOLUME = 1;
|
||||
|
||||
function isMobileLike() {
|
||||
if (!browser) return false;
|
||||
return window.matchMedia?.("(max-width: 1023px)")?.matches ?? false;
|
||||
}
|
||||
|
||||
function clamp01(n: number) {
|
||||
if (!Number.isFinite(n)) return DEFAULT_VOLUME;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function arrayRemoveAt<T>(arr: T[], index: number) {
|
||||
if (index < 0 || index >= arr.length) return arr;
|
||||
return [...arr.slice(0, index), ...arr.slice(index + 1)];
|
||||
}
|
||||
|
||||
function arrayInsertAt<T>(arr: T[], index: number, item: T) {
|
||||
const i = Math.max(0, Math.min(index, arr.length));
|
||||
return [...arr.slice(0, i), item, ...arr.slice(i)];
|
||||
}
|
||||
|
||||
function arrayMove<T>(arr: T[], from: number, to: number) {
|
||||
if (from === to) return arr;
|
||||
if (from < 0 || from >= arr.length) return arr;
|
||||
if (to < 0 || to >= arr.length) return arr;
|
||||
|
||||
const copy = [...arr];
|
||||
const [item] = copy.splice(from, 1);
|
||||
copy.splice(to, 0, item);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function dedupeTracks(tracks: Track[]) {
|
||||
const seen = new Set<number>();
|
||||
const out: Track[] = [];
|
||||
for (const t of tracks) {
|
||||
const id = Number(t.id);
|
||||
if (!Number.isFinite(id)) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function visitedSet(history: number[], cursor: number) {
|
||||
if (history.length === 0) return new Set<number>();
|
||||
const end = Math.max(0, Math.min(cursor, history.length - 1));
|
||||
return new Set(history.slice(0, end + 1));
|
||||
}
|
||||
|
||||
function persistableState(): PersistablePlayerState {
|
||||
return {
|
||||
queue,
|
||||
currentIndex,
|
||||
|
||||
shuffleEnabled,
|
||||
wrapEnabled,
|
||||
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
|
||||
volume,
|
||||
uiOpen,
|
||||
};
|
||||
}
|
||||
|
||||
/** --- Initialize state (restore persisted if present) --- */
|
||||
|
||||
const persisted = browser ? loadPersistedPlayerState() : null;
|
||||
|
||||
let queue = $state<Track[]>(persisted?.queue ?? []);
|
||||
let currentIndex = $state<number | null>(persisted?.currentIndex ?? null);
|
||||
|
||||
let shuffleEnabled = $state<boolean>(persisted?.shuffleEnabled ?? false);
|
||||
let wrapEnabled = $state<boolean>(persisted?.wrapEnabled ?? false);
|
||||
|
||||
let order = $state<number[]>(persisted?.order ?? []);
|
||||
let history = $state<number[]>(persisted?.history ?? []);
|
||||
let cursor = $state<number>(persisted?.cursor ?? 0);
|
||||
|
||||
let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME));
|
||||
|
||||
let uiOpen = $state<boolean>(
|
||||
// Default based on the current viewport:
|
||||
// - mobile: closed
|
||||
// - desktop: open
|
||||
//
|
||||
// Note: we intentionally do NOT default from persisted `uiOpen`, so the UI
|
||||
// always follows the current device/viewport expectation.
|
||||
!isMobileLike(),
|
||||
);
|
||||
|
||||
const currentTrack = $derived<Track | null>(
|
||||
currentIndex == null ? null : (queue[currentIndex] ?? null),
|
||||
);
|
||||
|
||||
const snapshot = $derived<PlayerSnapshot>({
|
||||
queue,
|
||||
currentIndex,
|
||||
currentTrack,
|
||||
shuffleEnabled,
|
||||
wrapEnabled,
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
volume,
|
||||
uiOpen,
|
||||
});
|
||||
|
||||
/** --- Lightweight subscription API (to avoid polling) --- */
|
||||
const subscribers = new Set<PlayerSubscriber>();
|
||||
|
||||
function notifySubscribers() {
|
||||
// Read `snapshot` here so every subscriber sees a consistent value.
|
||||
const s = snapshot;
|
||||
for (const fn of subscribers) fn(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to player snapshot changes.
|
||||
*
|
||||
* - Calls `fn` immediately with the current snapshot
|
||||
* - Returns an `unsubscribe` function
|
||||
*/
|
||||
export function subscribe(fn: PlayerSubscriber): Unsubscribe {
|
||||
subscribers.add(fn);
|
||||
fn(snapshot);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscribers at the end of the current microtask.
|
||||
* This coalesces multiple mutations within the same tick into one update.
|
||||
*/
|
||||
let notifyQueued = false;
|
||||
function queueNotify() {
|
||||
if (notifyQueued) return;
|
||||
notifyQueued = true;
|
||||
queueMicrotask(() => {
|
||||
notifyQueued = false;
|
||||
notifySubscribers();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence
|
||||
*
|
||||
* Persistence must be driven from a component (e.g. GlobalPlayer) via `subscribe()`
|
||||
* to avoid orphaned module-level effects.
|
||||
*/
|
||||
const schedulePersist = createPersistScheduler(250);
|
||||
|
||||
export function schedulePersistNow(): void {
|
||||
if (!browser) return;
|
||||
schedulePersist(persistableState());
|
||||
}
|
||||
|
||||
/** --- Public reads --- */
|
||||
|
||||
export function getSnapshot(): PlayerSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function hasTrack(id: number): boolean {
|
||||
const wanted = Number(id);
|
||||
return queue.some((t) => t.id === wanted);
|
||||
}
|
||||
|
||||
export function indexOfTrack(id: number): number {
|
||||
const wanted = Number(id);
|
||||
return queue.findIndex((t) => t.id === wanted);
|
||||
}
|
||||
|
||||
/** --- Queue traversal helpers --- */
|
||||
|
||||
function ensureTraversalStateForCurrent() {
|
||||
// Ensure history/cursor reflect currentIndex when possible.
|
||||
if (currentIndex == null) {
|
||||
history = [];
|
||||
cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
history = [currentIndex];
|
||||
cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = history[cursor];
|
||||
if (cur !== currentIndex) {
|
||||
// Repair without rewriting past: append and move cursor to it.
|
||||
history = [...history, currentIndex];
|
||||
cursor = history.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildShuffleOrderPreservingPast() {
|
||||
if (!shuffleEnabled) {
|
||||
order = [];
|
||||
return;
|
||||
}
|
||||
|
||||
ensureTraversalStateForCurrent();
|
||||
|
||||
const visited = visitedSet(history, cursor);
|
||||
order = buildInitialShuffleOrder(queue.length, visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next queue index according to shuffle/linear mode.
|
||||
* Returns null if there is no next item (and wrap is disabled).
|
||||
*/
|
||||
function computeNextIndex(): number | null {
|
||||
if (queue.length === 0) return null;
|
||||
|
||||
if (currentIndex == null) return 0;
|
||||
|
||||
if (!shuffleEnabled) {
|
||||
const next = currentIndex + 1;
|
||||
if (next < queue.length) return next;
|
||||
return wrapEnabled ? 0 : null;
|
||||
}
|
||||
|
||||
ensureTraversalStateForCurrent();
|
||||
|
||||
// If user went backwards in history and presses next, prefer moving forward in history.
|
||||
if (cursor < history.length - 1) {
|
||||
return history[cursor + 1] ?? null;
|
||||
}
|
||||
|
||||
if (order.length === 0) {
|
||||
if (!wrapEnabled) return null;
|
||||
|
||||
// Wrap in shuffle mode: keep past history, but allow revisiting by
|
||||
// generating a fresh future order excluding "past" only up through cursor.
|
||||
// Since at end, visited is all history; to wrap, we treat visited as empty
|
||||
// and start a new cycle, but we MUST NOT mutate history.
|
||||
// Easiest: generate an order containing all indices except current first,
|
||||
// then inject current out.
|
||||
const all = new Set<number>();
|
||||
// visited empty for wrap cycle
|
||||
order = buildInitialShuffleOrder(queue.length, all);
|
||||
order = order.filter((i) => i !== history[cursor]);
|
||||
}
|
||||
|
||||
return order[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous queue index according to shuffle history or linear mode.
|
||||
* Returns null if there is no previous item (and wrap is disabled).
|
||||
*/
|
||||
function computePrevIndex(currentTimeSeconds = 0): number | null {
|
||||
if (queue.length === 0) return null;
|
||||
if (currentIndex == null) return null;
|
||||
|
||||
// Standard behavior: if you've listened a bit, restart track.
|
||||
if (currentTimeSeconds > 3) return currentIndex;
|
||||
|
||||
if (!shuffleEnabled) {
|
||||
const prev = currentIndex - 1;
|
||||
if (prev >= 0) return prev;
|
||||
return wrapEnabled ? queue.length - 1 : null;
|
||||
}
|
||||
|
||||
ensureTraversalStateForCurrent();
|
||||
|
||||
if (cursor > 0) return history[cursor - 1] ?? null;
|
||||
if (!wrapEnabled) return null;
|
||||
|
||||
// Wrap backwards in shuffle mode:
|
||||
// We can jump to the last item in history if it exists, otherwise pick any.
|
||||
if (history.length > 0) return history[history.length - 1] ?? null;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function applyCurrentIndex(next: number | null) {
|
||||
currentIndex = next;
|
||||
|
||||
if (next == null) {
|
||||
// stop traversal, but keep queue
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shuffleEnabled) return;
|
||||
|
||||
// Ensure traversal exists and is anchored at *this* currentIndex.
|
||||
// This is important for jump-to-index behavior in shuffle mode.
|
||||
ensureTraversalStateForCurrent();
|
||||
|
||||
// If we jumped to an index that's not reflected at the current cursor,
|
||||
// align cursor/history so that prev/next work relative to the jumped item.
|
||||
// Strategy:
|
||||
// - if next is already somewhere in history, move cursor there
|
||||
// - otherwise, append it and set cursor to the end
|
||||
const existingPos = history.indexOf(next);
|
||||
if (existingPos !== -1) {
|
||||
cursor = existingPos;
|
||||
} else if (history[cursor] !== next) {
|
||||
history = [...history, next];
|
||||
cursor = history.length - 1;
|
||||
}
|
||||
|
||||
// If this index was scheduled in the future order, remove it so we don't revisit.
|
||||
order = order.filter((i) => i !== next);
|
||||
|
||||
// If it was at the head, that's implicitly consumed as well.
|
||||
if (order[0] === next) order = order.slice(1);
|
||||
}
|
||||
|
||||
/** --- Public traversal actions --- */
|
||||
|
||||
export function next(): void {
|
||||
const idx = computeNextIndex();
|
||||
if (idx == null) {
|
||||
currentIndex = null;
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
applyCurrentIndex(idx);
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function prev(currentTimeSeconds = 0): void {
|
||||
const idx = computePrevIndex(currentTimeSeconds);
|
||||
if (idx == null) return;
|
||||
|
||||
// If idx === currentIndex, we interpret that as "restart track"
|
||||
applyCurrentIndex(idx);
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
/** Jump to an existing queued track by id (does not reorder). */
|
||||
export function jumpToTrack(id: number): void {
|
||||
const i = indexOfTrack(id);
|
||||
if (i === -1) return;
|
||||
applyCurrentIndex(i);
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
/** --- Queue mutation primitives (keep traversal state consistent) --- */
|
||||
|
||||
function removeAt(index: number) {
|
||||
if (index < 0 || index >= queue.length) return;
|
||||
|
||||
queue = arrayRemoveAt(queue, index);
|
||||
|
||||
// Fix currentIndex
|
||||
if (currentIndex != null) {
|
||||
if (currentIndex === index) {
|
||||
// Removing current track -> advance to next if possible else stop
|
||||
currentIndex = null;
|
||||
} else if (currentIndex > index) {
|
||||
currentIndex = currentIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (shuffleEnabled) {
|
||||
const re = reindexAfterMoveOrRemove({
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
currentIndex,
|
||||
fromIndex: index,
|
||||
toIndex: null,
|
||||
});
|
||||
order = re.order;
|
||||
history = re.history;
|
||||
cursor = re.cursor;
|
||||
currentIndex = re.currentIndex;
|
||||
}
|
||||
|
||||
// If current was removed, attempt to advance
|
||||
if (currentIndex == null && queue.length > 0) {
|
||||
const idx = computeNextIndex();
|
||||
if (idx != null) applyCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function moveIndex(from: number, to: number) {
|
||||
if (from === to) return;
|
||||
if (from < 0 || from >= queue.length) return;
|
||||
if (to < 0 || to >= queue.length) return;
|
||||
|
||||
queue = arrayMove(queue, from, to);
|
||||
|
||||
// Fix currentIndex (linear)
|
||||
if (currentIndex != null) {
|
||||
if (currentIndex === from) currentIndex = to;
|
||||
else if (to < from) {
|
||||
// moved earlier
|
||||
if (currentIndex >= to && currentIndex < from) currentIndex += 1;
|
||||
} else {
|
||||
// moved later
|
||||
if (currentIndex > from && currentIndex <= to) currentIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (shuffleEnabled) {
|
||||
const re = reindexAfterMoveOrRemove({
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
currentIndex,
|
||||
fromIndex: from,
|
||||
toIndex: to,
|
||||
});
|
||||
order = re.order;
|
||||
history = re.history;
|
||||
cursor = re.cursor;
|
||||
currentIndex = re.currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert a new track (that is NOT currently in queue) at index. */
|
||||
function insertNewAt(index: number, track: Track) {
|
||||
queue = arrayInsertAt(queue, index, track);
|
||||
|
||||
// Fix currentIndex if insertion occurs before/at current
|
||||
if (currentIndex != null && index <= currentIndex) {
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
if (!shuffleEnabled) return;
|
||||
|
||||
// When shuffle is enabled, new item should be eligible for future play.
|
||||
// We do not touch past history.
|
||||
ensureTraversalStateForCurrent();
|
||||
|
||||
// We inserted at `index`, which shifts all existing indices >= index by +1.
|
||||
// Reindex traversal state for a conceptual move "fromIndex = -1" doesn't fit,
|
||||
// so perform a manual shift.
|
||||
const shiftUp = (i: number) => (i >= index ? i + 1 : i);
|
||||
|
||||
history = history.map(shiftUp);
|
||||
order = order.map(shiftUp);
|
||||
|
||||
// New track is at `index`. By default it should appear in the remaining order.
|
||||
// We'll append; specific actions (play/playNext) will inject it as needed.
|
||||
order = [...order, index];
|
||||
}
|
||||
|
||||
/** --- Public queue actions --- */
|
||||
|
||||
export function clearQueue(): void {
|
||||
queue = [];
|
||||
currentIndex = null;
|
||||
|
||||
order = [];
|
||||
history = [];
|
||||
cursor = 0;
|
||||
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function removeTrack(id: number): void {
|
||||
const i = indexOfTrack(id);
|
||||
if (i === -1) return;
|
||||
removeAt(i);
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder an existing queued track by id.
|
||||
*
|
||||
* Semantics depend on shuffle:
|
||||
* - Linear (shuffle off): reorder the underlying `queue` and adjust `currentIndex`.
|
||||
* - Shuffle (shuffle on): reorder the UPCOMING play order (the `order` list), not the
|
||||
* underlying `queue`, because the UI is presenting a shuffled traversal that users
|
||||
* expect to be able to rearrange.
|
||||
*
|
||||
* In shuffle mode this only affects tracks that are still in the future schedule
|
||||
* (`order`). Already-played history is left intact.
|
||||
*
|
||||
* Notes:
|
||||
* - `toIndex` is a queue index when shuffle is OFF (destination position in `queue`).
|
||||
* - `toIndex` is a queue index when shuffle is ON too, but it is interpreted as
|
||||
* "place this track before the track currently at queue index `toIndex` in the
|
||||
* UPCOMING order", i.e. it changes `order`, not `queue`.
|
||||
*/
|
||||
export function reorderTrackById(id: number, toIndex: number): void {
|
||||
const from = indexOfTrack(id);
|
||||
if (from === -1) return;
|
||||
|
||||
const clampedTo = Math.max(
|
||||
0,
|
||||
Math.min(queue.length - 1, Math.floor(toIndex)),
|
||||
);
|
||||
|
||||
// Shuffle: reorder upcoming traversal schedule, not underlying queue.
|
||||
if (shuffleEnabled) {
|
||||
// Ensure traversal state exists
|
||||
ensureTraversalStateForCurrent();
|
||||
|
||||
// Only reorder within the future schedule (`order`).
|
||||
// If the dragged track isn't in `order`, it's either current, already played, or unscheduled.
|
||||
const fromPos = order.indexOf(from);
|
||||
if (fromPos === -1) {
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine insertion position relative to the target queue index within `order`.
|
||||
// If target isn't currently in `order`, we clamp to the end of the future schedule.
|
||||
let toPos = order.indexOf(clampedTo);
|
||||
if (toPos === -1) toPos = order.length;
|
||||
|
||||
// Moving an item forward past itself needs an index adjustment after removal.
|
||||
const without = order.filter((i) => i !== from);
|
||||
if (toPos > fromPos) toPos = Math.max(0, toPos - 1);
|
||||
|
||||
const nextPos = Math.max(0, Math.min(without.length, toPos));
|
||||
order = [...without.slice(0, nextPos), from, ...without.slice(nextPos)];
|
||||
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Linear: reorder the underlying queue
|
||||
if (from === clampedTo) return;
|
||||
|
||||
moveIndex(from, clampedTo);
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core insertion behavior per your rules.
|
||||
*
|
||||
* - "jump": jump to an existing queued track (does not reorder)
|
||||
* - "play": move/insert to right-after-current and then skip to it
|
||||
* - "playNext": move/insert to right-after-current but don't skip
|
||||
* - "add": append (deduped)
|
||||
*
|
||||
* Dedupe semantics:
|
||||
* - If exists, we MOVE it instead of duplicating (except "jump", which never moves).
|
||||
*/
|
||||
export function insertTrack(track: Track, mode: InsertMode): void {
|
||||
// Normalize + basic guard
|
||||
if (!track || !Number.isFinite(track.id) || !track.src) return;
|
||||
|
||||
// Clicking an already-queued song should MOVE PLAYHEAD to that queue position,
|
||||
// not reshuffle the queue around the current track.
|
||||
if (mode === "jump") {
|
||||
const i = indexOfTrack(track.id);
|
||||
if (i === -1) return;
|
||||
|
||||
applyCurrentIndex(i);
|
||||
|
||||
// In shuffle mode, make sure the future order is still valid relative to the
|
||||
// new cursor position (i.e., "next" should come from order after this jump).
|
||||
// We rebuild the remaining order while preserving already-played history.
|
||||
if (shuffleEnabled) {
|
||||
rebuildShuffleOrderPreservingPast();
|
||||
}
|
||||
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user hits "Play" / "Play next" on the *currently playing* track,
|
||||
// treat it as a no-op. This avoids trying to move the current track to
|
||||
// "right after itself" and then skipping, which can produce confusing
|
||||
// queue changes (and in some cases corrupt traversal state).
|
||||
//
|
||||
// NOTE: "Add to queue" still dedupes below, but we early-return there too.
|
||||
if (currentIndex != null && queue[currentIndex]?.id === track.id) {
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty queue behavior
|
||||
if (queue.length === 0) {
|
||||
queue = [track];
|
||||
currentIndex = 0;
|
||||
|
||||
// Initialize traversal state
|
||||
if (shuffleEnabled) {
|
||||
history = [0];
|
||||
cursor = 0;
|
||||
order = [];
|
||||
} else {
|
||||
history = [];
|
||||
cursor = 0;
|
||||
order = [];
|
||||
}
|
||||
|
||||
if (mode === "add" || mode === "playNext") {
|
||||
// If user only adds, do not auto-start; but we still set currentIndex?
|
||||
// Per your desired behavior: "start the queue if it's empty" occurs on Play,
|
||||
// not necessarily on Add/PlayNext. We'll keep "currentIndex = 0" so it shows
|
||||
// a selected track, but GlobalPlayer should remain paused until user hits play.
|
||||
// NOTE: If you prefer currentIndex to remain null for add/playNext on empty,
|
||||
// we can tweak later.
|
||||
}
|
||||
|
||||
// For play: skipping to inserted is effectively current track already.
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine insertion target: right after current, or at end if nothing selected.
|
||||
const base =
|
||||
currentIndex == null
|
||||
? -1
|
||||
: Math.max(-1, Math.min(currentIndex, queue.length - 1));
|
||||
const targetIndex = base + 1;
|
||||
|
||||
const existingIndex = indexOfTrack(track.id);
|
||||
|
||||
if (mode === "add") {
|
||||
if (existingIndex !== -1) return;
|
||||
insertNewAt(queue.length, track);
|
||||
// No traversal tweaks required
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// play / playNext:
|
||||
if (existingIndex === -1) {
|
||||
// Insert as a new item at targetIndex
|
||||
insertNewAt(targetIndex, track);
|
||||
|
||||
if (shuffleEnabled) {
|
||||
// Ensure it becomes next in shuffled traversal
|
||||
ensureTraversalStateForCurrent();
|
||||
order = injectNextIntoShuffleOrder({
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
nextIndex: targetIndex,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Move existing to targetIndex (account for removal shifting)
|
||||
// If existing is before target, the "real" target after removal shifts by -1
|
||||
const adjustedTarget =
|
||||
existingIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||||
|
||||
if (existingIndex !== adjustedTarget) {
|
||||
moveIndex(existingIndex, adjustedTarget);
|
||||
}
|
||||
|
||||
if (shuffleEnabled) {
|
||||
ensureTraversalStateForCurrent();
|
||||
order = injectNextIntoShuffleOrder({
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
nextIndex: adjustedTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "play") {
|
||||
// Skip current -> go to that "next"
|
||||
next();
|
||||
} else {
|
||||
queueNotify();
|
||||
}
|
||||
}
|
||||
|
||||
export function insertTracks(tracks: Track[], mode: InsertMode): void {
|
||||
const incoming = dedupeTracks(tracks).filter((t) => t?.src);
|
||||
|
||||
if (incoming.length === 0) return;
|
||||
|
||||
if (mode === "add") {
|
||||
// Append in order
|
||||
for (const t of incoming) insertTrack(t, "add");
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// For play/playNext with multiple tracks:
|
||||
// Place them sequentially after current in the order provided.
|
||||
// - playNext: do not skip
|
||||
// - play: skip to first inserted (becomes next), but keep subsequent ones after it
|
||||
const base =
|
||||
queue.length === 0
|
||||
? -1
|
||||
: currentIndex == null
|
||||
? queue.length - 1
|
||||
: currentIndex;
|
||||
|
||||
let insertPos = base + 1;
|
||||
|
||||
for (const t of incoming) {
|
||||
const existing = indexOfTrack(t.id);
|
||||
if (existing === -1) {
|
||||
insertNewAt(insertPos, t);
|
||||
if (shuffleEnabled) {
|
||||
ensureTraversalStateForCurrent();
|
||||
order = injectNextIntoShuffleOrder({
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
nextIndex: insertPos,
|
||||
});
|
||||
}
|
||||
insertPos += 1;
|
||||
} else {
|
||||
const adjustedTarget = existing < insertPos ? insertPos - 1 : insertPos;
|
||||
if (existing !== adjustedTarget) moveIndex(existing, adjustedTarget);
|
||||
if (shuffleEnabled) {
|
||||
ensureTraversalStateForCurrent();
|
||||
order = injectNextIntoShuffleOrder({
|
||||
order,
|
||||
history,
|
||||
cursor,
|
||||
nextIndex: adjustedTarget,
|
||||
});
|
||||
}
|
||||
insertPos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "play") {
|
||||
next();
|
||||
} else {
|
||||
queueNotify();
|
||||
}
|
||||
}
|
||||
|
||||
/** --- Toggles / settings --- */
|
||||
|
||||
export function setVolume(v: number): void {
|
||||
volume = clamp01(v);
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function setUiOpen(open: boolean): void {
|
||||
uiOpen = !!open;
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function toggleUiOpen(): void {
|
||||
uiOpen = !uiOpen;
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function toggleWrap(): void {
|
||||
wrapEnabled = !wrapEnabled;
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function enableShuffle(enable: boolean): void {
|
||||
const nextVal = !!enable;
|
||||
if (shuffleEnabled === nextVal) return;
|
||||
|
||||
shuffleEnabled = nextVal;
|
||||
|
||||
if (!shuffleEnabled) {
|
||||
order = [];
|
||||
history = [];
|
||||
cursor = 0;
|
||||
queueNotify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Turning shuffle on: preserve current as starting history point
|
||||
if (currentIndex != null) {
|
||||
history = [currentIndex];
|
||||
cursor = 0;
|
||||
} else {
|
||||
history = [];
|
||||
cursor = 0;
|
||||
}
|
||||
|
||||
rebuildShuffleOrderPreservingPast();
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
export function toggleShuffle(): void {
|
||||
enableShuffle(!shuffleEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure traversal state is sane if queue was externally replaced.
|
||||
* Not expected in normal usage, but handy if you implement "replace queue" later.
|
||||
*/
|
||||
export function setQueue(
|
||||
tracks: Track[],
|
||||
opts?: { startIndex?: number | null },
|
||||
) {
|
||||
queue = dedupeTracks(tracks);
|
||||
currentIndex =
|
||||
opts?.startIndex == null
|
||||
? null
|
||||
: opts.startIndex >= 0 && opts.startIndex < queue.length
|
||||
? opts.startIndex
|
||||
: null;
|
||||
|
||||
order = [];
|
||||
history = [];
|
||||
cursor = 0;
|
||||
|
||||
if (shuffleEnabled) rebuildShuffleOrderPreservingPast();
|
||||
|
||||
queueNotify();
|
||||
}
|
||||
|
||||
/** --- Convenience wrappers that match UI wording --- */
|
||||
|
||||
export function play(track: Track): void {
|
||||
insertTrack(track, "play");
|
||||
}
|
||||
|
||||
export function playNext(track: Track): void {
|
||||
insertTrack(track, "playNext");
|
||||
}
|
||||
|
||||
export function addToQueue(track: Track): void {
|
||||
insertTrack(track, "add");
|
||||
}
|
||||
|
||||
export function addAllToQueue(tracks: Track[]): void {
|
||||
insertTracks(tracks, "add");
|
||||
}
|
||||
|
||||
export function playAllNext(tracks: Track[]): void {
|
||||
insertTracks(tracks, "playNext");
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal "now playing" string helpers for UI.
|
||||
* Keeping here avoids repeated null checks in templates.
|
||||
*/
|
||||
export function nowPlayingLabel(): string {
|
||||
if (!currentTrack) return "Nothing playing";
|
||||
const t = currentTrack;
|
||||
const title = (t.title ?? "").trim() || "Unknown title";
|
||||
const artist = (t.artist ?? "").trim() || "Unknown Artist";
|
||||
return `${title} — ${artist}`;
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
export type ShuffleState = {
|
||||
/**
|
||||
* Upcoming indices into the *current queue* in the order they will be visited
|
||||
* when calling `next()` while shuffle is enabled.
|
||||
*/
|
||||
order: number[];
|
||||
|
||||
/**
|
||||
* Visited indices into the *current queue* in visit order.
|
||||
* `cursor` points at the currently active entry within this array.
|
||||
*/
|
||||
history: number[];
|
||||
|
||||
/**
|
||||
* Index into `history` that represents the current item.
|
||||
*/
|
||||
cursor: number;
|
||||
};
|
||||
|
||||
export type ReindexResult = {
|
||||
order: number[];
|
||||
history: number[];
|
||||
cursor: number;
|
||||
currentIndex: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a shuffled play order for the *future* without affecting past history.
|
||||
*
|
||||
* - `queueLength`: length of the queue.
|
||||
* - `visited`: indices that are considered "already visited" (typically history[0..cursor]).
|
||||
* - `rng`: optional deterministic RNG for tests.
|
||||
*/
|
||||
export function buildInitialShuffleOrder(
|
||||
queueLength: number,
|
||||
visited: Set<number>,
|
||||
rng: () => number = Math.random,
|
||||
): number[] {
|
||||
const remaining: number[] = [];
|
||||
for (let i = 0; i < queueLength; i += 1) {
|
||||
if (!visited.has(i)) remaining.push(i);
|
||||
}
|
||||
return shuffleArray(remaining, rng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a track so it becomes the "next" item, even while shuffle is enabled.
|
||||
*
|
||||
* This modifies ONLY the future order. Past history is preserved.
|
||||
*
|
||||
* - If `nextIndex` is already in `order`, it is moved to the front.
|
||||
* - If `nextIndex` is currently in "visited" history (<= cursor), we do not
|
||||
* reschedule it as next (that would violate history semantics). In that case,
|
||||
* this function is a no-op.
|
||||
* - If `track` is the current item (history[cursor]), no-op.
|
||||
*/
|
||||
export function injectNextIntoShuffleOrder(args: {
|
||||
order: number[];
|
||||
history: number[];
|
||||
cursor: number;
|
||||
nextIndex: number;
|
||||
}): number[] {
|
||||
const { order, history, cursor, nextIndex } = args;
|
||||
|
||||
if (!Number.isInteger(nextIndex) || nextIndex < 0) return order;
|
||||
|
||||
const current = history[cursor];
|
||||
if (current === nextIndex) return order;
|
||||
|
||||
// Preserve past: don't schedule already-visited entries as "next"
|
||||
const visited = new Set(history.slice(0, cursor + 1));
|
||||
if (visited.has(nextIndex)) return order;
|
||||
|
||||
const nextOrder: number[] = [];
|
||||
nextOrder.push(nextIndex);
|
||||
|
||||
for (const i of order) {
|
||||
if (i === nextIndex) continue;
|
||||
nextOrder.push(i);
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reindex a shuffle traversal state after a queue mutation that changes indices.
|
||||
*
|
||||
* Provide:
|
||||
* - `fromIndex`: original index of the moved/removed item
|
||||
* - `toIndex`: new index of the moved item, or `null` if the item was removed
|
||||
* - `currentIndex`: the current queue index before reindexing (may be null)
|
||||
*
|
||||
* This updates `history`, `order`, and `currentIndex` so they point to correct
|
||||
* indices in the new queue.
|
||||
*
|
||||
* Notes:
|
||||
* - This assumes a *single-item* move or remove.
|
||||
* - For "insert new item" (not previously present), you typically don't need
|
||||
* this; instead you just insert the index into `order` as desired.
|
||||
*/
|
||||
export function reindexAfterMoveOrRemove(args: {
|
||||
order: number[];
|
||||
history: number[];
|
||||
cursor: number;
|
||||
currentIndex: number | null;
|
||||
|
||||
fromIndex: number;
|
||||
toIndex: number | null;
|
||||
}): ReindexResult {
|
||||
const { fromIndex, toIndex } = args;
|
||||
|
||||
const remap = (i: number): number | null => {
|
||||
if (!Number.isInteger(i) || i < 0) return null;
|
||||
|
||||
// Removal
|
||||
if (toIndex == null) {
|
||||
if (i === fromIndex) return null;
|
||||
if (i > fromIndex) return i - 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
// Move
|
||||
if (fromIndex === toIndex) return i;
|
||||
|
||||
// Moving earlier: items between [toIndex .. fromIndex-1] shift +1
|
||||
if (toIndex < fromIndex) {
|
||||
if (i === fromIndex) return toIndex;
|
||||
if (i >= toIndex && i < fromIndex) return i + 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
// Moving later: items between [fromIndex+1 .. toIndex] shift -1
|
||||
// (because we remove fromIndex then insert at toIndex)
|
||||
if (i === fromIndex) return toIndex;
|
||||
if (i > fromIndex && i <= toIndex) return i - 1;
|
||||
return i;
|
||||
};
|
||||
|
||||
const history = compactAndDedupePreservingOrder(
|
||||
args.history.map((i) => remap(i)).filter((i): i is number => i != null),
|
||||
);
|
||||
|
||||
// Cursor points into history; keep it at the same logical "current" where possible.
|
||||
// If the current item was removed, clamp.
|
||||
let cursor = args.cursor;
|
||||
if (history.length === 0) cursor = 0;
|
||||
else cursor = Math.max(0, Math.min(cursor, history.length - 1));
|
||||
|
||||
const order = compactAndDedupePreservingOrder(
|
||||
args.order.map((i) => remap(i)).filter((i): i is number => i != null),
|
||||
);
|
||||
|
||||
const currentIndex =
|
||||
args.currentIndex == null ? null : remap(args.currentIndex);
|
||||
|
||||
return { order, history, cursor, currentIndex };
|
||||
}
|
||||
|
||||
function shuffleArray<T>(arr: T[], rng: () => number) {
|
||||
// Fisher–Yates shuffle (in place), return copy for convenience
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
const tmp = a[i];
|
||||
a[i] = a[j];
|
||||
a[j] = tmp;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function compactAndDedupePreservingOrder(indices: number[]) {
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const i of indices) {
|
||||
if (!Number.isInteger(i) || i < 0) continue;
|
||||
if (seen.has(i)) continue;
|
||||
seen.add(i);
|
||||
out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
412
src/lib/player/store.svelte.ts
Normal file
412
src/lib/player/store.svelte.ts
Normal 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();
|
||||
@@ -18,9 +18,12 @@ export type Track = {
|
||||
|
||||
/** Optional extra context for rendering/debugging */
|
||||
animeName?: string;
|
||||
type?: SongType;
|
||||
number?: number;
|
||||
type: SongType;
|
||||
number: number;
|
||||
fileName?: string | null;
|
||||
dub: boolean;
|
||||
rebroadcast: boolean;
|
||||
globalPercent: number;
|
||||
};
|
||||
|
||||
export type SongRowLike = {
|
||||
@@ -31,6 +34,9 @@ export type SongRowLike = {
|
||||
songName: string;
|
||||
artistName: 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,
|
||||
number: row.number,
|
||||
fileName,
|
||||
dub: row.dub,
|
||||
rebroadcast: row.rebroadcast,
|
||||
globalPercent: row.globalPercent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,18 +2,28 @@ import { z } from "zod";
|
||||
|
||||
import { AmqAnimeCategory, AmqAnimeGenre, AmqAnimeTag } from "./anime-extended";
|
||||
|
||||
export const Season = z.enum({
|
||||
export const AmqSeasonMap = {
|
||||
Winter: 0,
|
||||
Spring: 1,
|
||||
Summer: 2,
|
||||
Fall: 3,
|
||||
} as const);
|
||||
} as const;
|
||||
|
||||
export const SongLinkType = z.enum({
|
||||
export const AmqSeason = z.enum(AmqSeasonMap);
|
||||
|
||||
export const AmqSongLinkTypeMap = {
|
||||
OP: 1,
|
||||
ED: 2,
|
||||
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({
|
||||
false: 0,
|
||||
@@ -23,7 +33,7 @@ const BooleanInt = z.enum({
|
||||
export const AmqSongLink = z.object({
|
||||
songId: z.int().positive(),
|
||||
number: z.int().nonnegative(),
|
||||
type: SongLinkType,
|
||||
type: AmqSongLinkType,
|
||||
annSongId: z.int().positive(),
|
||||
uploaded: BooleanInt,
|
||||
rebroadcast: BooleanInt,
|
||||
@@ -37,7 +47,7 @@ export const AmqAnimeSchema = z.object({
|
||||
kitsuId: z.int().positive().nullable(),
|
||||
category: z.object({
|
||||
name: AmqAnimeCategory,
|
||||
number: z.union([z.number(), z.string()]).nullable(),
|
||||
number: z.coerce.number().nullable(),
|
||||
}),
|
||||
genres: z.array(AmqAnimeGenre),
|
||||
tags: z.array(AmqAnimeTag),
|
||||
@@ -53,7 +63,7 @@ export const AmqAnimeSchema = z.object({
|
||||
}),
|
||||
),
|
||||
year: z.int().positive(),
|
||||
seasonId: Season,
|
||||
seasonId: AmqSeason,
|
||||
songLinks: z.array(AmqSongLink),
|
||||
opCount: z.int().nonnegative(),
|
||||
edCount: z.int().nonnegative(),
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
export const SongTypeMap: Record<string, number> = {
|
||||
OP: 1,
|
||||
ED: 2,
|
||||
INS: 3,
|
||||
};
|
||||
import { AmqSongLinkTypeMapReverse } from "$lib/types/amq";
|
||||
|
||||
// Map number back to string for displaying in UI
|
||||
export const SongTypeReverseMap: Record<number, string> = {
|
||||
1: "OP",
|
||||
2: "ED",
|
||||
3: "INS",
|
||||
};
|
||||
|
||||
export const SongCategoryMap: Record<number, string> = {
|
||||
0: "None",
|
||||
1: "Instrumental",
|
||||
2: "Chanting",
|
||||
3: "Character",
|
||||
4: "Standard",
|
||||
};
|
||||
export function songTypeNumberLabel(type: number, number: number) {
|
||||
return `${AmqSongLinkTypeMapReverse[type as keyof typeof AmqSongLinkTypeMapReverse] ?? `T${type}`}${number ? number : ""}`;
|
||||
}
|
||||
|
||||
36
src/lib/utils/list/index.ts
Normal file
36
src/lib/utils/list/index.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
import GlobalPlayer from "$lib/components/GlobalPlayer.svelte";
|
||||
import PlayerRoot from "$lib/components/player/PlayerRoot.svelte";
|
||||
import ClientOnly from "$lib/components/util/ClientOnly.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
@@ -18,7 +18,7 @@
|
||||
- Desktop: 2-column grid, right column reserved for the in-flow player sidebar
|
||||
-->
|
||||
<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
|
||||
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("/songs")}>Songs</a>
|
||||
<a href={resolve("/list")}>List</a>
|
||||
<a href={resolve("/mal")}>MAL</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex flex-col items-center]">
|
||||
<main class="flex flex-col flex-1 p-4 max-sm:px-2">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<!-- Desktop sidebar column (in normal flow) -->
|
||||
<aside class="hidden lg:block">
|
||||
<ClientOnly showFallback={false}>
|
||||
{#snippet children()}
|
||||
<GlobalPlayer />
|
||||
{/snippet}
|
||||
</ClientOnly>
|
||||
</aside>
|
||||
<!-- PlayerRoot uses contents to inject its children into this grid -->
|
||||
<ClientOnly showFallback={false}>
|
||||
{#snippet children()}
|
||||
<div class="contents">
|
||||
<PlayerRoot />
|
||||
</div>
|
||||
{/snippet}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Mobile player UI is rendered via a portal from the single GlobalPlayer instance
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
getClientDb,
|
||||
searchAnimeByName,
|
||||
} from "$lib/db/client-db";
|
||||
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import { trackFromSongRow } from "$lib/player/types";
|
||||
import { AmqBrowseSearchSchema } from "$lib/types/search/amq-browse";
|
||||
import { seasonName } from "$lib/utils/amq";
|
||||
@@ -74,11 +74,14 @@
|
||||
songName: s.songName,
|
||||
artistName: s.artistName,
|
||||
fileName: s.fileName ?? null,
|
||||
dub: Boolean(s.dub),
|
||||
rebroadcast: Boolean(s.rebroadcast),
|
||||
globalPercent: s.globalPercent,
|
||||
}),
|
||||
)
|
||||
.filter((t) => t !== null);
|
||||
|
||||
addAllToQueue(tracks);
|
||||
player.addAll(tracks);
|
||||
}
|
||||
|
||||
async function playAllNextForAnime(a: AnimeItem) {
|
||||
@@ -98,11 +101,14 @@
|
||||
songName: s.songName,
|
||||
artistName: s.artistName,
|
||||
fileName: s.fileName ?? null,
|
||||
dub: Boolean(s.dub),
|
||||
rebroadcast: Boolean(s.rebroadcast),
|
||||
globalPercent: s.globalPercent,
|
||||
}),
|
||||
)
|
||||
.filter((t) => t !== null);
|
||||
|
||||
playAllNext(tracks);
|
||||
player.playAllNext(tracks);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invalidate } from "$app/navigation";
|
||||
import SongEntry from "$lib/components/SongEntry.svelte";
|
||||
import { db as clientDb } from "$lib/db/client-db";
|
||||
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import { trackFromSongRow } from "$lib/player/types";
|
||||
import { seasonName } from "$lib/utils/amq";
|
||||
import type { PageData } from "./$types";
|
||||
@@ -36,17 +36,20 @@
|
||||
songName: s.songName,
|
||||
artistName: s.artistName,
|
||||
fileName: s.fileName ?? null,
|
||||
dub: Boolean(s.dub),
|
||||
rebroadcast: Boolean(s.rebroadcast),
|
||||
globalPercent: s.globalPercent,
|
||||
}),
|
||||
)
|
||||
.filter((t) => t !== null);
|
||||
}
|
||||
|
||||
function queueAll() {
|
||||
addAllToQueue(playableTracks());
|
||||
player.addAll(playableTracks());
|
||||
}
|
||||
|
||||
function playAllNextFromAnime() {
|
||||
playAllNext(playableTracks());
|
||||
player.playAllNext(playableTracks());
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -64,7 +67,9 @@
|
||||
<p class="mt-3 text-sm text-muted-foreground">Loading anime…</p>
|
||||
{:else}
|
||||
<header class="mt-2 space-y-2">
|
||||
<h1 class="text-2xl font-semibold">{data.animeWithSongs.anime.mainName}</h1>
|
||||
<h1 class="text-2xl font-semibold">
|
||||
{data.animeWithSongs.anime.mainName}
|
||||
</h1>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{data.animeWithSongs.anime.year}
|
||||
@@ -164,6 +169,8 @@
|
||||
artistName={s.artistName}
|
||||
fileName={s.fileName}
|
||||
globalPercent={s.globalPercent}
|
||||
dub={Boolean(s.dub)}
|
||||
rebroadcast={Boolean(s.rebroadcast)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -134,4 +134,40 @@
|
||||
.btn-icon-sm {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,221 +1,44 @@
|
||||
<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 { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
|
||||
import { trackFromSongRow } from "$lib/player/types";
|
||||
import {
|
||||
MalAnimeListQuerySchema,
|
||||
MalAnimeListStatusEnum,
|
||||
} from "$lib/types/mal";
|
||||
import type { PageData } from "./$types";
|
||||
import { useSearchParams } from "runed/kit";
|
||||
import type { PageData } from "./$types";
|
||||
import { SearchParamsSchema } from "./schema";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AnimeListCodec,
|
||||
AnimeListInput,
|
||||
} from "$lib/components/inputs/anime-list-input";
|
||||
|
||||
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();
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const params = useSearchParams(ListSearchSchema, {
|
||||
pushHistory: false,
|
||||
showDefaults: false,
|
||||
});
|
||||
const params = useSearchParams(SearchParamsSchema, {
|
||||
pushHistory: 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.
|
||||
let formMal = $state<string>(params.mal);
|
||||
// $inspect(formState);
|
||||
|
||||
// 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,
|
||||
}),
|
||||
)
|
||||
.filter((t) => t !== null),
|
||||
);
|
||||
$effect(() => {
|
||||
console.log("formState", formState);
|
||||
});
|
||||
</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>
|
||||
<h1 class="text-2xl font-semibold">List Search WIP</h1>
|
||||
|
||||
<form
|
||||
class="mt-4 flex flex-col gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
params.mal = formMal;
|
||||
}}
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
params.kind = formState.kind;
|
||||
params.username = formState.username;
|
||||
params.status = formState.status;
|
||||
}}
|
||||
class="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<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={() => 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}
|
||||
<AnimeListInput bind:value={formState} />
|
||||
<Button type="submit">Search</Button>
|
||||
</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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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;
|
||||
}
|
||||
import { SearchParamsSchema } from "./schema";
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
const parsed = SearchParamsSchema.safeParse(url.searchParams);
|
||||
console.log(parsed);
|
||||
}
|
||||
8
src/routes/list/schema.ts
Normal file
8
src/routes/list/schema.ts
Normal 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
226
src/routes/mal/+page.svelte
Normal 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
100
src/routes/mal/+page.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -5,19 +5,17 @@
|
||||
import { invalidate } from "$app/navigation";
|
||||
import SongEntry from "$lib/components/SongEntry.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { ChipGroup } from "$lib/components/ui/chip-group";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
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 { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import { trackFromSongRow } from "$lib/player/types";
|
||||
import { AmqSongLinkTypeMap } from "$lib/types/amq";
|
||||
import type { PageData } from "./$types";
|
||||
import { SearchParamsSchemaClient } from "./schema";
|
||||
import { SearchParamsSchema } from "./schema";
|
||||
|
||||
const params = useSearchParams(SearchParamsSchemaClient, {
|
||||
const params = useSearchParams(SearchParamsSchema, {
|
||||
pushHistory: false,
|
||||
showDefaults: false,
|
||||
});
|
||||
@@ -51,6 +49,9 @@
|
||||
songName: r.songName,
|
||||
artistName: songArtistLabel(r),
|
||||
fileName: r.fileName,
|
||||
dub: Boolean(r.dub),
|
||||
rebroadcast: Boolean(r.rebroadcast),
|
||||
globalPercent: r.globalPercent,
|
||||
}),
|
||||
)
|
||||
.filter((t) => t !== null),
|
||||
@@ -122,15 +123,14 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="song-type">Song Type</Label>
|
||||
<NativeSelect id="song-type" bind:value={params.songType}>
|
||||
<NativeSelectOption value="0">All</NativeSelectOption>
|
||||
<NativeSelectOption value="1">OP</NativeSelectOption>
|
||||
<NativeSelectOption value="2">ED</NativeSelectOption>
|
||||
<NativeSelectOption value="3">INS</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<ChipGroup
|
||||
label="Song Type"
|
||||
items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
|
||||
label: type,
|
||||
value: AmqSongLinkTypeMap[type as keyof typeof AmqSongLinkTypeMap],
|
||||
}))}
|
||||
bind:value={params.type}
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="songs-limit">Limit</Label>
|
||||
<Input
|
||||
@@ -140,7 +140,7 @@
|
||||
max="200"
|
||||
step="20"
|
||||
class="w-1/2"
|
||||
bind:value={params.songsLimit}
|
||||
bind:value={params.limit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +152,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="cursor-pointer"
|
||||
onclick={() => addAllToQueue(tracksFromResults)}
|
||||
onclick={() => player.addAll(tracksFromResults)}
|
||||
disabled={tracksFromResults.length === 0}
|
||||
>
|
||||
Add all to queue
|
||||
@@ -161,7 +161,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
class="cursor-pointer"
|
||||
onclick={() => playAllNext(tracksFromResults)}
|
||||
onclick={() => player.playAllNext(tracksFromResults)}
|
||||
disabled={tracksFromResults.length === 0}
|
||||
>
|
||||
Play all next
|
||||
@@ -191,6 +191,8 @@
|
||||
artistName={songArtistLabel(r)}
|
||||
fileName={r.fileName}
|
||||
globalPercent={r.globalPercent}
|
||||
dub={Boolean(r.dub)}
|
||||
rebroadcast={Boolean(r.rebroadcast)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { SongFilters } from "$lib/db/client-db";
|
||||
import { db, ensureSeeded, getSongsWithFilters } from "$lib/db/client-db";
|
||||
import type { PageLoad } from "./$types";
|
||||
import { SearchParamsSchemaServer } from "./schema";
|
||||
import { SearchParamsSchema } from "./schema";
|
||||
|
||||
export const load: PageLoad = async ({ url, fetch, depends }) => {
|
||||
depends("clientdb:songs");
|
||||
|
||||
const parsed = SearchParamsSchemaServer.safeParse(
|
||||
const parsed = SearchParamsSchema.safeParse(
|
||||
Object.fromEntries(url.searchParams.entries()),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ url, fetch, depends }) => {
|
||||
filters.globalPercentMin = parsed.data.gpm;
|
||||
if (parsed.data.gpx !== undefined)
|
||||
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.
|
||||
@@ -32,11 +32,7 @@ export const load: PageLoad = async ({ url, fetch, depends }) => {
|
||||
|
||||
await ensureSeeded({ fetch });
|
||||
|
||||
const songRows = await getSongsWithFilters(
|
||||
db,
|
||||
filters,
|
||||
parsed.data?.songsLimit,
|
||||
);
|
||||
const songRows = await getSongsWithFilters(db, filters, parsed.data?.limit);
|
||||
|
||||
return {
|
||||
filters: parsed.success ? parsed.data : {}, // Return original parsed data for form state
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
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({
|
||||
songsLimit: z.coerce.number().int().default(20),
|
||||
limit: z.coerce.number().int().default(20),
|
||||
song: z.string().optional().default(""),
|
||||
artist: z.string().optional().default(""),
|
||||
anime: z.string().optional().default(""),
|
||||
gpm: z.coerce.number().int().optional().default(0),
|
||||
gpx: z.coerce.number().int().optional().default(100),
|
||||
});
|
||||
|
||||
export const SearchParamsSchemaClient = SearchParamsSchema.extend({
|
||||
songType: z.string().optional().default("0"),
|
||||
});
|
||||
|
||||
export const SearchParamsSchemaServer = SearchParamsSchema.extend({
|
||||
songType: z.coerce.number().int().optional(),
|
||||
type: songTypesCodec.default([]),
|
||||
});
|
||||
|
||||
@@ -5,4 +5,7 @@ import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), sqlocal()],
|
||||
server: {
|
||||
allowedHosts: ["cazzzer.pgrok.cazzzer.com"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user