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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590<script lang="ts">import { auth, isOwner as checkIsOwner } from "../api/auth.svelte.js";import { api, type StarredItem, type StarredType, toggleStar,} from "../api/client.svelte.js";import type { Playlist, Song } from "../api/types.js";import { isInfoItemType, trackInfoItem } from "../api/types.js";import { settings } from "../app/settings.svelte.js";import type { Item } from "../components/ContextMenu.svelte";import Icon from "../icons/Icon.svelte";import { t } from "../languages/translate.svelte.js";import { offlineManager } from "../offline/offlineManager.svelte.js";import { songArtId } from "../ui/artwork.svelte.js";import { animateButtonRejection, attachEntry, fadeImage,} from "../ui/motion.svelte.js";import { ui } from "../ui/state.svelte.js";import { type ChildFactory, isTrackType, type LibraryItem, lib, libraryDragType, loadShares, recursiveSongs, type TreeType,} from "./store.svelte.js";
type DragWindow = Window & { _tinysub_drag?: () => Promise<Song | Song[]> };
let { id, rowKey, label, detail, fetcher, childFactory, track, type, owner = null, item, depth, expanded, busy, primaryUnavailable, onPrimaryAction, onAdd, onMenu, onUpdate, onDelete, entryIndex, entryGen, onEntryAnimationEnd, tabbable = false,} = $props<{ id: string; rowKey: string; label: string; detail?: string; fetcher?: () => Promise<LibraryItem[]>; childFactory?: ChildFactory; track?: Song; type: TreeType; owner?: string | null; item?: LibraryItem; depth: number; expanded: boolean; busy: boolean; primaryUnavailable: boolean; onPrimaryAction: () => void | Promise<void>; onAdd?: (asNext: boolean) => void | Promise<void>; onMenu?: (menu: { x: number; y: number; items: Item[]; keyboard?: boolean; }) => void; onUpdate?: () => void; onDelete?: () => void; entryIndex?: number; entryGen?: number; onEntryAnimationEnd?: (key: string) => void; tabbable?: boolean;}>();
const tabIndex = $derived(tabbable ? undefined : -1);
const isShare = $derived(type === "share");const isNewItem = $derived(type === "new-playlist" || type === "new-station");const isSpecialItem = $derived(isNewItem || type === "show-more");const isRadio = $derived(type === "radio");const newItemLabel = $derived( type === "new-playlist" ? t("action.new_playlist") : t("action.new_station"),);const playlist = $derived( type === "playlist" ? (item as Playlist | undefined) : undefined,);const isPublic = $derived(playlist?.public === true);
const isStarred = $derived(!!item?.starred);const canStar = $derived( type === "album" || type === "artist" || type === "song",);const isTrack = $derived(isTrackType(type));const canDrag = $derived((isTrack && !!track) || !!fetcher);
const isOwner = $derived(checkIsOwner(owner));
const canUpdate = $derived( type === "playlist" ? isOwner ? playlist?.readonly !== true : auth.admin : isRadio && auth.admin && !!onUpdate,);const canDelete = $derived( type === "playlist" ? auth.admin || isOwner : isRadio && auth.admin && !!onDelete,);const canShowDeleteAction = $derived( canDelete || (isShare && (isOwner || auth.admin)),);
const isItemDownloaded = $derived( type === "song" ? offlineManager.downloadedSongIds.has(id) : type === "album" ? offlineManager.downloadedAlbumIds.has(id) : type === "artist" ? offlineManager.downloadedArtistIds.has(id) : type === "playlist" ? offlineManager.downloadedPlaylistIds.has(id) : false,);
const canShowDownloadAction = $derived( settings.downloads && (canStar || type === "playlist") && (!offlineManager.isOfflineMode || isItemDownloaded),);
const isUnavailableOffline = $derived( offlineManager.isOfflineMode && !isItemDownloaded && (type === "song" || (type === "playlist" && !offlineManager.partialPlaylistIds.has(id))),);
const artSize = $derived.by(() => { const sizes: Record<string, number> = { artist: settings.artArtist, album: settings.artAlbum, playlist: settings.artPlaylist, radio: settings.artRadio, share: settings.artShare, song: settings.artSong, }; return sizes[type] ?? 0;});
const artId = $derived(track ? songArtId(track) : item?.coverArt || "");const coverArtUrl = $derived( artSize > 0 && artId ? api.getCoverArt(artId, artSize) : "",);function doToggleStar() { if (!item || !canStar) return; void toggleStar(item as StarredItem, type as StarredType);}
async function doTogglePublic() { if (type !== "playlist" || !canDelete || !playlist) return; const prev = playlist.public; const next = !isPublic; playlist.public = next; try { await api.updatePlaylist(id, undefined, undefined, undefined, next); } catch (err) { playlist.public = prev; console.error(err); }}
async function doDelete() { if (!canShowDeleteAction) return; if (isRadio) { onDelete?.(); return; } if (isShare) { try { await api.deleteShare(id); await loadShares(); } catch (err) { console.error(err); } return; } ui.confirmDeletePlaylist = { id, name: label };}
function doUpdate() { if (isRadio) { onUpdate?.(); return; } ui.playlistForm = { mode: "update", id, name: label };}
function doCopyShareLink() { if (item?.url) navigator.clipboard.writeText(item.url).catch(() => {});}
async function doToggleDownload() { try { switch (type) { case "song": if (isItemDownloaded) await offlineManager.removeSongs([id]); else if (track) await offlineManager.downloadSongs([track]); break; case "album": if (isItemDownloaded) await offlineManager.removeAlbum(id); else await offlineManager.downloadAlbum(id); break; case "artist": if (isItemDownloaded) await offlineManager.removeArtist(id); else await offlineManager.downloadArtist(id); break; case "playlist": if (isItemDownloaded) await offlineManager.removePlaylist(id); else await offlineManager.downloadPlaylist(id); break; } } catch (err) { console.error("failed to toggle download:", err); }}
const menuItems = $derived.by<Item[]>(() => { const list: Item[] = []; const canShare = !offlineManager.isOfflineMode && lib.isSharingSupported && !isShare && !isSpecialItem && !isRadio;
if (!isShare && !isSpecialItem) { list.push({ label: t("common.add"), action: () => onAdd?.(false) }); list.push({ label: t("action.add_next"), action: () => onAdd?.(true) }); } if ( list.length && (canShare || canShowDownloadAction || canStar || canUpdate || (type === "playlist" && canDelete)) ) { list.push({ type: "separator" }); } if (canShare) { list.push({ label: t("common.share"), action: () => { ui.shareItemId = id; ui.shareItemLabel = label; ui.shareItemType = type; }, }); } if (isShare) { list.push({ label: t("action.copy_link"), action: doCopyShareLink }); } if (canStar) { list.push({ label: isStarred ? t("common.unfavorite") : t("common.favorite"), action: doToggleStar, }); } if (canUpdate) { list.push({ label: t("common.update"), action: doUpdate }); } if (type === "playlist" && canDelete) { list.push( { label: t("common.rename"), action: () => { ui.playlistForm = { mode: "rename", id, name: label }; }, }, { label: isPublic ? t("action.make_private") : t("action.make_public"), action: doTogglePublic, }, ); } if (canShowDownloadAction) { list.push({ label: isItemDownloaded ? t("action.remove_download") : t("common.download"), action: doToggleDownload, }); } if (canShowDeleteAction) { if (list.length) list.push({ type: "separator" }); list.push({ label: t("common.delete"), action: doDelete }); } if (!offlineManager.isOfflineMode && isInfoItemType(type)) { if (list.length) list.push({ type: "separator" }); list.push({ label: t("common.info"), action: () => { ui.infoItem = type === "song" && track ? trackInfoItem(track) : { type, id, label }; }, }); } return list;});
function onPrimaryClick(event: MouseEvent) { event.stopPropagation(); if (primaryUnavailable) animateButtonRejection(event.currentTarget as HTMLButtonElement); else void onPrimaryAction();}
function onDragStart(event: DragEvent) { if (!event.dataTransfer || !canDrag) return; event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData(libraryDragType, ""); (window as DragWindow)._tinysub_drag = async () => { if (isTrack && track) return track; if (fetcher) return recursiveSongs(await fetcher(), childFactory); return []; };}</script>
<div class="row" class:loading={busy} data-tree-key={rowKey} {@attach (element) => attachEntry(element, entryIndex, entryGen, rowKey, onEntryAnimationEnd)} style:--tree-depth={depth} style:opacity={isUnavailableOffline ? 0.5 : 1}> <button type="button" tabindex={tabIndex} class:focused={lib.focusedId === rowKey} class="main pressable pressable--gentle" data-id={rowKey} draggable={canDrag} disabled={busy} aria-expanded={fetcher && !isTrack ? expanded : undefined} aria-disabled={primaryUnavailable} onclick={onPrimaryClick} ondragend={() => delete (window as DragWindow)._tinysub_drag} ondragstart={onDragStart} oncontextmenu={(e) => { e.preventDefault(); if (busy || isSpecialItem) return; onMenu?.({ x: e.clientX, y: e.clientY, items: menuItems, keyboard: !e.isTrusted, }); }} > {#if artSize > 0} <div class="artwork" style:inline-size={`${artSize}px`} style:block-size={`${artSize}px`} > {#if coverArtUrl} <img fetchpriority="low" decoding="async" alt="" src={coverArtUrl} srcset={!artId || coverArtUrl.startsWith("blob:") ? undefined : `${coverArtUrl} 1x, ${api.getCoverArt(artId, artSize * 2)} 2x`} class="artwork-image" {@attach (image) => fadeImage(image, id)} > {/if} {#if type === "playlist" && isPublic} <Icon class="badge public" name="globe" title={t("common.public")} /> {/if} {#if isStarred} <Icon class="badge favorite" name="heart" title={t("common.favorite")} /> {/if} {#if isItemDownloaded} <Icon class="badge downloaded" name="check" title={t("common.downloaded")} style={`--icon-size: ${settings.iconSize / 2}px`} /> {/if} </div> {/if} <div class="label-stack"> <span title={label} style:font-style={isSpecialItem ? "italic" : undefined} >{label}</span > {#if detail} <small title={detail}>{detail}</small> {/if} {#if owner && !isOwner} <small title={owner}>{owner}</small> {/if} </div> </button> {#if isShare} <button type="button" tabindex={tabIndex} class="pressable" disabled={busy} aria-label={t("action.copy_link")} onclick={(e) => { e.stopPropagation(); doCopyShareLink(); }} title={t("action.copy_link")} > <Icon name="link" /> </button> {/if} {#if isNewItem} <button type="button" tabindex={tabIndex} class="pressable" disabled={busy} aria-label={newItemLabel} onclick={(e) => { e.stopPropagation(); onPrimaryAction(); }} title={newItemLabel} > <Icon name="create" /> </button> {/if} {#if !isSpecialItem} <button type="button" tabindex={tabIndex} class="pressable" aria-haspopup="menu" disabled={busy} aria-label={t("shortcut.context_menu")} onclick={(e) => { e.stopPropagation(); const bounds = ( e.currentTarget as HTMLButtonElement ).getBoundingClientRect(); onMenu?.({ x: bounds.right, y: bounds.bottom, items: menuItems }); }} title={t("shortcut.context_menu")} > ⋮ </button> {/if} {#if !isShare && !isSpecialItem} <button type="button" tabindex={tabIndex} class="pressable" disabled={busy} aria-label={t("action.add_to_queue")} onclick={(e) => { e.stopPropagation(); onAdd?.(false); }} title={t("action.add_to_queue")} > <Icon name="add" /> </button> {/if}</div>
<style>.row { display: flex; padding-inline-start: calc(var(--tree-depth) * 1rem); align-items: center; gap: 0.25rem; margin-block-start: 0.5rem;}
.main.focused { background-color: var(--selected-background); color: var(--selected-text);}:global(#library:not(:focus-within)) .main.focused { background-color: var(--unfocused-background); color: var(--unfocused-text);}button { display: flex; align-items: center;}.main { flex: 1; min-inline-size: 0; gap: 0.5rem; --button-transform-origin: 10% 75%;}:global(:dir(rtl)) .main { --button-transform-origin: 90% 75%;}.artwork { position: relative;}button[aria-haspopup="menu"] { display: none; inline-size: var(--icon-size); block-size: var(--icon-size); justify-content: center;}@media (pointer: coarse) { button[aria-haspopup="menu"] { display: flex; }}.label-stack > :is(span, small) { text-box: text; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start;}small { opacity: 0.75;}.label-stack { display: flex; flex-direction: column; overflow: hidden; flex: 1;}.artwork > :global(.badge) { position: absolute; inset-block-end: calc(var(--icon-size) / -4);}:global(#library) .artwork > :global(.badge) { filter: drop-shadow(1px 1px 0 var(--background)) drop-shadow(1px -1px 0 var(--background)) drop-shadow(-1px 1px 0 var(--background)) drop-shadow(-1px -1px 0 var(--background));}.artwork > :global(.badge:is(.public, .favorite)) { inset-inline-end: calc(var(--icon-size) / -4);}.artwork > :global(.badge:is(.public, .downloaded)) { color: var(--accent-green);}.artwork > :global(.badge.favorite) { color: var(--accent-red);
& > :global(svg) { fill: currentColor; }}.artwork > :global(.badge.downloaded) { inset-inline-start: calc(var(--icon-size) / -4);}</style>