Compare commits
3 Commits
1bcdb77c1d
...
list-type
| Author | SHA1 | Date | |
|---|---|---|---|
|
775b67c177
|
|||
|
4c50a5faab
|
|||
|
b684ab790b
|
@@ -1,9 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { GripVertical, LocateFixed, Play, X } from "@lucide/svelte";
|
||||
import {
|
||||
Download,
|
||||
GripVertical,
|
||||
LocateFixed,
|
||||
Play,
|
||||
Upload,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import { tick } from "svelte";
|
||||
import { z } from "zod";
|
||||
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 { trackSchema } from "$lib/player/persist";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import type { Track } from "$lib/player/types";
|
||||
import { songTypeNumberLabel } from "$lib/utils/amq";
|
||||
@@ -59,6 +68,54 @@
|
||||
player.move(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function exportQueue() {
|
||||
const dataStr = JSON.stringify(player.queue, null, 2);
|
||||
const dataUri =
|
||||
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
const exportFileDefaultName = "amq-queue.json";
|
||||
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function importQueue(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
|
||||
const file = input.files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
const parsed = JSON.parse(content);
|
||||
const result = z.array(trackSchema).safeParse(parsed);
|
||||
|
||||
if (result.success) {
|
||||
player.clearQueue();
|
||||
player.isShuffled = false;
|
||||
player.repeatMode = "off";
|
||||
player.addAll(result.data);
|
||||
} else {
|
||||
console.error("Invalid queue format", result.error);
|
||||
alert("Failed to import: Invalid queue format.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reading file", err);
|
||||
alert("Failed to read file.");
|
||||
}
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -84,29 +141,65 @@
|
||||
</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 class="flex items-center gap-1 text-muted-foreground pl-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
bind:this={fileInput}
|
||||
onchange={importQueue}
|
||||
class="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
aria-label="Import Queue"
|
||||
onclick={() => fileInput?.click()}
|
||||
title="Import Queue"
|
||||
>
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
aria-label="Export Queue"
|
||||
onclick={exportQueue}
|
||||
disabled={player.queue.length === 0}
|
||||
title="Export Queue"
|
||||
>
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
{...props}
|
||||
title="Clear Queue"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<VirtualList
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function getAnimeWithSongsByAnnId(db: ClientDb, annId: number) {
|
||||
export async function getSongsForMalAnimeIds(
|
||||
db: ClientDb,
|
||||
malAnimeIds: number[],
|
||||
songTypes?: number[],
|
||||
) {
|
||||
const ids = malAnimeIds.filter((n) => Number.isFinite(n));
|
||||
if (ids.length === 0) return [];
|
||||
@@ -192,7 +193,14 @@ export async function getSongsForMalAnimeIds(
|
||||
.innerJoin(songs, eq(songs.annSongId, animeSongLinks.annSongId))
|
||||
.leftJoin(artists, eq(artists.songArtistId, songs.songArtistId))
|
||||
.leftJoin(groups, eq(groups.songGroupId, songs.songGroupId))
|
||||
.where(inArray(anime.malId, ids))
|
||||
.where(
|
||||
songTypes && songTypes.length > 0
|
||||
? and(
|
||||
inArray(anime.malId, ids),
|
||||
inArray(animeSongLinks.type, songTypes),
|
||||
)
|
||||
: inArray(anime.malId, ids),
|
||||
)
|
||||
.orderBy(
|
||||
desc(anime.year),
|
||||
desc(anime.seasonId),
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import { browser } from "$app/environment";
|
||||
import type { Track } from "./types";
|
||||
|
||||
const STORAGE_KEY = "amqtrain:player:v2";
|
||||
|
||||
export type PersistedState = {
|
||||
queue: Track[];
|
||||
currentId: number | null;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
minimized: boolean;
|
||||
};
|
||||
export const trackSchema: z.ZodType<Track> = z.object({
|
||||
id: z.number(),
|
||||
src: z.string(),
|
||||
title: z.string(),
|
||||
artist: z.string(),
|
||||
album: z.string(),
|
||||
animeName: z.string().optional(),
|
||||
type: z.number(),
|
||||
number: z.number(),
|
||||
fileName: z.string().nullable().optional(),
|
||||
dub: z.boolean(),
|
||||
rebroadcast: z.boolean(),
|
||||
globalPercent: z.number(),
|
||||
});
|
||||
|
||||
export const persistedStateSchema = z.object({
|
||||
queue: z.array(trackSchema),
|
||||
currentId: z.number().nullable(),
|
||||
volume: z.number(),
|
||||
isMuted: z.boolean(),
|
||||
minimized: z.boolean(),
|
||||
});
|
||||
|
||||
export type PersistedState = z.infer<typeof persistedStateSchema>;
|
||||
|
||||
export function loadState(): PersistedState | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
const result = persistedStateSchema.safeParse(parsed);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
console.error("Failed to parse player state", result.error);
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Failed to load player state", e);
|
||||
return null;
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { invalidate } from "$app/navigation";
|
||||
import SongEntry from "$lib/components/SongEntry.svelte";
|
||||
import { ChipGroup } from "$lib/components/ui/chip-group";
|
||||
import { db as clientDb } from "$lib/db/client-db";
|
||||
import { player } from "$lib/player/store.svelte";
|
||||
import { trackFromSongRow } from "$lib/player/types";
|
||||
import { AmqSongLinkTypeMap } from "$lib/types/amq";
|
||||
import {
|
||||
MalAnimeListQuerySchema,
|
||||
MalAnimeListStatusEnum,
|
||||
} from "$lib/types/mal";
|
||||
import { songTypesCodec } from "../songs/schema";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
const ListSearchSchema = MalAnimeListQuerySchema.extend({
|
||||
@@ -19,6 +22,7 @@
|
||||
status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
|
||||
// URL param `mal` is updated only on Search
|
||||
mal: z.string().default(""),
|
||||
type: songTypesCodec.default([]),
|
||||
}).strict();
|
||||
|
||||
const params = useSearchParams(ListSearchSchema, {
|
||||
@@ -130,6 +134,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<ChipGroup
|
||||
label="Song Type"
|
||||
items={Object.keys(AmqSongLinkTypeMap).map((type) => ({
|
||||
label: type,
|
||||
value: AmqSongLinkTypeMap[type as keyof typeof AmqSongLinkTypeMap],
|
||||
}))}
|
||||
bind:value={params.type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{#if data.username}
|
||||
MAL entries: {data.malResponse?.data.length ?? 0} (limited to {data.LIST_QUERY_LIMIT})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MalAnimeListResponseSchema,
|
||||
MalAnimeListStatusEnum,
|
||||
} from "$lib/types/mal";
|
||||
import { songTypesCodec } from "../songs/schema";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const LIST_QUERY_LIMIT = 1000;
|
||||
@@ -17,6 +18,7 @@ const SearchSchema = MalAnimeListQuerySchema.extend({
|
||||
|
||||
// Allow empty string to mean "All"
|
||||
status: MalAnimeListStatusEnum.or(z.literal("")).optional(),
|
||||
type: songTypesCodec.optional(),
|
||||
}).strict();
|
||||
|
||||
type StatusParam = z.infer<typeof SearchSchema>["status"];
|
||||
@@ -39,6 +41,7 @@ export const load: PageLoad = async ({ url, fetch, depends }) => {
|
||||
const status = parsed.success
|
||||
? normalizeStatus(parsed.data.status)
|
||||
: undefined;
|
||||
const types = parsed.success ? parsed.data.type : undefined;
|
||||
|
||||
const username = (mal ?? "").trim();
|
||||
|
||||
@@ -88,7 +91,7 @@ export const load: PageLoad = async ({ url, fetch, depends }) => {
|
||||
await ensureSeeded({ fetch });
|
||||
|
||||
const malIds = malResponse.data.map((e) => e.node.id);
|
||||
const songRows = await getSongsForMalAnimeIds(db, malIds);
|
||||
const songRows = await getSongsForMalAnimeIds(db, malIds, types);
|
||||
|
||||
return {
|
||||
LIST_QUERY_LIMIT,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
const SEP = ",";
|
||||
|
||||
const songTypesCodec = z.codec(z.string(), z.array(AmqSongLinkType), {
|
||||
export const songTypesCodec = z.codec(z.string(), z.array(AmqSongLinkType), {
|
||||
decode: (str) =>
|
||||
str
|
||||
? decodeURIComponent(str)
|
||||
|
||||
Reference in New Issue
Block a user