296 lines
7.7 KiB
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>
|