add extra song information to player

This commit is contained in:
2026-02-10 05:00:53 -08:00
parent 864a11fac4
commit cdd90016f8
12 changed files with 213 additions and 169 deletions

View File

@@ -8,6 +8,7 @@
} from "@lucide/svelte"; } from "@lucide/svelte";
import { player } from "$lib/player/store.svelte"; import { player } from "$lib/player/store.svelte";
import { type SongType, trackFromSongRow } from "$lib/player/types"; import { type SongType, trackFromSongRow } from "$lib/player/types";
import { songTypeNumberLabel } from "$lib/utils/amq";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
type SongEntryProps = { type SongEntryProps = {
@@ -18,9 +19,9 @@
songName: string; songName: string;
artistName: string | null; artistName: string | null;
fileName?: string | null; fileName?: string | null;
globalPercent: number | null; globalPercent: number;
dub: boolean | null; dub: boolean;
rebroadcast: boolean | null; rebroadcast: boolean;
}; };
let { let {
@@ -36,14 +37,7 @@
rebroadcast, rebroadcast,
}: SongEntryProps = $props(); }: SongEntryProps = $props();
const typeLabelMap: Record<number, string> = { const displayTypeNumber = $derived(songTypeNumberLabel(type, number));
1: "OP",
2: "ED",
3: "INS",
};
const typeLabel = $derived(typeLabelMap[type] ?? `T${type}`);
const displayTypeNumber = $derived(`${typeLabel}${number || ""}`);
const artistDisplay = $derived.by( const artistDisplay = $derived.by(
() => artistName?.trim() || "Unknown Artist", () => artistName?.trim() || "Unknown Artist",
@@ -60,6 +54,7 @@
fileName, fileName,
dub, dub,
rebroadcast, rebroadcast,
globalPercent,
}), }),
); );

View File

