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.
Svelte
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178<script lang="ts">import { tick, untrack } from "svelte";import { Virtualizer, type VirtualizerHandle } from "virtua/svelte";import { canPlayTrack, getSourceApi, isRadio, type Song, songAlbumArtist, songArtId, songArtist,} from "./client.svelte.js";import ContextMenu, { type Item } from "./components/ContextMenu.svelte";import MediaAction from "./components/MediaAction.svelte";import { trackInfoItem } from "./info.js";import { t } from "./languages/translate.svelte.js";import { animateEntryRows, entryMotion, fadeImage, motionEnabled, subtleMoveMotion,} from "./motion.js";import { offlineManager } from "./offline/offlineManager.svelte.js";import { formatTime } from "./player.svelte.js";import { clear, clearSel, copy, cut, getSortItems, goto, insertAt, isSelected, moveHead, moveSelected, paste, playNext, type QueueMove, queue, rateSelected, remove, reorder, select, selectAll, setRating, starSelected, toggleSelectionRange, toggleStar,} from "./queue.svelte.js";import { scrollFade } from "./scrollFade.js";import { settings } from "./settings.svelte.js";import { getSource, sourceDisplayName, sourceMarker, sources,} from "./sources.svelte.js";import { transparencyEnabled } from "./transparency.js";import { nav, ui } from "./ui.svelte.js";
type DragWindow = Window & { _tinysub_drag?: () => Promise<Song | Song[]> };type QueueTrack = (typeof queue.tracks)[number];type QueueColumn = { enabled: boolean; header: string; fixed?: boolean; 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), queueVirtualizer = $state<VirtualizerHandle>(), groupPhase = $state<null | "from" | "to">(null), groupFrom = $state(-1), queueElement = $state<HTMLElement>(), queueFont = $state(""), headerHeight = $state(0), lastHeaderHeight = $state(0);
const measureCache = new Map<string, number>();const moveAnimations = new WeakMap<HTMLElement, Animation>();let pendingMoveAnimation: | { moves: QueueMove[]; selected?: Set<number>; } | undefined;let pendingEntryKeys: Set<number> | undefined;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);});
$effect(() => { if (headerHeight > 0) lastHeaderHeight = headerHeight;});
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.by((): QueueColumn[] => [ { enabled: true, header: t("common.title"), value: (track) => track.title, }, { enabled: settings.enableAlbumArtist, header: t("common.album_artist"), value: songAlbumArtist, }, { enabled: settings.enableArtist, header: t("common.artist"), value: songArtist, }, { 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.enableYear, header: t("common.year"), numeric: true, value: (track) => track.year || "", }, { enabled: settings.enableTime, header: t("common.time"), numeric: true, value: (track) => (isRadio(track) ? "" : formatTime(track.duration)), }, { 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, },]);
const sourceColumn = $derived.by( (): QueueColumn => ({ enabled: sources.items.length > 1, header: "", fixed: true, value: (track) => { const source = getSource(track.sourceId); return source ? sourceMarker(source, sources.items) : ""; }, }),);
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 textColumns = $derived( queueColumns.filter( (column) => column.enabled && !column.numeric && !column.fixed, ),);const numericColumns = $derived( queueColumns.filter( (column) => column.enabled && column.numeric && !column.fixed, ),);const visibleColumns = $derived([...textColumns, ...numericColumns]);const showDownloads = $derived(settings.downloads && settings.enableDownloads);
const columnTemplate = $derived.by(() => { queue.contentVersion; queueFont; const template = sourceColumn.enabled ? [measuredColumnTrack(sourceColumn)] : []; if (settings.artSong > 0) template.push(`${settings.artSong}px`); template.push(`repeat(${textColumns.length}, minmax(0, 1fr))`); template.push(...numericColumns.map(measuredColumnTrack)); if (settings.enableFavorites) template.push(`${measureText(favoriteSymbol)}px`); if (settings.enableRatings) template.push(`${measureText(ratingHeader)}px`); if (showDownloads) { template.push( `${Math.max(...Object.values(downloadSymbols).map(measureText))}px`, ); } return template.join(" ");});const scrollFadeEnabled = $derived( settings.transparentOutlines && 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; if (!center) { const scrollOffset = queueVirtualizer.getScrollOffset(); const viewportSize = queueVirtualizer.getViewportSize(); const itemStart = headerHeight + queueVirtualizer.getItemOffset(index); const itemEnd = itemStart + queueVirtualizer.getItemSize(index); const target = itemStart < scrollOffset + headerHeight ? itemStart - headerHeight : itemEnd > scrollOffset + viewportSize ? itemEnd - viewportSize : null; if (target !== null) queueVirtualizer.scrollTo(target); return; } const shouldAnimate = smooth && motionEnabled() && Math.abs( queueVirtualizer.getItemOffset(index) - queueVirtualizer.getScrollOffset(), ) <= queueVirtualizer.getViewportSize() * 8; queueVirtualizer.scrollToIndex(index, { align: "center", smooth: shouldAnimate, });}
$effect(() => { if (nav.head >= 0) scrollToIndex(nav.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 animateEntries(keys: number[]) { if (!motionEnabled()) return;
if (pendingEntryKeys) { for (const key of keys) pendingEntryKeys.add(key); return; }
pendingEntryKeys = new Set(keys); void tick().then(() => requestAnimationFrame(() => { const entries = pendingEntryKeys; pendingEntryKeys = undefined; if (!entries || !motionEnabled() || !headerHeight) return; const rows = getMountedRows(); animateEntryRows( [...rows].filter(([key]) => entries.has(key)).map(([, row]) => row), ); }), );}
function animateMoves(moves: QueueMove[], selected?: Set<number>) { if (!motionEnabled() || !headerHeight || !moves.length) return;
if (pendingMoveAnimation) { pendingMoveAnimation.moves.push(...moves); return; }
pendingMoveAnimation = { moves: [...moves], selected }; void tick().then(() => requestAnimationFrame(() => { const pending = pendingMoveAnimation; if (!pending) return; pendingMoveAnimation = undefined; if (!motionEnabled() || !headerHeight) return;
const rows = getMountedRows(); 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; previous.fullDistance ||= move.fullDistance; } else netMoves.set(move.key, { ...move }); } for (const { key, from, to, fullDistance } of netMoves.values()) { if (from === to) continue; const row = rows.get(key); if (!row) continue;
const isSelected = pending.selected?.has(key) ?? false; const distance = from - to; const maxOffset = isSelected ? 2 : 1; const offset = (fullDistance ? distance : Math.max(-maxOffset, Math.min(maxOffset, distance))) * headerHeight; if (!offset) continue; moveAnimations.get(row)?.cancel(); const animation = row.animate( [{ translate: `0 ${offset}px` }, { translate: "0" }], { duration: fullDistance ? subtleMoveMotion.duration * 2 : 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: () => QueueMove[]) { const selected = motionEnabled() ? new Set(queue.sel.map((index) => queue.keys[index])) : undefined; const moves = move(); if (moves.length) animateMoves(moves, selected);}
$effect(() => { const moves = queue.moveRequest; if (!moves) return; queue.moveRequest = null; animateMoves(moves);});
$effect(() => { const entries = queue.entryRequest; if (!entries || !queueVirtualizer || !headerHeight) return; queue.entryRequest = null; animateEntries(entries);});
function reorderWithAnimation(direction: -1 | 1, steps = 1) { moveWithAnimation(() => reorder(direction, steps));}
function onDragStart(event: DragEvent, index: number) { if (!event.dataTransfer) return; if (!isSelected(index)) select(index); event.dataTransfer.effectAllowed = "move";}
function onDragOver(event: DragEvent, index: number) { if (!event.dataTransfer) return; event.preventDefault(); event.dataTransfer.dropEffect = event.dataTransfer.types.includes( "application/tinysub-library", ) ? "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("application/tinysub-library")) { const resolver = (window as DragWindow)._tinysub_drag; if (resolver) insertAt(targetIndex, await resolver()); } else { moveWithAnimation(() => moveSelected(targetIndex)); } } finally { drag = null; }}
function onKey(event: KeyboardEvent) { 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) { const normalizedKey = key.toLowerCase(); if (normalizedKey === "a") { event.preventDefault(); selectAll(); } else if (normalizedKey === "c") { event.preventDefault(); copy(); } else if (normalizedKey === "x") { event.preventDefault(); cut(); } else if (normalizedKey === "v") { event.preventDefault(); paste(); } 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) : select(0, false, isShiftPressed), End: () => isAltPressed ? reorderWithAnimation(1, queue.tracks.length) : select(queue.tracks.length - 1, false, isShiftPressed), PageUp: () => isAltPressed ? reorderWithAnimation(-1, 10) : moveHead(-10, isShiftPressed), PageDown: () => isAltPressed ? reorderWithAnimation(1, 10) : moveHead(10, isShiftPressed), Enter: () => queue.sel.length && goto(queue.sel[0]), Delete: remove, Backspace: remove, Escape: clearSel, ContextMenu: () => showMenu(nav.head), "`": () => showMenu(nav.head), };
if (moves[key]) { event.preventDefault(); moves[key](); }}
function copyQueue() { queue.clipboard = [...queue.tracks];}
function cutQueue() { copyQueue(); clearSel(); clear();}
function getQueueItems(index: number): Item[] { if (index < 0) { const empty = !queue.tracks.length; return [ { label: t("common.copy"), action: copyQueue, disabled: empty }, { label: t("common.cut"), action: cutQueue, disabled: empty }, { label: t("common.paste"), action: paste, disabled: !queue.clipboard.length, }, { type: "separator" }, { label: t("common.select_all"), action: selectAll, disabled: empty }, { type: "separator" }, { label: t("common.sort"), items: getSortItems() }, { type: "separator" }, { label: t("common.clear"), action: clear }, ]; }
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.isSongDownloaded(s), ); 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, }, { type: "separator" }, ...(queue.sel.length > 1 ? [{ label: t("common.sort"), items: getSortItems() }] : []), { 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), action: () => rateSelected(rating), })) .reverse(), { label: t("common.none"), action: () => rateSelected(0), }, ], }, ] : []), ...(settings.downloads && selectedTracks.length ? [ { label: shouldDownloadSelected ? t("common.download") : t("action.remove_download"), action: () => { if (shouldDownloadSelected) return offlineManager.downloadSongs(selectedTracks); const songsBySource = new Map<string, string[]>(); for (const song of selectedTracks) { const sourceId = song.sourceId || sources.activeId; if (!sourceId) continue; const ids = songsBySource.get(sourceId); if (ids) ids.push(song.id); else songsBySource.set(sourceId, [song.id]); } return Promise.all( [...songsBySource].map(([sourceId, ids]) => offlineManager.removeSongs(ids, sourceId), ), ); }, }, ] : []), ...(selectedTrack && !offlineManager.isSourceOffline(selectedTrack.sourceId) ? [ { type: "separator" as const }, { label: t("common.info"), action: () => (ui.infoItem = trackInfoItem( selectedTrack, isRadio(selectedTrack), )), }, ] : []), { type: "separator" }, { label: t("common.clear"), action: clear }, ];}
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; 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} 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" class:selection={ui.selectionMode} class:has-selection={queue.sel.length > 0} bind:clientHeight={headerHeight} > {#if ui.selectionMode} <button type="button" tabindex="-1" class="col" onclick={(e) => { e.stopPropagation(); if (queue.sel.length > 0) clearSel(); else selectAll(); }} > {queue.sel.length > 0 ? t("common.deselect_all") : t("common.select_all")} </button> <button type="button" tabindex="-1" class="col" onclick={(e) => { e.stopPropagation(); if (groupPhase === null) { groupPhase = "from"; } else { groupPhase = null; groupFrom = -1; } }} > {groupPhase === "from" ? t("action.group_from") : groupPhase === "to" ? t("action.group_to") : t("action.group")} </button> {#if queue.sel.length} <button type="button" tabindex="-1" class="col pressable" aria-label={t("action.move_up")} title={t("action.move_up")} disabled={isSelected(0)} onclick={(e) => { e.stopPropagation(); reorderWithAnimation(-1); }} > ↑ </button> <button type="button" tabindex="-1" class="col pressable" aria-label={t("action.move_down")} title={t("action.move_down")} disabled={isSelected(queue.tracks.length - 1)} onclick={(e) => { e.stopPropagation(); reorderWithAnimation(1); }} > ↓ </button> {/if} {:else} {#if sourceColumn.enabled} <div class="col col-source"></div> {/if} {#if settings.artSong > 0} <div class="col-artwork"></div> {/if} {#each visibleColumns as column} <div class="col" class:col-number={column.numeric}> {column.header} </div> {/each} {#if settings.enableFavorites} <div class="col-favorite">{favoriteSymbol}</div> {/if} {#if settings.enableRatings} <div class="col-rating">{ratingHeader}</div> {/if} {#if showDownloads} <div class="col-download">{downloadSymbols.idle}</div> {/if} {/if} <div class="col-touch"> <button type="button" tabindex="-1" aria-label={t( ui.selectionMode ? "common.deselect_all" : "common.select_all", )} aria-pressed={ui.selectionMode} onclick={(e) => { e.stopPropagation(); ui.selectionMode = !ui.selectionMode; }} > {ui.selectionMode ? "✓" : "☐"} </button> </div> </div> {#if lastHeaderHeight} <!-- HACK: remount Virtua if measured head height changes, so queue entry animation selects all rows properly --> {#key lastHeaderHeight} <Virtualizer bind:this={queueVirtualizer} data={queue.keys} getKey={(key) => key} itemSize={lastHeaderHeight} scrollRef={queueElement} startMargin={lastHeaderHeight} > {#snippet children(key, index)} {@const track = queue.tracks[index]} {@const radio = isRadio(track)} <!-- 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" data-id="queue-row-{index}" data-queue-key={key} class:playing={queue.pos === index} class:selected={isSelected(index)} class:odd={index % 2 !== 0} class:over-above={drag?.index === index && !drag.isAfter} class:over-below={drag?.index === index && drag.isAfter} class:unavailable={!canPlayTrack(track)} draggable={true} ondragstart={(e) => onDragStart(e, index)} ondragover={(e) => onDragOver(e, index)} ondrop={onDrop} onclick={(e) => { e.stopPropagation(); if (groupPhase === "from") { groupFrom = index; groupPhase = "to"; } else if (groupPhase === "to") { toggleSelectionRange(groupFrom, index); groupPhase = null; groupFrom = -1; } else { select( index, ui.selectionMode || e.ctrlKey || e.metaKey, e.shiftKey, ); } }} ondblclick={() => { if (ui.selectionMode) return; goto(index); clearSel(); }} oncontextmenu={(e) => { e.preventDefault(); showMenu(index, e.clientX, e.clientY); }} > {#if sourceColumn.enabled} <div class="col col-source" title={sourceDisplayName(getSource(track.sourceId))} > {sourceColumn.value(track, index)} </div> {/if} {#if settings.artSong > 0} {@const artId = songArtId(track)} {@const coverArtUrl = artId ? track.sourceId ? getSourceApi(track.sourceId).getCoverArt(artId, settings.artSong) : "" : ""} <div class="col-artwork artwork"> {#if coverArtUrl} <img fetchpriority="low" decoding="async" alt="" src={coverArtUrl} srcset={coverArtUrl.startsWith("blob:") ? undefined : `${coverArtUrl} 1x, ${track.sourceId ? getSourceApi(track.sourceId).getCoverArt(artId, settings.artSong * 2) : ""} 2x`} class="artwork-image" {@attach fadeImage} > {/if} </div> {/if} {#each visibleColumns as column} {@const value = String(column.value(track, index))} <div class="col" class:col-number={column.numeric} title={value}> {value} </div> {/each} {#if settings.enableFavorites} <div class="col-favorite"> {#if !radio} <MediaAction kind="favorite" starred={!!track.starred} subtle tabindex={-1} onfavorite={(event) => { event.stopPropagation(); toggleStar(track); }} /> {/if} </div> {/if} {#if settings.enableRatings} <div class="col-rating"> {#if !radio} <MediaAction kind="rating" rating={track.userRating ?? 0} subtle tabindex={-1} onrating={(value, event) => { event.stopPropagation(); setRating(track, value); }} /> {/if} </div> {/if} {#if showDownloads} <div class="col-download"> {#if !radio} {@const isDownloaded = offlineManager.isSongDownloaded(track)} {@const isProgress = offlineManager.isDownloadActive(track) || offlineManager.isDownloadQueued(track)} <button type="button" tabindex="-1" class="pressable" disabled={!isDownloaded && offlineManager.isSourceOffline(track.sourceId)} class:active={isDownloaded} class:downloading={isProgress && !isDownloaded} aria-busy={isProgress && !isDownloaded} aria-label={t( isDownloaded ? "action.remove_download" : "common.download", )} onclick={(e) => { e.stopPropagation(); isDownloaded ? offlineManager.removeSong(track.id, track.sourceId) : offlineManager.downloadSong(track); }} > {isDownloaded ? downloadSymbols.downloaded : isProgress ? downloadSymbols.progress : downloadSymbols.idle} </button> {/if} </div> {/if} <div class="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(index, bounds.right, bounds.bottom); }} > ⋮ </button> </div> </div> {/snippet} </Virtualizer> {/key} {/if}</section><!-- biome-ignore-end lint/a11y/noNoninteractiveTabindex: Queue keyboard navigation -->
{#if menu} <ContextMenu x={menu.x} y={menu.y} onclose={() => (menu = null)} items={getQueueItems(menu.index)} />{/if}
<style>#queue { overflow: hidden auto; &:not(:focus-within) .row.selected { background-color: GrayText; color: HighlightText; }}
.head,.row { position: relative; display: grid; grid-template-columns: var(--queue-columns); align-items: center; padding-inline: 1rem; padding-block: 2px; 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(--bg); border-block-end: var(--border-subtle);
&.selection { grid-template-columns: repeat(2, minmax(0, 1fr));
&.has-selection { grid-template-columns: repeat(2, minmax(0, 1fr)) repeat(2, max-content); } }}.col { min-inline-size: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.col-artwork { block-size: var(--queue-art-size);}.col-source { text-align: center;}.col-number,.col-rating,.col-favorite,.col-download { text-align: end;}.col-rating { display: flex; justify-content: flex-end;}.unavailable { opacity: 0.5;}@keyframes spin { to { transform: rotate(1turn); }}.col-touch { display: none; position: absolute; inset-inline-end: 1rem; inline-size: 2rem;
@media (pointer: coarse) { display: block; }}.head > button { text-align: start; font-weight: bold;}.row { border-radius: var(--radius-sm); corner-shape: squircle;
&.odd { background-color: var(--bg-layer); } &.playing { background-color: hsl(200 90% 50% / 0.3); } &.selected { background-color: var(--bg-highlight); color: var(--text-highlight); } &.over-above { box-shadow: inset 0 2px 0 var(--text); } &.over-below { box-shadow: inset 0 -2px 0 var(--text); }}.col-touch,.col-download { > button { opacity: 0.25;
&:hover { opacity: 0.5; } }}.col-touch > button { inline-size: 100%; text-align: end;}.head .col-touch > button { opacity: 1;}.col-download > button { &.active { opacity: 1; }
&.downloading { opacity: 1; display: inline-block;
:global(html.motion) & { animation: spin 1.5s linear infinite; } }}</style>