a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 485 lines 11 kB view raw
1<script lang="ts"> 2 import { untrack } from "svelte"; 3 import { api } from "./client.svelte.js"; 4 import { 5 insertAt, 6 queue, 7 auth, 8 loadLib, 9 player, 10 recursiveSongs, 11 settings, 12 lib, 13 t, 14 } from "./app.svelte.js"; 15 import { ui } from "./app.svelte.js"; 16 import ContextMenu from "./ContextMenu.svelte"; 17 import TreeItem from "./TreeItem.svelte"; 18 import icAdd from "../assets/famfamfam-silk-svg/icons/Add.svg"; 19 import icDisk from "../assets/famfamfam-silk-svg/icons/Disk.svg"; 20 import icBinEmpty from "../assets/famfamfam-silk-svg/icons/Bin empty.svg"; 21 import icWorld from "../assets/famfamfam-silk-svg/icons/World.svg"; 22 import icHeart from "../assets/famfamfam-silk-svg/icons/Heart.svg"; 23 import icLink from "../assets/famfamfam-silk-svg/icons/Link.svg"; 24 25 let { 26 id, 27 uid = id, 28 label, 29 fetcher, 30 childFactory, 31 isTrack = false, 32 track = null, 33 type = "artist", 34 isReadonly = false, 35 owner = null, 36 isPublic = false, 37 item = null, 38 } = $props<{ 39 id: string; 40 uid?: string; 41 label: string; 42 fetcher?: () => Promise<any[]>; 43 childFactory?: (item: any) => (() => Promise<any[]>) | undefined; 44 isTrack?: boolean; 45 track?: any; 46 type?: "artist" | "album" | "playlist" | "song" | "share"; 47 isReadonly?: boolean; 48 owner?: string | null; 49 isPublic?: boolean; 50 item?: any; 51 }>(); 52 53 let open = $state(false), 54 items = $state<any[] | null>(null), 55 busy = $state(false), 56 menu = $state<{ x: number; y: number } | null>(null); 57 58 let publicOverride = $state<boolean | null>(null); 59 let isPublicState = $derived(publicOverride ?? isPublic); 60 61 let starredOverride = $state<string | undefined | null>(null); 62 let isStarred = $derived(starredOverride ?? item?.starred); 63 64 async function doToggleStar() { 65 if (type !== "album" && type !== "artist") return; 66 const next = !isStarred; 67 try { 68 const params = { id }; 69 starredOverride = next ? new Date().toISOString() : undefined; 70 next ? await api.star(params) : await api.unstar(params); 71 if (item) item.starred = starredOverride; 72 starredOverride = null; 73 } catch (err) { 74 console.error(err); 75 starredOverride = null; 76 } 77 } 78 79 async function doTogglePublic() { 80 if (type !== "playlist" || (owner !== auth.user && !auth.admin)) return; 81 try { 82 const next = !isPublicState; 83 await api.updatePlaylist(id, undefined, undefined, undefined, next); 84 publicOverride = next; 85 await loadLib(); 86 publicOverride = null; 87 } catch (err) { 88 console.error(err); 89 } 90 } 91 92 $effect(() => { 93 const signal = ui.lastUpdatedPlaylistId; 94 if (type === "playlist" && signal?.startsWith(id + ":")) { 95 if (untrack(() => open) && fetcher) { 96 busy = true; 97 fetcher() 98 .then((res: any[]) => (items = res)) 99 .catch((err: any) => console.error(err)) 100 .finally(() => (busy = false)); 101 } else { 102 items = null; 103 } 104 } 105 }); 106 107 async function doAdd(next: boolean) { 108 const index = next ? queue.pos + 1 : queue.tracks.length; 109 if (isTrack && track) { 110 insertAt(index, { ...track }); 111 } else if (fetcher) { 112 busy = true; 113 try { 114 insertAt(index, await recursiveSongs(await fetcher(), childFactory)); 115 } catch (err) { 116 console.error(err); 117 } finally { 118 busy = false; 119 } 120 } 121 } 122 123 async function toggle(e?: Event) { 124 e?.stopPropagation(); 125 if (isTrack) return; 126 open = !open; 127 if (open && !items && fetcher) { 128 busy = true; 129 try { 130 items = await fetcher(); 131 } finally { 132 busy = false; 133 } 134 } 135 } 136 137 function doUpdate() { 138 if (canUpdate) { 139 ui.confirmUpdatePlaylistId = id; 140 ui.confirmUpdatePlaylistName = label; 141 } 142 } 143 144 function doRename() { 145 if (canDelete) { 146 ui.renamePlaylistId = id; 147 ui.renamePlaylistName = label; 148 } 149 } 150 151 async function doDelete() { 152 if (type === "share") { 153 try { 154 await api.deleteShare(id); 155 await loadLib(); 156 } catch (err) { 157 console.error(err); 158 } 159 return; 160 } 161 if (canDelete) { 162 ui.confirmDeletePlaylistId = id; 163 ui.confirmDeletePlaylistName = label; 164 } 165 } 166 167 function keyboardActions(node: HTMLElement) { 168 const onAdd = (e: any) => doAdd(e.detail); 169 node.addEventListener("actionadd", onAdd); 170 return { 171 destroy: () => node.removeEventListener("actionadd", onAdd), 172 }; 173 } 174 175 function onDragStart(e: DragEvent) { 176 e.dataTransfer!.effectAllowed = "copy"; 177 e.dataTransfer!.setData("application/tinysub-library", ""); 178 (window as any)._tinysub_drag = async () => { 179 if (isTrack && track) return { ...track }; 180 if (fetcher) return recursiveSongs(await fetcher(), childFactory); 181 return []; 182 }; 183 } 184 185 function onDragEnd() { 186 delete (window as any)._tinysub_drag; 187 } 188 189 const artSize = $derived( 190 ( 191 { 192 artist: settings.artArtist, 193 album: settings.artAlbum, 194 playlist: settings.artPlaylist, 195 } as any 196 )[type] ?? 0, 197 ); 198 199 const isOwner = $derived(!owner || (auth.user && owner === auth.user)); 200 const canUpdate = $derived( 201 type === "playlist" && 202 (auth.admin ? (isOwner ? !isReadonly : true) : isOwner && !isReadonly), 203 ); 204 const canDelete = $derived( 205 type === "playlist" && (auth.admin || (isOwner && !isReadonly)), 206 ); 207</script> 208 209<li> 210 <div class="row"> 211 <button 212 tabindex="-1" 213 class:focused={lib.focusedId === uid} 214 class="btn main" 215 data-id={uid} 216 draggable={true} 217 onclick={toggle} 218 ondragend={onDragEnd} 219 ondragstart={onDragStart} 220 oncontextmenu={(e) => { 221 e.preventDefault(); 222 menu = { x: e.clientX, y: e.clientY }; 223 }} 224 use:keyboardActions 225 > 226 {#if artSize > 0} 227 {@const artId = track?.albumId || track?.coverArt || id} 228 <div class="art-container"> 229 <img 230 alt="" 231 class="art" 232 loading="lazy" 233 src={api.art(artId, artSize)} 234 srcset="{api.art(artId, artSize)} 1x, {api.art( 235 artId, 236 artSize * 2, 237 )} 2x" 238 style="inline-size: {artSize}px; block-size: {artSize}px;" 239 /> 240 {#if type === "playlist" && isPublicState} 241 <img alt="" class="badge" src={icWorld} title={t("public")} /> 242 {/if} 243 {#if (type === "album" || type === "artist") && isStarred} 244 <img alt="" class="badge" src={icHeart} title={t("favorite")} /> 245 {/if} 246 </div> 247 {/if} 248 <div class="label-stack"> 249 <span class="label">{label}</span> 250 {#if owner && type === "playlist" && owner.toLowerCase() !== auth.user?.toLowerCase()} 251 <span class="owner">owner: {owner}</span> 252 {/if} 253 </div> 254 </button> 255 {#if canUpdate} 256 <button 257 tabindex="-1" 258 class="btn action" 259 onclick={(e) => { 260 e.stopPropagation(); 261 doUpdate(); 262 }} 263 title={t("update_playlist")} 264 > 265 <img alt="" src={icDisk} /> 266 </button> 267 {/if} 268 {#if canDelete || type === "share"} 269 <button 270 tabindex="-1" 271 class="btn action" 272 onclick={(e) => { 273 e.stopPropagation(); 274 doDelete(); 275 }} 276 title={type === "share" ? t("delete_share") : t("delete_playlist")} 277 > 278 <img alt="" src={icBinEmpty} /> 279 </button> 280 {/if} 281 {#if type === "share"} 282 <button 283 tabindex="-1" 284 class="btn action" 285 onclick={(e) => { 286 e.stopPropagation(); 287 if (item?.url) navigator.clipboard.writeText(item.url); 288 }} 289 title={t("copy_link")} 290 > 291 <img alt="" src={icLink} /> 292 </button> 293 {/if} 294 {#if type !== "share"} 295 <button 296 tabindex="-1" 297 class="btn action" 298 onclick={(e) => { 299 e.stopPropagation(); 300 doAdd(false); 301 }} 302 title={t("add_to_queue")} 303 > 304 <img alt="" src={icAdd} /> 305 </button> 306 {/if} 307 <button 308 tabindex="-1" 309 class="btn touch" 310 onclick={(e) => { 311 e.stopPropagation(); 312 menu = { x: window.innerWidth, y: e.clientY }; 313 }} 314 title={t("context_menu")} 315 > 316317 </button> 318 </div> 319 320 {#if open} 321 <ul> 322 {#if busy} 323 <li>{t("busy")}</li> 324 {:else if items} 325 {#each items as item, i (item.id + i)} 326 <TreeItem 327 childFactory={undefined} 328 fetcher={childFactory?.(item)} 329 id={item.id} 330 isTrack={type !== "artist"} 331 {item} 332 label={item.name || item.title} 333 track={type !== "artist" ? item : null} 334 type={type === "artist" ? "album" : "song"} 335 uid={id + ":" + item.id + ":" + i} 336 /> 337 {/each} 338 {/if} 339 </ul> 340 {/if} 341</li> 342 343{#if menu} 344 <ContextMenu 345 onclose={() => (menu = null)} 346 x={menu.x} 347 y={menu.y} 348 items={[ 349 type !== "share" && { label: t("add"), action: () => doAdd(false) }, 350 type !== "share" && { label: t("add_next"), action: () => doAdd(true) }, 351 type !== "share" && { type: "separator" }, 352 lib.isSharingSupported && 353 type !== "share" && { 354 label: t("share"), 355 action: () => { 356 ui.shareItemId = id; 357 ui.shareItemLabel = label; 358 ui.shareItemType = type; 359 }, 360 }, 361 type === "share" && { 362 label: t("copy_link"), 363 action: () => { 364 if (item?.url) navigator.clipboard.writeText(item.url); 365 }, 366 }, 367 ...(type === "album" || type === "artist" 368 ? [ 369 { 370 label: isStarred ? t("unfavorite") : t("favorite"), 371 action: doToggleStar, 372 }, 373 ] 374 : []), 375 ...(type === "playlist" 376 ? [ 377 ...(canUpdate ? [{ label: t("update"), action: doUpdate }] : []), 378 ...(canDelete 379 ? [ 380 { label: t("rename"), action: doRename }, 381 ...(isOwner || auth.admin 382 ? [ 383 { 384 label: isPublicState 385 ? t("make_private") 386 : t("make_public"), 387 action: doTogglePublic, 388 }, 389 ] 390 : []), 391 { label: t("delete"), action: doDelete }, 392 ] 393 : []), 394 ] 395 : []), 396 type === "share" && { label: t("delete"), action: doDelete }, 397 ].filter(Boolean) as any} 398 /> 399{/if} 400 401<style> 402 ul { 403 list-style: none; 404 padding-inline-start: 1rem; 405 } 406 .row { 407 display: flex; 408 align-items: center; 409 gap: 0.25rem; 410 margin-block-start: 0.5rem; 411 } 412 .btn.main.focused { 413 background: Highlight; 414 color: HighlightText; 415 } 416 :global(#library:not(:focus-within)) .btn.main.focused { 417 background: GrayText; 418 color: HighlightText; 419 } 420 .btn { 421 background: none; 422 border: none; 423 display: flex; 424 align-items: center; 425 } 426 .main { 427 flex: 1; 428 overflow: hidden; 429 gap: 0.5rem; 430 } 431 @media (pointer: coarse) { 432 .action { 433 display: none; 434 } 435 } 436 .art { 437 object-fit: cover; 438 } 439 .touch { 440 display: none; 441 } 442 @media (pointer: coarse) { 443 .touch { 444 display: block; 445 padding: 0.5rem; 446 margin: -0.5rem; 447 } 448 } 449 .label { 450 overflow: hidden; 451 text-overflow: ellipsis; 452 white-space: nowrap; 453 inline-size: 100%; 454 text-align: start; 455 } 456 .label-stack { 457 display: flex; 458 flex-direction: column; 459 align-items: flex-start; 460 overflow: hidden; 461 flex: 1; 462 } 463 .owner { 464 font-size: 0.8rem; 465 opacity: 0.75; 466 } 467 .art-container { 468 position: relative; 469 } 470 .badge { 471 position: absolute; 472 inset-block-end: 0; 473 inset-inline-end: 0; 474 } 475 img { 476 display: block; 477 } 478 button { 479 padding: 0; 480 color: inherit; 481 } 482 :global(body.rounded-art) .art { 483 border-radius: 2px; 484 } 485</style>