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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580<script lang="ts">import { auth, isOwner as checkIsOwner } from "./auth.svelte.js";import { getSourceApi, type Playlist, type Song, songArtId,} from "./client.svelte.js";import type { Item } from "./components/ContextMenu.svelte";import Icon from "./icons/Icon.svelte";import { isInfoItemType, trackInfoItem } from "./info.js";import { t } from "./languages/translate.svelte.js";import { type ChildFactory, type LibraryItem, lib, loadShares, recursiveSongs, type TreeType,} from "./library.svelte.js";import { animateButtonRejection, fadeImage } from "./motion.js";import { offlineManager } from "./offline/offlineManager.svelte.js";import { settings } from "./settings.svelte.js";import { sources } from "./sources.svelte.js";import { ui } from "./ui.svelte.js";
type DragWindow = Window & { _tinysub_drag?: () => Promise<Song | Song[]> };
let { id, rowKey, label, fetcher, childFactory, track, type, owner = null, item, artworkUrl, depth, expanded, busy, primaryUnavailable, onPrimaryAction, onAdd, onMenu, onItemChange, onUpdate, onDelete,} = $props<{ id: string; rowKey: string; label: string; fetcher?: () => Promise<LibraryItem[]>; childFactory?: ChildFactory; track?: Song; type: TreeType; owner?: string | null; item?: LibraryItem; artworkUrl?: string; 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; onItemChange?: (changes: { starred?: string; public?: boolean }) => void; onUpdate?: () => void; onDelete?: () => void;}>();
let starredOverride = $state<boolean | null>(null);
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 playlist = $derived( type === "playlist" ? (item as Playlist | undefined) : undefined,);const isPublic = $derived(playlist?.public === true);
const isStarred = $derived(starredOverride ?? !!item?.starred);const isAlbumOrArtist = $derived(type === "album" || type === "artist");const isTrack = $derived(type === "song" || type === "radio");const canDrag = $derived((isTrack && !!track) || !!fetcher);
const isOwner = $derived(checkIsOwner(owner));const sourceId = $derived( track?.sourceId || item?.sourceId || sources.activeId || "",);const sourceApi = $derived(sourceId ? getSourceApi(sourceId) : null);
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" ? !!track && offlineManager.isSongDownloaded(track) : type === "album" ? offlineManager.isAlbumDownloaded(id, sourceId) : type === "artist" ? offlineManager.isArtistDownloaded(id, sourceId) : type === "playlist" ? offlineManager.isPlaylistDownloaded(id, sourceId) : false,);
const canShowDownloadAction = $derived( settings.downloads && (type === "song" || isAlbumOrArtist || type === "playlist") && (!offlineManager.isSourceOffline(sourceId) || isItemDownloaded),);
const isUnavailableOffline = $derived( offlineManager.isSourceOffline(sourceId) && !isItemDownloaded && (type === "song" || (type === "playlist" && !offlineManager.isPlaylistPartiallyDownloaded(id, sourceId))),);
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 ? sourceApi?.getCoverArt(artId, artSize) || "" : artworkUrl || "" : "",);async function doToggleStar() { if (!isAlbumOrArtist) return; const next = !isStarred; starredOverride = next; try { if (!sourceApi) return; next ? await sourceApi.star({ id }) : await sourceApi.unstar({ id }); onItemChange?.({ starred: next ? new Date().toISOString() : undefined, }); } catch (err) { console.error(err); } finally { starredOverride = null; }}
async function doTogglePublic() { if (type !== "playlist" || !canDelete) return; try { const next = !isPublic; if (!sourceApi) return; await sourceApi.updatePlaylist(id, undefined, undefined, undefined, next); onItemChange?.({ public: next }); } catch (err) { console.error(err); }}
async function doDelete() { if (!canShowDeleteAction) return; if (isRadio) { onDelete?.(); return; } if (isShare) { try { if (!sourceApi) return; await sourceApi.deleteShare(id); await loadShares(sourceId); } catch (err) { console.error(err); } return; } ui.confirmDeletePlaylist = { id, name: label, sourceId };}
function doUpdate() { if (isRadio) { onUpdate?.(); return; } ui.playlistForm = { mode: "update", id, name: label, sourceId };}
function doCopyShareLink() { if (item?.url) void navigator.clipboard.writeText(item.url);}
function doToggleDownload() { switch (type) { case "song": if (isItemDownloaded) offlineManager.removeSong(id, sourceId); else if (track) offlineManager.downloadSong(track); break; case "album": isItemDownloaded ? offlineManager.removeAlbum(id, sourceId) : offlineManager.downloadAlbum(id, sourceId); break; case "artist": isItemDownloaded ? offlineManager.removeArtist(id, sourceId) : offlineManager.downloadArtist(id, sourceId); break; case "playlist": isItemDownloaded ? offlineManager.removePlaylist(id, sourceId) : offlineManager.downloadPlaylist(id, sourceId); }}
const menuItems = $derived.by<Item[]>(() => { const list: Item[] = []; const canShare = !offlineManager.isSourceOffline(sourceId) && 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 || isAlbumOrArtist || canUpdate || (type === "playlist" && canDelete)) ) { list.push({ type: "separator" }); } if (canShare) { list.push({ label: t("common.share"), action: () => { ui.shareItemId = id; ui.shareItemSourceId = sourceId; ui.shareItemLabel = label; ui.shareItemType = type; }, }); } if (isShare) { list.push({ label: t("action.copy_link"), action: doCopyShareLink }); } if (isAlbumOrArtist) { 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, sourceId }; }, }, { 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.isSourceOffline(sourceId) && 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, sourceId }), }); } 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("application/tinysub-library", ""); (window as DragWindow)._tinysub_drag = async () => { if (isTrack && track) return { ...track }; if (fetcher) return recursiveSongs(await fetcher(), childFactory); return []; };}</script>
<div class="row" data-tree-key={rowKey} style:--tree-depth={depth} style:opacity={isUnavailableOffline ? 0.5 : 1}> <button type="button" tabindex="-1" 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, ${sourceApi?.getCoverArt(artId, artSize * 2) || ""} 2x`} class="artwork-image" {@attach fadeImage} > {/if} {#if type === "playlist" && isPublic} <Icon class="badge public" name="globe" title={t("common.public")} /> {/if} {#if isAlbumOrArtist && 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 owner && !isOwner} <small title={owner}>{owner}</small> {/if} </div> </button> {#if isShare} <button type="button" tabindex="-1" class="pressable" disabled={busy} onclick={(e) => { e.stopPropagation(); doCopyShareLink(); }} title={t("action.copy_link")} > <Icon name="link" /> </button> {/if} {#if isNewItem} <button type="button" tabindex="-1" class="pressable" disabled={busy} onclick={(e) => { e.stopPropagation(); onPrimaryAction(); }} title={type === "new-playlist" ? t("action.new_playlist") : t("action.new_station")} > <Icon name="create" /> </button> {/if} {#if !isSpecialItem} <button type="button" tabindex="-1" class="pressable" aria-haspopup="menu" disabled={busy} 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="-1" class="pressable" disabled={busy} 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(--bg-highlight); color: var(--text-highlight);}:global(#library:not(:focus-within)) .main.focused { background-color: GrayText; color: HighlightText;}button { display: flex; align-items: center;}button:disabled { opacity: 0.5;}.main { flex: 1; overflow: hidden; 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; }}:is(span, small) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start;}.label-stack { display: flex; flex-direction: column; overflow: hidden; flex: 1;}.artwork > :global(.badge) { position: absolute; inset-block-end: 0; line-height: 0;}.artwork > :global(.badge:is(.public, .favorite)) { inset-inline-end: 0;}.artwork > :global(.badge.public) { color: var(--accent-green);}.artwork > :global(.badge.favorite) { color: var(--accent-red);
& > :global(svg) { fill: currentColor; }}.artwork > :global(.badge.downloaded) { inset-inline-start: 0; color: var(--accent-green);}</style>