Files
amqtrain/src/lib/components/player/Queue.svelte

296 lines
7.7 KiB
Svelte

<script lang="ts">
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";
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);
}
}
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
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>
<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
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>