Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426<script lang="ts">import { tick, untrack } from "svelte";import { SvelteMap } from "svelte/reactivity";import { slide } from "svelte/transition";import { Virtualizer, type VirtualizerHandle } from "virtua/svelte";import { api, canPlayTrack, isRadio, setRating, songAlbumArtist, songArtist, toggleStar,} from "../api/client.svelte.js";
import type { Song } from "../api/types.js";import { trackInfoItem } from "../api/types.js";import { normalizeColumnOrder, settings } from "../app/settings.svelte.js";import ContextMenu, { type Item } from "../components/ContextMenu.svelte";import MediaAction from "../components/MediaAction.svelte";import Icon from "../icons/Icon.svelte";import { t } from "../languages/translate.svelte.js";import { libraryDragType } from "../library/store.svelte.js";import { offlineManager } from "../offline/offlineManager.svelte.js";import { formatTime } from "../playback/player.svelte.js";import { canReorder, clear, clearSel, copy, cut, getQueueFilterNeedle, getSortItems, getVisibleQueueIndices, goto, insertAt, isSelected, isStopAfter, moveHead, moveSelected, paste, playNext, type QueueMove, queue, queueFilter, rateSelected, redo, remove, reorder, select, selectAll, selectEdge, starSelected, toggleSelectionRange, toggleStopAfter, undo,} from "../playback/queue.svelte.js";import { songArtId } from "../ui/artwork.svelte.js";import { attachEntry, cancelEntryAnimation, entryMotion, fadeImage, motionEnabled, subtleMoveMotion, transparencyEnabled,} from "../ui/motion.svelte.js";import { scrollFade } from "../ui/scrollFade.js";import { nav, shouldIgnoreKey, ui } from "../ui/state.svelte.js";import { getVirtualizerPageSize } from "./virtualizer.js";
type DragWindow = Window & { _tinysub_drag?: () => Promise<Song | Song[]> };type QueueTrack = (typeof queue.tracks)[number];type QueueColumn = { id: string; enabled: boolean; header: string; numeric?: boolean; kind?: "artwork" | "favorite" | "rating" | "download"; 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), queueVirtualizer = $state<VirtualizerHandle>(), groupPhase = $state<null | "from" | "to">(null), groupFrom = $state(-1), queueElement = $state<HTMLElement>(), queueFont = $state(""), headerHeight = $state(0), sortExpanded = $state(false);
$effect(() => { const text = queueFilter.text; if (!text.trim()) { queueFilter.debounced = ""; return; } const timer = setTimeout(() => { queueFilter.debounced = text; }, 300); return () => clearTimeout(timer);});
const hasFilter = $derived(queueFilter.text.trim() !== "");
const measureCache = new Map<string, number>();const stop = (action: () => void) => (e: MouseEvent) => { e.stopPropagation(); action();};const moveAnimations = new WeakMap<HTMLElement, Animation>();let pendingMoveAnimation: | { moves: QueueMove[]; selected?: Set<number>; } | undefined;let pendingEntryAnimations = $state(new SvelteMap<number, number>());let entryGen = $state(0);let programmaticScroll = false;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 resolvedFont = style.font || `${style.fontSize} ${style.fontFamily}`; if (resolvedFont === queueFont) return; queueFont = resolvedFont; measureCache.clear();}
$effect(() => { settings.fontSize; settings.fontFamily; 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;}
const queueColumns = $derived<QueueColumn[]>([ { id: "enableQueueNum", enabled: settings.enableQueueNum, header: "#", numeric: true, value: (_, index) => index + 1, }, { id: "enableArtwork", enabled: settings.enableArtwork && settings.artSong > 0, header: "", kind: "artwork", }, { id: "enableTitle", enabled: settings.enableTitle, header: t("common.title"), value: (track) => track.title, }, { id: "enableAlbumArtist", enabled: settings.enableAlbumArtist, header: t("common.album_artist"), value: songAlbumArtist, }, { id: "enableArtist", enabled: settings.enableArtist, header: t("common.artist"), value: songArtist, }, { id: "enableAlbum", enabled: settings.enableAlbum, header: t("common.album"), value: (track) => track.album, }, { id: "enableGenre", enabled: settings.enableGenre, header: t("common.genre"), value: (track) => track.genres?.map((genre) => genre.name).join(", ") || track.genre || "", }, { id: "enableYear", enabled: settings.enableYear, header: t("common.year"), numeric: true, value: (track) => track.year || "", }, { id: "enableTime", enabled: settings.enableTime, header: t("common.time"), numeric: true, value: (track) => (isRadio(track) ? "" : formatTime(track.duration)), }, { id: "enableQuality", enabled: settings.enableQuality, header: t("common.quality"), numeric: true, value: (track) => `${track.suffix || ""}${track.suffix && track.bitRate ? " " : ""}${track.bitRate || ""}`, }, { id: "enablePlayCount", enabled: settings.enablePlayCount, header: t("common.play_count"), numeric: true, value: (track) => track.playCount ?? track.playcount ?? "", }, { id: "enableDisc", enabled: settings.enableDisc, header: t("common.disc_num"), numeric: true, value: (track) => track.discNumber || "", }, { id: "enableTrackNum", enabled: settings.enableTrackNum, header: t("common.track_num"), numeric: true, value: (track) => track.track || "", }, { id: "enableFavorites", enabled: settings.enableFavorites, header: favoriteSymbol, kind: "favorite", }, { id: "enableRatings", enabled: settings.enableRatings, header: ratingHeader, kind: "rating", }, { id: "enableDownloads", enabled: settings.downloads && settings.enableDownloads, header: downloadSymbols.idle, kind: "download", },]);
function measuredColumnTrack(column: QueueColumn) { let width = measureText(column.header); untrack(() => { for (let index = 0; index < queue.tracks.length; index++) { width = Math.max( width, measureText(column.value?.(queue.tracks[index], index) ?? ""), ); } }); return `minmax(0, ${width}px)`;}
const visibleColumns = $derived( normalizeColumnOrder(settings.columnOrder) .map((id) => queueColumns.find((column) => column.id === id)) .filter((column): column is QueueColumn => !!column?.enabled),);
const columnTemplate = $derived.by(() => { queue.contentVersion; queueFont; const template: string[] = []; for (const column of visibleColumns) { if (column.kind === "artwork") { template.push(`${settings.artSong}px`); } else if (column.kind === "download") { template.push( `${Math.max(...Object.values(downloadSymbols).map(measureText))}px`, ); } else if (column.kind) { template.push(`${measureText(column.header)}px`); } else if (column.numeric) { template.push(measuredColumnTrack(column)); } else { template.push("minmax(0, 1fr)"); } } return template.join(" ");});const scrollFadeEnabled = $derived(!settings.borders && transparencyEnabled());
$effect(() => { if (!ui.selectionMode) { groupPhase = null; groupFrom = -1; }});
function scrollToIndex(index: number, center = false, smooth = false) { if ( !queueVirtualizer || !headerHeight || index < 0 || index >= queue.tracks.length ) return; const virt = getVisibleQueueIndices().indexOf(index); if (virt < 0) return; if (!center) { const scrollOffset = queueVirtualizer.getScrollOffset(); const viewportSize = queueVirtualizer.getViewportSize(); const itemStart = headerHeight + queueVirtualizer.getItemOffset(virt); const itemEnd = itemStart + queueVirtualizer.getItemSize(virt); const target = itemStart < scrollOffset + headerHeight ? itemStart - headerHeight : itemEnd > scrollOffset + viewportSize ? itemEnd - viewportSize : null; if (target === null || Math.abs(target - scrollOffset) <= 1) return; programmaticScroll = true; queueVirtualizer.scrollTo(target); return; } const target = headerHeight + queueVirtualizer.getItemOffset(virt) + (queueVirtualizer.getItemSize(virt) - queueVirtualizer.getViewportSize()) / 2; if (Math.abs(target - queueVirtualizer.getScrollOffset()) <= 1) return; const shouldAnimate = smooth && motionEnabled() && Math.abs( queueVirtualizer.getItemOffset(virt) - queueVirtualizer.getScrollOffset(), ) <= queueVirtualizer.getViewportSize() * 8; programmaticScroll = true; queueVirtualizer.scrollToIndex(virt, { align: "center", smooth: shouldAnimate, });}
let prevQueueFilterNeedle: string | null = null;$effect(() => { const needle = getQueueFilterNeedle(); const prev = prevQueueFilterNeedle; prevQueueFilterNeedle = needle; if (prev === null || needle === prev) return; if (motionEnabled()) { entryGen++; const pending = new SvelteMap<number, number>(); const visible = untrack(() => getVisibleQueueIndices()); for (let virt = 0; virt < visible.length; virt++) { const orig = visible[virt]; pending.set(queue.keys[orig] ?? -orig - 1, virt); } pendingEntryAnimations = pending; } void tick().then(() => { if (!queueVirtualizer || !headerHeight) return; if (needle && !getVisibleQueueIndices().includes(nav.head)) { programmaticScroll = true; queueVirtualizer.scrollTo(0); } });});
let prevNavHead = -1;$effect(() => { const head = nav.head; if (head === prevNavHead) return; prevNavHead = head; if (head >= 0 && getVisibleQueueIndices().includes(head)) scrollToIndex(head);});
$effect(() => { const scrollRequest = queue.scrollRequest; if (scrollRequest !== null && queueVirtualizer && headerHeight) { scrollToIndex( scrollRequest.index, scrollRequest.center, scrollRequest.smooth, ); queue.scrollRequest = null; }});
function getMountedRows() { const rows = queueElement?.querySelectorAll<HTMLElement>(".row[data-queue-key]") ?? []; return new Map([...rows].map((row) => [Number(row.dataset.queueKey), row]));}
function pruneUnmountedEntryAnimations() { if (!pendingEntryAnimations.size) return; const mountedKeys = new Set(getMountedRows().keys()); for (const key of pendingEntryAnimations.keys()) { if (!mountedKeys.has(key)) pendingEntryAnimations.delete(key); }}
function handleScroll() { if (programmaticScroll) return; pendingEntryAnimations.clear();}
function handleUserScroll() { programmaticScroll = false; handleScroll();}
function handleScrollEnd() { programmaticScroll = false; pruneUnmountedEntryAnimations();}
function animateMoves(moves: QueueMove[], selected?: Set<number>) { if (!motionEnabled() || !headerHeight || !moves.length) return;
if (pendingMoveAnimation) { pendingMoveAnimation.moves.push(...moves); return; }
pendingMoveAnimation = { moves, selected }; void tick().then(() => requestAnimationFrame(() => { const pending = pendingMoveAnimation; if (!pending) return; pendingMoveAnimation = undefined; if (!motionEnabled() || !headerHeight) return;
const rows = getMountedRows(); const rankOf = getQueueFilterNeedle() !== "" ? new Map( getVisibleQueueIndices().map((index, rank) => [index, rank]), ) : null; const netMoves = new Map<number, QueueMove>(); for (const move of pending.moves) { if (!rows.has(move.key)) continue; const previous = netMoves.get(move.key); if (previous) { previous.to = move.to; } else netMoves.set(move.key, { ...move }); } for (const { key, from, to } of netMoves.values()) { if (from === to) continue; const row = rows.get(key); if (!row) continue; const isSelected = pending.selected?.has(key) ?? false; const fromRank = rankOf?.get(from); const toRank = rankOf?.get(to); const distance = fromRank !== undefined && toRank !== undefined ? fromRank - toRank : from - to; const maxOffset = isSelected ? 2 : 1; const offset = Math.max(-maxOffset, Math.min(maxOffset, distance)) * headerHeight; cancelEntryAnimation(row); moveAnimations.get(row)?.cancel(); const animation = row.animate( [{ translate: `0 ${offset}px` }, { translate: "0" }], { duration: isSelected ? entryMotion.duration : subtleMoveMotion.duration, easing: subtleMoveMotion.easing, }, ); moveAnimations.set(row, animation); animation.onfinish = animation.oncancel = () => { if (moveAnimations.get(row) === animation) moveAnimations.delete(row); }; } }), );}
function moveWithAnimation(move: (visible: Set<number>) => QueueMove[]) { const moves = move(visibleKeySet()); if (!moves.length) return; const selected = motionEnabled() ? new Set(queue.sel.map((index) => queue.keys[index])) : undefined; animateMoves(moves, selected);}
const noKeys: Set<number> = new Set();const nonScrollingKeys = new Set([ "Enter", "Delete", "Backspace", "Escape", "ContextMenu", "`",]);function visibleKeySet(): Set<number> { if (!motionEnabled()) return noKeys; return new Set(getMountedRows().keys());}
$effect(() => { const moves = queue.moveRequest; if (!moves) return; queue.moveRequest = null; animateMoves(moves);});
$effect(() => { const entries = queue.entryRequest; if (!entries) return; queue.entryRequest = null; if (!entries.length || !motionEnabled()) return; for (const [index, key] of entries.entries()) { if (!pendingEntryAnimations.has(key)) pendingEntryAnimations.set(key, index); }});
$effect(() => { if (!motionEnabled()) { pendingEntryAnimations.clear(); return; } const keys = new Set(queue.keys); untrack(() => { if (!pendingEntryAnimations.size) return; const pending = pendingEntryAnimations; for (const key of pending.keys()) { if (!keys.has(key)) pending.delete(key); } });});
function finishEntryAnimation(key: number) { pendingEntryAnimations.delete(key);}
function reorderWithAnimation(direction: -1 | 1, steps = 1) { moveWithAnimation((visible) => reorder(direction, steps, visible));}
function getPageSize() { return getVirtualizerPageSize(queueVirtualizer, 0, headerHeight);}
function onDragStart(event: DragEvent, index: number) { if (!event.dataTransfer) return; if (!isSelected(index)) select(index); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", "");}
function onDragOver(event: DragEvent, index: number) { if (!event.dataTransfer) return; event.preventDefault(); event.dataTransfer.dropEffect = event.dataTransfer.types.includes( libraryDragType, ) ? "copy" : "move";
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect(); 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);
try { if (event.dataTransfer.types.includes(libraryDragType)) { const resolver = (window as DragWindow)._tinysub_drag; if (resolver) insertAt(targetIndex, await resolver(), false, visibleKeySet()); } else { moveWithAnimation((visible) => moveSelected(targetIndex, visible)); } } catch (err) { console.error("failed to complete drop:", err); } finally { drag = null; }}
function onKey(event: KeyboardEvent) { if (shouldIgnoreKey(event)) return; const key = event.key, isShiftPressed = event.shiftKey, isAltPressed = event.altKey; if (event.ctrlKey || event.metaKey) { const normalizedKey = key.toLowerCase(); if (normalizedKey === "a") { event.preventDefault(); selectAll(); } else if (normalizedKey === "c") { event.preventDefault(); copy(); } else if (normalizedKey === "x") { event.preventDefault(); cut(visibleKeySet()); } else if (normalizedKey === "v") { event.preventDefault(); paste(undefined, visibleKeySet()); } return; }
if (key === "F10") { if (isShiftPressed) { event.preventDefault(); showMenu(nav.head); } return; }
const moves: Record<string, () => void> = { ArrowUp: () => isAltPressed ? reorderWithAnimation(-1) : moveHead(-1, isShiftPressed), ArrowDown: () => isAltPressed ? reorderWithAnimation(1) : moveHead(1, isShiftPressed), Home: () => isAltPressed ? reorderWithAnimation(-1, queue.tracks.length) : selectEdge(-1, isShiftPressed), End: () => isAltPressed ? reorderWithAnimation(1, queue.tracks.length) : selectEdge(1, isShiftPressed), PageUp: () => isAltPressed ? reorderWithAnimation(-1, getPageSize()) : moveHead(-getPageSize(), isShiftPressed), PageDown: () => isAltPressed ? reorderWithAnimation(1, getPageSize()) : moveHead(getPageSize(), isShiftPressed), Enter: () => queue.sel.length && goto(queue.sel[0]), Delete: () => remove(visibleKeySet()), Backspace: () => remove(visibleKeySet()), Escape: clearSel, ContextMenu: () => showMenu(nav.head), "`": () => showMenu(nav.head), };
if (moves[key]) { if (!nonScrollingKeys.has(key)) handleUserScroll(); event.preventDefault(); moves[key](); }}
function copyVisibleQueue() { const indices = getVisibleQueueIndices(); if (!indices.length) return; queue.clipboard = indices.map((index) => queue.tracks[index]);}
function cutQueue() { copyVisibleQueue(); clearSel(); clear(visibleKeySet());}
function getQueueItems(index: number): Item[] { const sortItems = () => getSortItems(visibleKeySet(), sortExpanded, () => (sortExpanded = true)); if (index < 0) { const empty = getVisibleQueueIndices().length === 0; return [ { label: t("common.copy"), action: copyVisibleQueue, disabled: empty }, { label: t("common.cut"), action: cutQueue, disabled: empty }, { label: t("common.paste"), action: () => paste(undefined, visibleKeySet()), disabled: !queue.clipboard.length, }, { type: "separator" }, { label: t("common.select_all"), action: selectAll, disabled: empty }, { type: "separator" }, { label: t("common.sort"), items: sortItems(), disabled: empty, }, { type: "separator" }, { label: t("common.clear"), action: () => clear(visibleKeySet()), disabled: empty, }, ]; }
const selectedTracks = queue.sel .map((idx) => queue.tracks[idx]) .filter((song): song is QueueTrack => !!song && !isRadio(song)); const shouldFavoriteSelection = selectedTracks.some((song) => !song.starred); const selectedTrack = queue.sel.length === 1 ? queue.tracks[queue.sel[0]] : undefined; const shouldDownloadSelected = selectedTracks.some( (s) => !offlineManager.downloadedSongIds.has(s.id), ); const targetTrack = queue.tracks[index]; return [ { label: t("common.play"), action: () => goto(index) }, { label: t("action.play_next"), action: playNext }, { label: t("action.stop_after"), action: () => toggleStopAfter(index), disabled: !targetTrack || isRadio(targetTrack), }, { type: "separator" }, { label: t("common.copy"), action: copy, disabled: !queue.sel.length, }, { label: t("common.cut"), action: () => cut(visibleKeySet()), disabled: !queue.sel.length, }, { label: t("common.paste"), action: () => paste(index + 1, visibleKeySet()), disabled: !queue.clipboard.length, }, { type: "separator" }, ...(queue.sel.length > 1 ? [{ label: t("common.sort"), items: sortItems() }] : []), { label: t("action.move_up"), action: () => reorderWithAnimation(-1), }, { label: t("action.move_down"), action: () => reorderWithAnimation(1), }, ...(selectedTracks.length ? [ { type: "separator" as const }, { label: shouldFavoriteSelection ? t("common.favorite") : t("common.unfavorite"), action: () => starSelected(shouldFavoriteSelection), }, { label: t("common.rating"), items: [ ...ratings .map((rating) => ({ label: ratingSymbol.repeat(rating) + "☆".repeat(ratings.length - rating), action: () => rateSelected(rating), })) .reverse(), { label: "☆".repeat(ratings.length), action: () => rateSelected(0), }, ], }, ] : []), ...(settings.downloads && selectedTracks.length ? [ { label: shouldDownloadSelected ? t("common.download") : t("action.remove_download"), disabled: offlineManager.isOfflineMode && shouldDownloadSelected, action: () => shouldDownloadSelected ? offlineManager.downloadSongs(selectedTracks) : offlineManager.removeSongs(selectedTracks.map((s) => s.id)), }, ] : []), ...(selectedTrack && !offlineManager.isOfflineMode ? [ { type: "separator" as const }, { label: t("common.info"), action: () => { ui.infoItem = trackInfoItem( selectedTrack, isRadio(selectedTrack), ); }, }, ] : []), { type: "separator" }, { label: t("common.clear"), action: () => clear(visibleKeySet()) }, ];}
function showMenu(index: number, x?: number, y?: number) { if (index >= 0 && !isSelected(index)) select(index); const row = index >= 0 ? queueElement?.querySelector(`[data-id="queue-row-${index}"]`) : null; sortExpanded = false; menu = { x: x ?? window.innerWidth / 2, y: y ?? row?.getBoundingClientRect().bottom ?? window.innerHeight / 2, index, };}</script>
<svelte:window ondragend={() => (drag = null)} />
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --><!-- svelte-ignore a11y_no_noninteractive_tabindex --><!-- biome-ignore-start lint/a11y/noNoninteractiveTabindex: Queue keyboard navigation --><section bind:this={queueElement} id="queue" class:scroll-fade={scrollFadeEnabled} aria-label={t("common.queue")} use:scrollFade={scrollFadeEnabled} style:--queue-art-size={`${settings.artSong}px`} style:--scroll-fade-top-offset={`${headerHeight}px`} style:--queue-columns={columnTemplate} onclick={clearSel} onkeydown={onKey} onwheel={handleUserScroll} ontouchmove={handleUserScroll} onfocusin={() => (ui.activeContext = "queue")} oncontextmenu={(e) => { if (e.target === e.currentTarget) { e.preventDefault(); clearSel(); showMenu(-1, e.clientX, e.clientY); } }} tabindex="0"> <div class="head" bind:clientHeight={headerHeight}> {#each visibleColumns as column (column.id)} <div class="col" class:col-number={column.numeric} class:col-artwork={column.kind === "artwork"} class:col-favorite={column.kind === "favorite"} class:col-rating={column.kind === "rating"} class:col-download={column.kind === "download"} > {column.header} </div> {/each} <div class="col col-touch"> <button type="button" tabindex="-1" aria-label={t( ui.selectionMode ? "common.deselect_all" : "common.select_all", )} aria-pressed={ui.selectionMode} onclick={stop(() => { ui.selectionMode = !ui.selectionMode; })} > {ui.selectionMode ? "✓" : "☐"} </button> </div> {#if ui.selectionMode} {@const hasSelection = queue.sel.length > 0} {@const selectKey = hasSelection ? "common.deselect_all" : "common.select_all"} {@const groupKey = groupPhase === "from" ? "action.group_from" : groupPhase === "to" ? "action.group_to" : "action.group"} {@const groupIcon = groupPhase === "from" ? "groupFrom" : groupPhase === "to" ? "groupTo" : "group"} <div role="dialog" aria-label={t("shortcut.extend_sel")}> <div> <button type="button" tabindex="-1" aria-label={t("common.undo")} title={t("common.undo")} onclick={stop(undo)} > <Icon name="undo" /> </button> <button type="button" tabindex="-1" aria-label={t("common.redo")} title={t("common.redo")} onclick={stop(redo)} > <Icon name="redo" /> </button> </div> <div> <button type="button" tabindex="-1" aria-label={t(selectKey)} title={t(selectKey)} onclick={stop(() => { if (hasSelection) clearSel(); else selectAll(); })} > <Icon name={hasSelection ? "check" : "selectAll"} /> </button> <button type="button" tabindex="-1" aria-label={t(groupKey)} title={t(groupKey)} onclick={stop(() => { if (groupPhase === null) { groupPhase = "from"; } else { groupPhase = null; groupFrom = -1; } })} > <Icon name={groupIcon} /> </button> </div> {#if hasSelection} <div transition:slide={{ axis: "x", duration: motionEnabled() ? entryMotion.duration : 0, }} > <button type="button" tabindex="-1" aria-label={t("action.move_up")} title={t("action.move_up")} disabled={!canReorder(-1)} onclick={stop(() => reorderWithAnimation(-1))} > <Icon name="moveUp" /> </button> <button type="button" tabindex="-1" aria-label={t("action.move_down")} title={t("action.move_down")} disabled={!canReorder(1)} onclick={stop(() => reorderWithAnimation(1))} > <Icon name="moveDown" /> </button> </div> {/if} </div> {/if} <div class="queue-filter" class:has-filter={hasFilter}> <input type="search" bind:value={queueFilter.text} placeholder={t("browse.filter")} aria-label={t("browse.filter")} autocomplete="off" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === "Escape") { if (hasFilter) queueFilter.text = ""; else e.currentTarget.blur(); } }} > </div> </div> <Virtualizer bind:this={queueVirtualizer} data={getVisibleQueueIndices()} getKey={(origIdx) => queue.keys[origIdx] ?? -origIdx - 1} bufferSize={600} startMargin={headerHeight} onscroll={handleScroll} onscrollend={handleScrollEnd} > {#snippet children( origIdx, virtIdx, )} {@const track = queue.tracks[origIdx]} {@const trackKey = queue.keys[origIdx] ?? -origIdx - 1} {@const radio = isRadio(track)} {@const entryIndex = pendingEntryAnimations.get(trackKey)} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <!-- biome-ignore lint/a11y/noStaticElementInteractions: Custom draggable queue rows --> <!-- biome-ignore lint/a11y/useKeyWithClickEvents: Custom queue row keyboard handling --> <div class="row" {@attach (element) => attachEntry( element, entryIndex, entryGen, trackKey, finishEntryAnimation, )} data-id="queue-row-{origIdx}" data-queue-key={trackKey} class:playing={queue.pos === origIdx} class:stop-after={isStopAfter(origIdx)} class:selected={isSelected(origIdx)} class:odd={virtIdx % 2 !== 0} class:over-above={drag?.index === origIdx && !drag.isAfter} class:over-below={drag?.index === origIdx && drag.isAfter} class:unavailable={!canPlayTrack(track)} draggable={true} ondragstart={(e) => onDragStart(e, origIdx)} ondragover={(e) => onDragOver(e, origIdx)} ondrop={onDrop} onclick={(e) => { e.stopPropagation(); if (groupPhase === "from") { groupFrom = origIdx; groupPhase = "to"; } else if (groupPhase === "to") { toggleSelectionRange(groupFrom, origIdx); groupPhase = null; groupFrom = -1; } else { select( origIdx, ui.selectionMode || e.ctrlKey || e.metaKey, e.shiftKey, ); } }} ondblclick={() => { if (ui.selectionMode) return; goto(origIdx); clearSel(); }} oncontextmenu={(e) => { e.preventDefault(); showMenu(origIdx, e.clientX, e.clientY); }} > {#each visibleColumns as column (column.id)} {#if column.kind === "artwork"} {@const artId = songArtId(track)} {@const coverArtUrl = artId ? api.getCoverArt(artId, settings.artSong) : ""} <div class="col col-artwork artwork"> {#if coverArtUrl} <img fetchpriority="low" decoding="async" alt="" src={coverArtUrl} srcset={coverArtUrl.startsWith("blob:") ? undefined : `${coverArtUrl} 1x, ${api.getCoverArt(artId, settings.artSong * 2)} 2x`} class="artwork-image" {@attach (image) => fadeImage(image, track.id)} > {/if} </div> {:else if column.kind === "favorite"} <div class="col col-favorite"> {#if !radio} <MediaAction kind="favorite" starred={!!track.starred} subtle tabindex={-1} onfavorite={stop(() => toggleStar(track, "song"))} /> {/if} </div> {:else if column.kind === "rating"} <div class="col col-rating"> {#if !radio} <MediaAction kind="rating" rating={track.userRating ?? 0} subtle tabindex={-1} onrating={(value, event) => { event.stopPropagation(); setRating(track, value); }} /> {/if} </div> {:else if column.kind === "download"} <div class="col col-download"> {#if !radio} {@const isDownloaded = offlineManager.downloadedSongIds.has( track.id, )} {@const isDownloading = !isDownloaded && (offlineManager.activeDownloadIds.has(track.id) || offlineManager.queuedDownloadIds.has(track.id))} <button type="button" tabindex="-1" class="pressable" disabled={!isDownloaded && offlineManager.isOfflineMode} class:active={isDownloaded} class:downloading={isDownloading} aria-busy={isDownloading} aria-label={t( isDownloaded ? "action.remove_download" : "common.download", )} onclick={(e) => { e.stopPropagation(); isDownloaded ? offlineManager.removeSongs([track.id]) : offlineManager.downloadSongs([track]); }} > {isDownloaded ? downloadSymbols.downloaded : isDownloading ? downloadSymbols.progress : downloadSymbols.idle} </button> {/if} </div> {:else} {@const value = String(column.value?.(track, origIdx) ?? "")} <div class="col" class:col-number={column.numeric} title={value}> {value} </div> {/if} {/each} <div class="col col-touch"> <button type="button" tabindex="-1" aria-label={t("shortcut.context_menu")} onclick={(e) => { e.stopPropagation(); const bounds = ( e.currentTarget as HTMLButtonElement ).getBoundingClientRect(); showMenu(origIdx, bounds.right, bounds.bottom); }} > ⋮ </button> </div> </div> {/snippet} </Virtualizer></section><!-- biome-ignore-end lint/a11y/noNoninteractiveTabindex: Queue keyboard navigation -->
{#if menu} <ContextMenu x={menu.x} y={menu.y} onclose={() => { menu = null; sortExpanded = false; }} items={getQueueItems(menu.index)} />{/if}
<style>#queue { overflow: hidden auto; &:not(:focus-within) .row.selected { background-color: var(--unfocused-background); color: var(--unfocused-text); }}
.head,.row { position: relative; display: grid; grid-template-columns: var(--queue-columns); align-items: center; padding-inline: 1rem; padding-block: round(0.125rem, 1px); gap: 1rem;
:global(html.motion) & :global(button) { transition-property: transform; }
@media (pointer: coarse) { padding-inline-end: 3.5rem; }}.head { position: sticky; inset-block-start: 0; z-index: 1; background-color: var(--background); border-block-end: var(--border-subtle);
> [role="dialog"] { position: absolute; inset-block-start: calc(100% + 0.25rem); inset-inline: 0; margin-inline: auto; inline-size: fit-content; z-index: 30; transform-origin: top center;
display: flex; gap: 1.5rem; padding-block: 0.375rem; padding-inline: 0.75rem; border: var(--border); background-color: Canvas; color: CanvasText; box-shadow: var(--shadow-sm); border-radius: var(--radius-md); corner-shape: squircle;
> div { display: flex; gap: 0.75rem; } }}:global(html.transparency) .head > [role="dialog"] { background-color: rgb(from Canvas r g b / 80%); backdrop-filter: blur(16px);}:global(html.motion) .head > [role="dialog"] { animation: pop-in 75ms ease-out;}.queue-filter { position: absolute; inset-block: 0; inset-inline-start: 0; inline-size: min(12rem, 50%); padding-inline-start: 1rem; background-color: var(--background); opacity: 0; pointer-events: none;
> input { inline-size: 100%; block-size: 100%; }}.head:hover .queue-filter,.queue-filter:focus-within,.queue-filter.has-filter { opacity: 1; pointer-events: auto;}.queue-filter:focus-within,.queue-filter.has-filter { inline-size: min(24rem, 50%);}:global(html.motion) .queue-filter { transition: opacity 150ms ease-out, inline-size 150ms ease-out;}.col { overflow: hidden; text-box: text; text-overflow: ellipsis; white-space: nowrap;
&:where(.col-number, .col-favorite, .col-download) { text-align: end; }
&.col-rating { display: flex; justify-content: flex-end; }
&.col-artwork { block-size: var(--queue-art-size); }
&.col-touch { display: none; position: absolute; inset-inline-end: 1rem; inline-size: 2rem;
@media (pointer: coarse) { display: block; } }}@keyframes spin { to { transform: rotate(1turn); }}:is(.head, .row) > .col-touch > button { inline-size: 100%; text-align: end;}.row { border-radius: var(--radius-sm); corner-shape: squircle;
&.odd { background-color: var(--layer); } &.playing { background-color: var(--playing-background); color: var(--playing-text); } &.playing::before, &.stop-after::after { content: ""; position: absolute; inset-inline-start: 0.25rem; inset-block-start: 50%; inline-size: 0.5rem; block-size: 0.5rem; translate: 0 -50%; } &.playing::before { background: currentColor; clip-path: polygon(0 0, 100% 50%, 0 100%); } &.stop-after::after { background: var(--accent-red); } &.playing.stop-after::before { translate: 0 calc(-50% - 0.325rem); } &.playing.stop-after::after { translate: 0 calc(-50% + 0.325rem); } &.selected { background-color: var(--selected-background); color: var(--selected-text); } &.over-above { box-shadow: inset 0 round(0.125rem, 1px) 0 var(--text); } &.over-below { box-shadow: inset 0 calc(-1 * round(0.125rem, 1px)) 0 var(--text); } &.unavailable { opacity: 0.5; }
> :is(.col-touch, .col-download) > button { opacity: 0.25;
&:hover { opacity: 0.5; } }
> .col-download > button { &.active, &.downloading { opacity: 1; }
&.downloading { :global(html.motion) & { animation: spin 1.5s linear infinite; } } }}.head > [role="dialog"] > div > button { display: flex;}</style>