@@ -2,12 +2,22 @@
import { Disc, Volume1, Volume2, VolumeX } from "@lucide/svelte"; import { Disc, Volume1, Volume2, VolumeX } from "@lucide/svelte";
import { player } from "$lib/player/store.svelte"; import { player } from "$lib/player/store.svelte";
import { songTypeNumberLabel } from "$lib/utils/amq";
import Controls from "./Controls.svelte"; import Controls from "./Controls.svelte";
import { getAudioContext } from "./ctx.svelte"; import { getAudioContext } from "./ctx.svelte";
import Queue from "./Queue.svelte"; import Queue from "./Queue.svelte";
import { formatTime } from "./utils"; import { formatTime } from "./utils";
const audio = getAudioContext(); const audio = getAudioContext();
const displayTypeNumber = $derived(
player.currentTrack
? songTypeNumberLabel(
player.currentTrack.type,
player.currentTrack.number,
)
: "",
);
</script> </script>
<div <div
@@ -16,15 +26,20 @@
{#if player.currentTrack} {#if player.currentTrack}
<div class="p-6 space-y-4 shrink-0"> <div class="p-6 space-y-4 shrink-0">
<!-- Track Info --> <!-- Track Info -->
<div class="space-y-1.5 text-center"> <div class="space-y-1.5">
<h2 class="text-xl font-bold leading-tight line-clamp-2"> <h2 class="text-lg font-bold leading-tight">
{player.currentTrack.title} {player.currentTrack.animeName}
<span class="tag">{displayTypeNumber}</span>
</h2> </h2>
<p class="text-muted-foreground font-medium text-lg line-clamp-1"> <p class="text-foreground/80 font-medium text-l">
{player.currentTrack.artist} {player.currentTrack.title}
<span class="text-muted-foreground"> · </span>
<span class="text-muted-foreground"
>{player.currentTrack.globalPercent}%</span
>
</p> </p>
<p class="text-xs text-muted-foreground/80"> <p class="text-sm text-muted-foreground">
{player.currentTrack.album || player.currentTrack.animeName || ""} {player.currentTrack.artist}
</p> </p>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { Disc } from "@lucide/svelte"; import { Disc } from "@lucide/svelte";
import * as Drawer from "$lib/components/ui/drawer"; import * as Drawer from "$lib/components/ui/drawer";
import { player } from "$lib/player/store.svelte"; import { player } from "$lib/player/store.svelte";
import { songTypeNumberLabel } from "$lib/utils/amq";
import Controls from "./Controls.svelte"; import Controls from "./Controls.svelte";
import { getAudioContext } from "./ctx.svelte"; import { getAudioContext } from "./ctx.svelte";
import Queue from "./Queue.svelte"; import Queue from "./Queue.svelte";
@@ -9,6 +10,15 @@
const audio = getAudioContext(); const audio = getAudioContext();
let open = $state(false); let open = $state(false);
const displayTypeNumber = $derived(
player.currentTrack
? songTypeNumberLabel(
player.currentTrack.type,
player.currentTrack.number,
)
: "",
);
</script> </script>
<div class="z-50 border-t bg-background/95 backdrop-blur shadow-2xl"> <div class="z-50 border-t bg-background/95 backdrop-blur shadow-2xl">
@@ -19,19 +29,17 @@
class="flex items-center gap-3 overflow-hidden flex-1 text-left bg-transparent border-none p-0 cursor-pointer" class="flex items-center gap-3 overflow-hidden flex-1 text-left bg-transparent border-none p-0 cursor-pointer"
onclick={() => (open = true)} onclick={() => (open = true)}
> >
<!-- Placeholder Art -->
<div
class="h-10 w-10 rounded bg-muted flex items-center justify-center shrink-0"
>
<Disc class="h-6 w-6 text-muted-foreground" />
</div>
<div class="flex flex-col overflow-hidden"> <div class="flex flex-col overflow-hidden">
<div class="text-sm font-medium truncate leading-tight"> <div class="text-sm font-medium truncate leading-tight">
{player.currentTrack?.title || "Unknown Title"} {player.currentTrack?.animeName || "Unknown"}
<span class="tag">{displayTypeNumber}</span>
</div> </div>
<div class="text-xs text-muted-foreground truncate leading-tight"> <div class="text-xs text-muted-foreground truncate leading-tight">
{player.currentTrack?.artist || "Unknown Artist"} {player.currentTrack?.title || "Unknown Title"}
<span class="text-muted-foreground"> · </span>
<span class="text-muted-foreground"
>{player.currentTrack?.globalPercent}%</span
>
</div> </div>
</div> </div>
</button> </button>
@@ -59,14 +67,19 @@
<div class="mx-auto w-full max-w-xl flex-1 flex flex-col p-4 gap-4"> <div class="mx-auto w-full max-w-xl flex-1 flex flex-col p-4 gap-4">
<!-- Track Info --> <!-- Track Info -->
<div class="text-center space-y-1"> <div class="text-center space-y-1">
<h2 class="text-xl font-bold leading-tight line-clamp-2"> <h2 class="text-xl font-bold leading-tight">
{player.currentTrack?.title} {player.currentTrack?.animeName}
<span class="tag">{displayTypeNumber}</span>
</h2> </h2>
<p class="text-muted-foreground font-medium text-lg line-clamp-1"> <p class="text-foreground/80 font-medium text-lg line-clamp-1">
{player.currentTrack?.artist} {player.currentTrack?.title}
<span class="text-muted-foreground"> · </span>
<span class="text-muted-foreground"
>{player.currentTrack?.globalPercent}%</span
>
</p> </p>
<p class="text-xs text-muted-foreground/80 mt-1"> <p class="text-sm text-muted-foreground">
{player.currentTrack?.album || player.currentTrack?.animeName} {player.currentTrack?.artist}
</p> </p>
</div> </div>
@@ -87,7 +100,7 @@
</div> </div>
<!-- Main Controls --> <!-- Main Controls -->
<div class="flex justify-center py-4"> <div class="flex justify-center">
<Controls /> <Controls />
</div> </div>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, setContext } from "svelte"; import { onMount, setContext } from "svelte";
import { player } from "$lib/player/store.svelte"; import { player } from "$lib/player/store.svelte";
import { AudioContext, setAudioContext } from "./ctx.svelte"; import { songTypeNumberLabel } from "$lib/utils/amq";
import { AudioContext } from "./ctx.svelte";
import PlayerDesktop from "./PlayerDesktop.svelte"; import PlayerDesktop from "./PlayerDesktop.svelte";
import PlayerMobile from "./PlayerMobile.svelte"; import PlayerMobile from "./PlayerMobile.svelte";
@@ -13,8 +14,6 @@
import { loadState, saveState } from "$lib/player/persist"; import { loadState, saveState } from "$lib/player/persist";
// ... existing imports ...
onMount(() => { onMount(() => {
audioCtx.setElement(audioEl); audioCtx.setElement(audioEl);
@@ -24,16 +23,34 @@
player.init(saved); player.init(saved);
} }
// Setup MediaSession // 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());
}
}); });
// ... existing effect for persistence ... // 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 ?? "",
});
} else {
navigator.mediaSession.metadata = null;
}
}
});
// Update MediaSession metadata
// ...
// ... existing effect for playback ...
$effect(() => { $effect(() => {
const track = player.currentTrack; const track = player.currentTrack;
if (audioEl) { if (audioEl) {

View File

@@ -3,6 +3,7 @@
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { player } from "$lib/player/store.svelte"; import { player } from "$lib/player/store.svelte";
import type { Track } from "$lib/player/types"; import type { Track } from "$lib/player/types";
import { songTypeNumberLabel } from "$lib/utils/amq";
function onRemove(id: number) { function onRemove(id: number) {
player.remove(id); player.remove(id);
@@ -41,101 +42,113 @@
</script> </script>
<div <div
class="flex flex-col h-full w-full bg-background/50 backdrop-blur rounded-lg border overflow-hidden" class="flex flex-col h-full w-full bg-background/50 backdrop-blur rounded-lg border overflow-hidden"
> >
<div <div
class="px-4 py-3 border-b flex justify-between items-center bg-muted/20" class="px-4 py-3 border-b flex justify-between items-center bg-muted/20"
> >
<h3 class="font-semibold text-sm">Up Next</h3> <h3 class="font-semibold text-sm">Up Next</h3>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-6 w-6 p-0" class="h-6 w-6 p-0"
onclick={() => player.clearQueue()} onclick={() => player.clearQueue()}
> >
<span class="sr-only">Clear</span> <span class="sr-only">Clear</span>
<X class="h-3 w-3" /> <X class="h-3 w-3" />
</Button> </Button>
</div> </div>
<div class="flex-1 overflow-y-auto p-2 space-y-1"> <div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if player.displayQueue.length === 0} {#if player.displayQueue.length === 0}
<div class="text-center py-8 text-muted-foreground text-sm"> <div class="text-center py-8 text-muted-foreground text-sm">
Queue is empty Queue is empty
</div> </div>
{:else} {:else}
{#each player.displayQueue as track, i (track.id)} {#each player.displayQueue as track, i (track.id)}
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
draggable="true" draggable="true"
ondragstart={(e) => onDragStart(e, i)} ondragstart={(e) => onDragStart(e, i)}
ondragover={(e) => onDragOver(e, i)} ondragover={(e) => onDragOver(e, i)}
ondrop={(e) => onDrop(e, i)} ondrop={(e) => onDrop(e, i)}
onclick={() => onJump(track)} onclick={() => onJump(track)}
onkeydown={(e) => e.key === "Enter" && onJump(track)} onkeydown={(e) => e.key === "Enter" && onJump(track)}
class="group flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-sm" class="group flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-sm"
class:active={player.currentId === track.id} class:active={player.currentId === track.id}
class:border-t-2={dragOverIndex === i} class:border-t-2={dragOverIndex === i}
class:border-primary={dragOverIndex === i} class:border-primary={dragOverIndex === i}
> >
<div <div
class="w-6 shrink-0 flex items-center justify-center text-xs text-muted-foreground/60 font-mono" class="w-6 shrink-0 flex items-center justify-center text-xs text-muted-foreground/60 font-mono"
> >
<!-- Default: Number or Active Indicator --> <!-- Default: Number or Active Indicator -->
<div <div
class="group-hover:hidden flex items-center justify-center w-full h-full" class="group-hover:hidden flex items-center justify-center w-full h-full"
> >
{#if player.currentId === track.id} {#if player.currentId === track.id}
<div <div
class="w-2 h-2 bg-primary rounded-full animate-pulse" class="w-2 h-2 bg-primary rounded-full animate-pulse"
></div> ></div>
{:else} {:else}
<span>{i + 1}</span> <span>{i + 1}</span>
{/if} {/if}
</div> </div>
<!-- Hover: Grip Handle --> <!-- Hover: Grip Handle -->
<div <div
class="hidden group-hover:flex items-center justify-center w-full h-full cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-foreground" class="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" aria-label="Drag to reorder"
> >
<GripVertical class="h-4 w-4" /> <GripVertical class="h-4 w-4" />
</div> </div>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div <div
class="font-medium truncate" class="font-medium"
class:text-primary={player.currentId === track.id} class:text-primary={player.currentId === track.id}
> >
{track.title} {track.animeName}
</div> <span class="tag"
<div class="text-xs text-muted-foreground truncate"> >{songTypeNumberLabel(
{track.artist} track.type,
</div> track.number,
</div> )}</span
>
<span class="text-muted-foreground font-normal"
>{track.globalPercent}%</span
>
</div>
<div class="text-xs text-foreground/80">
{track.title}
<span class="text-muted-foreground"
>{track.artist}</span
>
</div>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity" class="h-6 w-6 opacity-50 group-hover:opacity-100 transition-opacity"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(track.id); onRemove(track.id);
}} }}
> >
<X class="h-3 w-3" /> <X class="h-3 w-3" />
</Button> </Button>
</div> </div>
{/each} {/each}
{/if} {/if}
</div> </div>
</div> </div>
<style> <style>
@reference "../../../routes/layout.css"; @reference "../../../routes/layout.css";
.active { .active {
@apply bg-muted/40; @apply bg-muted/40;
} }
</style> </style>

View File

@@ -37,7 +37,7 @@ export const songs = sqliteTable(
fileName: text("file_name"), fileName: text("file_name"),
fileName480: text("file_name_480"), fileName480: text("file_name_480"),
fileName720: text("file_name_720"), fileName720: text("file_name_720"),
globalPercent: integer("global_percent"), globalPercent: integer("global_percent").notNull(),
meanVolume: real("mean_volume"), meanVolume: real("mean_volume"),
/** /**

View File

@@ -18,11 +18,12 @@ export type Track = {
/** Optional extra context for rendering/debugging */ /** Optional extra context for rendering/debugging */
animeName?: string; animeName?: string;
type?: SongType; type: SongType;
number?: number; number: number;
fileName?: string | null; fileName?: string | null;
dub?: boolean | null; dub: boolean;
rebroadcast?: boolean | null; rebroadcast: boolean;
globalPercent: number;
}; };
export type SongRowLike = { export type SongRowLike = {
@@ -33,8 +34,9 @@ export type SongRowLike = {
songName: string; songName: string;
artistName: string | null; artistName: string | null;
fileName?: string | null; fileName?: string | null;
dub: boolean | null; dub: boolean;
rebroadcast: boolean | null; rebroadcast: boolean;
globalPercent: number;
}; };
/** /**
@@ -61,5 +63,6 @@ export function trackFromSongRow(row: SongRowLike): Track | null {
fileName, fileName,
dub: row.dub, dub: row.dub,
rebroadcast: row.rebroadcast, rebroadcast: row.rebroadcast,
globalPercent: row.globalPercent,
}; };
} }

View File

@@ -1,20 +1,5 @@
export const SongTypeMap: Record<string, number> = { import { AmqSongLinkTypeMapReverse } from "$lib/types/amq";
OP: 1,
ED: 2,
INS: 3,
};
// Map number back to string for displaying in UI export function songTypeNumberLabel(type: number, number: number) {
export const SongTypeReverseMap: Record<number, string> = { return `${AmqSongLinkTypeMapReverse[type as keyof typeof AmqSongLinkTypeMapReverse] ?? `T${type}`}${number ? number : ""}`;
1: "OP", }
2: "ED",
3: "INS",
};
export const SongCategoryMap: Record<number, string> = {
0: "None",
1: "Instrumental",
2: "Chanting",
3: "Character",
4: "Standard",
};

View File

@@ -74,6 +74,9 @@
songName: s.songName, songName: s.songName,
artistName: s.artistName, artistName: s.artistName,
fileName: s.fileName ?? null, fileName: s.fileName ?? null,
dub: Boolean(s.dub),
rebroadcast: Boolean(s.rebroadcast),
globalPercent: s.globalPercent,
}), }),
) )
.filter((t) => t !== null); .filter((t) => t !== null);
@@ -98,6 +101,9 @@
songName: s.songName, songName: s.songName,
artistName: s.artistName, artistName: s.artistName,
fileName: s.fileName ?? null, fileName: s.fileName ?? null,
dub: Boolean(s.dub),
rebroadcast: Boolean(s.rebroadcast),
globalPercent: s.globalPercent,
}), }),
) )
.filter((t) => t !== null); .filter((t) => t !== null);
@@ -157,8 +163,7 @@
class="rounded border px-3 py-2 text-sm" class="rounded border px-3 py-2 text-sm"
placeholder="Type to search by name…" placeholder="Type to search by name…"
value={params.q} value={params.q}
oninput={(e) => oninput={(e) => (params.q = (e.currentTarget as HTMLInputElement).value)}
(params.q = (e.currentTarget as HTMLInputElement).value)}
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
/> />
@@ -176,10 +181,7 @@
<li class="rounded border px-3 py-2"> <li class="rounded border px-3 py-2">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<a <a class="font-medium hover:underline" href={animeHref(a.annId)}>
class="font-medium hover:underline"
href={animeHref(a.annId)}
>
{a.mainName} {a.mainName}
</a> </a>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
@@ -196,8 +198,7 @@
type="button" type="button"
class="btn-icon" class="btn-icon"
onclick={() => void playAllNextForAnime(a)} onclick={() => void playAllNextForAnime(a)}
disabled={a.opCount + a.edCount + a.insertCount === disabled={a.opCount + a.edCount + a.insertCount === 0}
0}
title="Play all next" title="Play all next"
aria-label="Play all next" aria-label="Play all next"
> >
@@ -208,8 +209,7 @@
type="button" type="button"
class="btn-icon" class="btn-icon"
onclick={() => void queueAllForAnime(a)} onclick={() => void queueAllForAnime(a)}
disabled={a.opCount + a.edCount + a.insertCount === disabled={a.opCount + a.edCount + a.insertCount === 0}
0}
title="Queue all" title="Queue all"
aria-label="Queue all" aria-label="Queue all"
> >

View File

@@ -38,6 +38,7 @@
fileName: s.fileName ?? null, fileName: s.fileName ?? null,
dub: Boolean(s.dub), dub: Boolean(s.dub),
rebroadcast: Boolean(s.rebroadcast), rebroadcast: Boolean(s.rebroadcast),
globalPercent: s.globalPercent,
}), }),
) )
.filter((t) => t !== null); .filter((t) => t !== null);

View File

@@ -64,6 +64,7 @@
fileName: r.fileName, fileName: r.fileName,
dub: Boolean(r.dub), dub: Boolean(r.dub),
rebroadcast: Boolean(r.rebroadcast), rebroadcast: Boolean(r.rebroadcast),
globalPercent: r.globalPercent,
}), }),
) )
.filter((t) => t !== null), .filter((t) => t !== null),

View File

@@ -51,6 +51,7 @@
fileName: r.fileName, fileName: r.fileName,
dub: Boolean(r.dub), dub: Boolean(r.dub),
rebroadcast: Boolean(r.rebroadcast), rebroadcast: Boolean(r.rebroadcast),
globalPercent: r.globalPercent,
}), }),
) )
.filter((t) => t !== null), .filter((t) => t !== null),