Compare commits
9 Commits
4c50a5faab
...
ui/player
| Author | SHA1 | Date | |
|---|---|---|---|
| b37eef8f31 | |||
| a144baba2b | |||
| 21d62f8c6f | |||
| f90cf66cc1 | |||
| 1a3ec7d84e | |||
| 7dc37d9eb7 | |||
| ec3565078f | |||
| e3c0c6cade | |||
|
28643c38b8
|
@@ -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)}`),
|
||||
});
|
||||
@@ -24,7 +24,7 @@
|
||||
class="h-full flex flex-col border-l bg-background/50 backdrop-blur w-full"
|
||||
>
|
||||
{#if player.currentTrack}
|
||||
<div class="p-6 space-y-4 shrink-0">
|
||||
<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">
|
||||
@@ -63,7 +63,7 @@
|
||||
<div class="flex justify-center gap-4 divide-x divide-accent">
|
||||
<Controls />
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onclick={() => player.toggleMute()}
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -82,7 +82,7 @@
|
||||
bind:value={player.volume}
|
||||
max={1}
|
||||
step={0.05}
|
||||
class="flex-1"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@
|
||||
<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 />
|
||||
<Queue visible={!!player.currentTrack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<!-- Queue -->
|
||||
<div class="flex-1 overflow-hidden relative mt-auto">
|
||||
<div class="absolute inset-0">
|
||||
<Queue />
|
||||
<Queue visible={open} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { GripVertical, Play, X } from "@lucide/svelte";
|
||||
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) {
|
||||
@@ -44,27 +64,53 @@
|
||||
<div
|
||||
class="flex flex-col h-full w-full bg-background/50 backdrop-blur rounded-lg border overflow-hidden"
|
||||
>
|
||||
<div class="px-4 py-3 border-b flex justify-between items-center bg-muted/20">
|
||||
<h3 class="font-semibold text-sm">
|
||||
Up Next
|
||||
<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
|
||||
>
|
||||
{/if}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
onclick={() => player.clearQueue()}
|
||||
aria-label="Scroll to currently playing"
|
||||
onclick={scrollToCurrentlyPlaying}
|
||||
>
|
||||
<span class="sr-only">Clear</span>
|
||||
<X class="h-3 w-3" />
|
||||
<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}
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
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) => {
|
||||
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
- Desktop: 2-column grid, right column reserved for the in-flow player sidebar
|
||||
-->
|
||||
<div
|
||||
class="flex flex-col min-h-dvh not-lg:w-full 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,6 +30,7 @@
|
||||
<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>
|
||||
|
||||
@@ -1,226 +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 { 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,
|
||||
});
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
const params = useSearchParams(SearchParamsSchema, {
|
||||
pushHistory: false,
|
||||
});
|
||||
|
||||
function songArtistLabel(r: (typeof data.songRows)[number]) {
|
||||
return r.artistName ?? r.groupName ?? null;
|
||||
}
|
||||
let formState: z.infer<typeof AnimeListCodec> = $state({
|
||||
kind: "mal",
|
||||
username: "",
|
||||
status: [],
|
||||
});
|
||||
|
||||
function makeMalHref(username: string) {
|
||||
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
|
||||
}
|
||||
// $inspect(formState);
|
||||
|
||||
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),
|
||||
);
|
||||
$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;
|
||||
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={() => 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}
|
||||
<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}
|
||||
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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user