From e958322d43df601ff53646ea8c4e4d86cdc3d70d Mon Sep 17 00:00:00 2001 From: intergrav Date: Wed, 15 Jul 2026 02:32:07 -0400 Subject: [PATCH] feat: rewrite queue - auto resizing columns to fit necessary space - improve consistency with gaps (1rem now to match padding-inline of row) - title column setting for consistency - many other improvements --- src/lib/Queue.svelte | 548 +++++++++++++++++---------- src/lib/dialogs/SettingsModal.svelte | 3 +- src/lib/settings.svelte.ts | 1 + 3 files changed, 350 insertions(+), 202 deletions(-) diff --git a/src/lib/Queue.svelte b/src/lib/Queue.svelte index a86a700..5a6a43c 100644 --- a/src/lib/Queue.svelte +++ b/src/lib/Queue.svelte @@ -34,18 +34,174 @@ import { songArtId, } from "./client.svelte.js"; import ContextMenu, { type Item } from "./components/ContextMenu.svelte"; - -type DragWindow = Window & { _tinysub_drag?: () => Promise }; - import VirtualList from "./components/VirtualList.svelte"; import { offlineManager } from "./offline/offlineManager.svelte.js"; import { settings } from "./settings.svelte.js"; +type DragWindow = Window & { _tinysub_drag?: () => Promise }; +type QueueTrack = (typeof queue.tracks)[number]; +type QueueColumn = { + enabled: boolean; + header: string; + numeric?: boolean; + value: (track: QueueTrack, index: number) => string | number; +}; + let menu = $state<{ x: number; y: number; index: number } | null>(null), drag = $state<{ index: number; isAfter: boolean } | null>(null), scrollToIndex = $state<(index: number) => void>(), groupPhase = $state(null), - groupFrom = $state(-1); + groupFrom = $state(-1), + queueElement = $state(), + queueFont = $state(""); + +const measureCache = new Map(); +const ratings = [1, 2, 3, 4, 5] as const; +const favoriteSymbol = "♥"; +const ratingSymbol = "★"; +const ratingHeader = ratingSymbol.repeat(ratings.length); +const downloadSymbols = { + idle: "⤓", + downloaded: "✓", + progress: "↻", +} as const; +let measureContext: CanvasRenderingContext2D | null = null; + +function updateQueueFont() { + if (!queueElement) return; + const style = getComputedStyle(queueElement); + const font = style.font || `${style.fontSize} ${style.fontFamily}`; + if (font !== queueFont) { + queueFont = font; + measureCache.clear(); + } +} + +$effect(() => { + settings.fontSize; + if (!queueElement) return; + untrack(updateQueueFont); + const frame = requestAnimationFrame(updateQueueFont); + return () => cancelAnimationFrame(frame); +}); + +function measureText(value: string | number) { + const text = String(value ?? ""); + const cached = measureCache.get(text); + if (cached !== undefined) return cached; + + if (!measureContext) { + measureContext = document.createElement("canvas").getContext("2d"); + } + if (!measureContext) return 0; + measureContext.font = queueFont; + const width = Math.ceil(measureContext.measureText(text).width) + 1; + measureCache.set(text, width); + return width; +} + +let queueColumns = $derived.by((): QueueColumn[] => [ + { + enabled: settings.enableTitle, + header: t("common.title"), + value: (track) => track.title, + }, + { + enabled: settings.enableArtist, + header: t("common.artist"), + value: (track) => track.artist, + }, + { + enabled: settings.enableAlbum, + header: t("common.album"), + value: (track) => track.album, + }, + { + enabled: settings.enableGenre, + header: t("common.genre"), + value: (track) => + track.genres?.map((genre) => genre.name).join(", ") || track.genre || "", + }, + { + enabled: settings.enableTime, + header: t("common.time"), + numeric: true, + value: (track) => (isRadio(track) ? "" : formatTime(track.duration)), + }, + { + enabled: settings.enableYear, + header: t("common.year"), + numeric: true, + value: (track) => track.year || "", + }, + { + enabled: settings.enableQuality, + header: t("common.quality"), + numeric: true, + value: (track) => + `${track.suffix || ""}${track.suffix && track.bitRate ? " " : ""}${track.bitRate || ""}`, + }, + { + enabled: settings.enablePlayCount, + header: t("common.play_count"), + numeric: true, + value: (track) => track.playCount ?? track.playcount ?? "", + }, + { + enabled: settings.enableDisc, + header: t("common.disc_num"), + numeric: true, + value: (track) => track.discNumber || "", + }, + { + enabled: settings.enableTrackNum, + header: t("common.track_num"), + numeric: true, + value: (track) => track.track || "", + }, + { + enabled: settings.enableQueueNum, + header: t("common.queue_num"), + numeric: true, + value: (_, index) => index + 1, + }, +]); + +function columnWidth(header: string, value: QueueColumn["value"]) { + let width = measureText(header); + for (let index = 0; index < queue.tracks.length; index++) { + width = Math.max(width, measureText(value(queue.tracks[index], index))); + } + return width; +} + +let textColumns = $derived( + queueColumns.filter((column) => column.enabled && !column.numeric), +); +let numericColumns = $derived( + queueColumns.filter((column) => column.enabled && column.numeric), +); + +let columnTemplate = $derived.by(() => { + const template = settings.artSong > 0 ? [`${settings.artSong}px`] : []; + + for (const column of textColumns) { + template.push(`minmax(0, ${columnWidth(column.header, column.value)}px)`); + } + template.push("minmax(0, 1fr)"); + for (const column of numericColumns) { + template.push(`minmax(0, ${columnWidth(column.header, column.value)}px)`); + } + if (settings.enableFavorites) + template.push(`${measureText(favoriteSymbol)}px`); + if (settings.enableRatings) template.push(`${measureText(ratingHeader)}px`); + if (settings.downloads && settings.enableDownloads) { + template.push( + `${Math.max(...Object.values(downloadSymbols).map(measureText))}px`, + ); + } + return template.join(" "); +}); $effect(() => { if (!ui.selectionMode) { @@ -79,53 +235,71 @@ function onDragStart(event: DragEvent, index: number) { function onDragOver(event: DragEvent, index: number) { if (!event.dataTransfer) return; event.preventDefault(); - const isLibrary = event.dataTransfer.types.includes( + event.dataTransfer.dropEffect = event.dataTransfer.types.includes( "application/tinysub-library", - ); - event.dataTransfer.dropEffect = isLibrary ? "copy" : "move"; + ) + ? "copy" + : "move"; const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect(); - drag = { index, isAfter: event.clientY > bounds.top + bounds.height / 2 }; + const isAfter = event.clientY > bounds.top + bounds.height / 2; + if (drag?.index !== index || drag.isAfter !== isAfter) + drag = { index, isAfter }; } async function onDrop(event: DragEvent) { if (!drag || !event.dataTransfer) return; + event.preventDefault(); const targetIndex = drag.index + (drag.isAfter ? 1 : 0); - if (event.dataTransfer.types.includes("application/tinysub-library")) { - const resolver = (window as DragWindow)._tinysub_drag; - if (resolver) { - const songs = await resolver(); - insertAt(targetIndex, songs); + try { + if (event.dataTransfer.types.includes("application/tinysub-library")) { + const resolver = (window as DragWindow)._tinysub_drag; + if (resolver) insertAt(targetIndex, await resolver()); + } else { + moveSelected(targetIndex); } - } else { - moveSelected(targetIndex); + } finally { + drag = null; } - drag = null; } function onKey(event: KeyboardEvent) { - if ((event.target as HTMLElement).tagName === "INPUT") return; + if ( + (event.target as HTMLElement).closest( + "input, button, select, textarea, [contenteditable]", + ) + ) + return; const key = event.key, isShiftPressed = event.shiftKey, isAltPressed = event.altKey; if (event.ctrlKey || event.metaKey) { - if (key === "a") { + const normalizedKey = key.toLowerCase(); + if (normalizedKey === "a") { event.preventDefault(); selectAll(); - } else if (key === "c") { + } else if (normalizedKey === "c") { event.preventDefault(); copy(); - } else if (key === "x") { + } else if (normalizedKey === "x") { event.preventDefault(); cut(); - } else if (key === "v") { + } else if (normalizedKey === "v") { event.preventDefault(); paste(); } return; } + if (key === "F10") { + if (isShiftPressed) { + event.preventDefault(); + showMenu(nav.head); + } + return; + } + const moves: Record void> = { ArrowUp: () => (isAltPressed ? reorder(-1) : moveHead(-1, isShiftPressed)), ArrowDown: () => (isAltPressed ? reorder(1) : moveHead(1, isShiftPressed)), @@ -139,7 +313,6 @@ function onKey(event: KeyboardEvent) { Escape: clearSel, ContextMenu: () => showMenu(nav.head), "`": () => showMenu(nav.head), - F10: () => event.shiftKey && showMenu(nav.head), }; if (moves[key]) { @@ -149,7 +322,19 @@ function onKey(event: KeyboardEvent) { } function getQueueItems(index: number): Item[] { - const isRow = index >= 0; + if (index < 0) { + return [ + { + label: t("common.paste"), + action: () => paste(), + disabled: !queue.clipboard.length, + }, + { type: "separator" }, + { label: t("common.sort"), items: getSortItems() }, + { label: t("common.clear"), action: clear }, + ]; + } + const selectedTracks = queue.sel .map((idx) => queue.tracks[idx]) .filter( @@ -159,102 +344,91 @@ function getQueueItems(index: number): Item[] { const shouldDownloadSelected = selectedTracks.some( (s) => !offlineManager.downloadedSongIds.has(s.id), ); - return ( - isRow + return [ + { label: t("common.play"), action: () => goto(index) }, + { label: t("action.play_next"), action: playNext }, + { type: "separator" }, + { + label: t("common.copy"), + action: copy, + disabled: !queue.sel.length, + }, + { + label: t("common.cut"), + action: cut, + disabled: !queue.sel.length, + }, + { + label: t("common.paste"), + action: () => paste(index + 1), + disabled: !queue.clipboard.length, + }, + ...(settings.downloads && selectedTracks.length ? [ - { label: t("common.play"), action: () => goto(index) }, - { label: t("action.play_next"), action: playNext }, - { type: "separator" }, - { - label: t("common.copy"), - action: copy, - disabled: !queue.sel.length, - }, { - label: t("common.cut"), - action: cut, - disabled: !queue.sel.length, + label: shouldDownloadSelected + ? t("action.download") + : t("action.remove_download"), + action: () => + shouldDownloadSelected + ? offlineManager.downloadSongs(selectedTracks) + : offlineManager.removeSongs(selectedTracks.map((s) => s.id)), }, + ] + : []), + { label: t("common.sort"), items: getSortItems() }, + { label: t("action.move_up"), action: () => reorder(-1) }, + { label: t("action.move_down"), action: () => reorder(1) }, + ...(selectedTracks.length + ? [ + { type: "separator" as const }, { - label: t("common.paste"), - action: () => paste(index + 1), - disabled: !queue.clipboard.length, + label: shouldFavoriteSelection + ? t("common.favorite") + : t("common.unfavorite"), + action: () => starSelected(shouldFavoriteSelection), }, - ...(settings.downloads && selectedTracks.length - ? [ - { - label: shouldDownloadSelected - ? t("action.download") - : t("action.remove_download"), - action: () => - shouldDownloadSelected - ? offlineManager.downloadSongs(selectedTracks) - : offlineManager.removeSongs( - selectedTracks.map((s) => s.id), - ), - }, - ] - : []), - { label: t("common.sort"), items: getSortItems() }, - { label: t("action.move_up"), action: () => reorder(-1) }, - { label: t("action.move_down"), action: () => reorder(1) }, - ...(selectedTracks.length - ? [ - { type: "separator" as const }, - { - label: shouldFavoriteSelection - ? t("common.favorite") - : t("common.unfavorite"), - action: () => starSelected(shouldFavoriteSelection), - }, - { - label: t("common.rating"), - items: [ - { label: "★★★★★", action: () => rateSelected(5) }, - { label: "★★★★", action: () => rateSelected(4) }, - { label: "★★★", action: () => rateSelected(3) }, - { label: "★★", action: () => rateSelected(2) }, - { label: "★", action: () => rateSelected(1) }, - { - label: t("common.none"), - action: () => rateSelected(0), - }, - ], - }, - ] - : []), - { type: "separator" }, - { label: t("common.clear"), action: clear }, - ] - : [ { - label: t("common.paste"), - action: () => paste(), - disabled: !queue.clipboard.length, + label: t("common.rating"), + items: [ + { label: "★★★★★", action: () => rateSelected(5) }, + { label: "★★★★", action: () => rateSelected(4) }, + { label: "★★★", action: () => rateSelected(3) }, + { label: "★★", action: () => rateSelected(2) }, + { label: "★", action: () => rateSelected(1) }, + { + label: t("common.none"), + action: () => rateSelected(0), + }, + ], }, - { type: "separator" }, - { label: t("common.sort"), items: getSortItems() }, - { label: t("common.clear"), action: clear }, ] - ) as Item[]; + : []), + { type: "separator" }, + { label: t("common.clear"), action: clear }, + ]; } function showMenu(index: number, x?: number, y?: number) { if (index >= 0 && !queue.sel.includes(index)) select(index); const row = index >= 0 - ? document.querySelector(`[data-id="queue-row-${index}"]`) + ? queueElement?.querySelector(`[data-id="queue-row-${index}"]`) : null; - const mx = x ?? window.innerWidth / 2; - const my = y ?? row?.getBoundingClientRect().bottom ?? window.innerHeight / 2; - menu = { x: mx, y: my, index }; + menu = { + x: x ?? window.innerWidth / 2, + y: y ?? row?.getBoundingClientRect().bottom ?? window.innerHeight / 2, + index, + }; }
(ui.activeContext = "queue")} @@ -270,9 +444,14 @@ function showMenu(index: number, x?: number, y?: number) {
{#snippet header()} -
+
{#if ui.selectionMode}
{/if} -
{t("common.title")}
- {#if settings.enableArtist} -
{t("common.artist")}
- {/if} - {#if settings.enableAlbum} -
{t("common.album")}
- {/if} - {#if settings.enableGenre} -
{t("common.genre")}
- {/if} - {#if settings.enableTime} -
{t("common.time")}
- {/if} - {#if settings.enableYear} -
{t("common.year")}
- {/if} - {#if settings.enableQuality} -
{t("common.quality")}
- {/if} - {#if settings.enablePlayCount}
- {t("common.play_count")} -
{/if} - {#if settings.enableDisc} -
{t("common.disc_num")}
- {/if} - {#if settings.enableTrackNum} -
{t("common.track_num")}
- {/if} - {#if settings.enableQueueNum} -
{t("common.queue_num")}
- {/if} + {#each textColumns as column} +
{column.header}
+ {/each} +
+ {#each numericColumns as column} +
{column.header}
+ {/each} {#if settings.enableFavorites} -
♥
+
{favoriteSymbol}
{/if} {#if settings.enableRatings} -
★★★★★
+
{ratingHeader}
{/if} {#if settings.downloads && settings.enableDownloads} -
⤓
+
{downloadSymbols.idle}
{/if} {/if}
@@ -439,54 +598,19 @@ function showMenu(index: number, x?: number, y?: number) { {/if}
{/if} -
{track.title}
- {#if settings.enableArtist} -
{track.artist}
- {/if} - {#if settings.enableAlbum} -
{track.album}
- {/if} - {#if settings.enableGenre} -
- {track.genres - ?.map((genre: { name: string }) => genre.name) - .join(", ") || - track.genre || - ""} -
- {/if} - {#if settings.enableTime && !isRadio(track)} -
{formatTime(track.duration)}
- {:else if settings.enableTime} -
- {/if} - {#if settings.enableYear} -
{track.year || ""}
- {/if} - {#if settings.enableQuality} -
- {track.suffix || ""} - {#if track.suffix && track.bitRate}{" "}{/if} - {track.bitRate || ""} -
- {/if} - {#if settings.enablePlayCount} + {#each textColumns as column} +
{column.value(track, index)}
+ {/each} +
+ {#each numericColumns as column}
- {track.playCount ?? track.playcount ?? ""} + {column.value(track, index)}
- {/if} - {#if settings.enableDisc} -
{track.discNumber || ""}
- {/if} - {#if settings.enableTrackNum} -
{track.track || ""}
- {/if} - {#if settings.enableQueueNum} -
{index + 1}
- {/if} + {/each} {#if settings.enableFavorites && !isRadio(track)}
{:else if settings.enableFavorites} @@ -503,8 +627,9 @@ function showMenu(index: number, x?: number, y?: number) { {/if} {#if settings.enableRatings && !isRadio(track)}
- {#each [1, 2, 3, 4, 5] as rating} + {#each ratings as rating} {/each}
@@ -543,13 +668,18 @@ function showMenu(index: number, x?: number, y?: number) { : offlineManager.downloadSong(track); }} > - {isDownloaded ? "✓" : isProgress ? "↻" : "⤓"} + {isDownloaded + ? downloadSymbols.downloaded + : isProgress + ? downloadSymbols.progress + : downloadSymbols.idle} {/if}
{/if}