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.

feat: sharing

also has detection for servers that don't support sharing (you have to manually configure it on navidrome servers, atleast in the nixpkgs module. i dont think gonic supports it at all, tested with both)

+271 -39
+2
src/App.svelte
··· 8 8 import SettingsModal from "./lib/SettingsModal.svelte"; 9 9 import ShortcutsModal from "./lib/ShortcutsModal.svelte"; 10 10 import PlaylistModals from "./lib/PlaylistModals.svelte"; 11 + import ShareModal from "./lib/ShareModal.svelte"; 11 12 import { onMount } from "svelte"; 12 13 13 14 onMount(init); ··· 39 40 <SettingsModal bind:open={ui.showSettings} /> 40 41 <ShortcutsModal bind:open={ui.showKeys} /> 41 42 <PlaylistModals /> 43 + <ShareModal />
-1
src/lib/AuthModal.svelte
··· 49 49 display: flex; 50 50 flex-direction: column; 51 51 gap: 0.5rem; 52 - inline-size: 16rem; 53 52 } 54 53 .err { 55 54 color: red;
+33 -3
src/lib/Library.svelte
··· 10 10 let isArtistsExpanded = $state(true); 11 11 let isPlaylistsExpanded = $state(true); 12 12 let isSharedPlaylistsExpanded = $state(true); 13 + let isSharesExpanded = $state(true); 13 14 14 15 let myPlaylists = $derived( 15 16 lib.playlists.filter( ··· 211 212 label={item.name} 212 213 fetcher={() => api.playlist(item.id)} 213 214 type="playlist" 214 - isReadonly={item.readonly === true || item.readonly === "true"} 215 + isReadonly={item.readonly === true} 215 216 owner={item.owner} 216 217 isPublic={item.public} 217 218 {item} ··· 244 245 label={item.name} 245 246 fetcher={() => api.playlist(item.id)} 246 247 type="playlist" 247 - isReadonly={item.readonly === true || 248 - item.readonly === "true"} 248 + isReadonly={item.readonly === true} 249 249 owner={item.owner} 250 250 isPublic={item.public} 251 + {item} 252 + /> 253 + {/each} 254 + </ul> 255 + {/if} 256 + </li> 257 + {/if} 258 + {#if lib.isSharingSupported && lib.shares.length} 259 + <li class="section"> 260 + <button 261 + tabindex="-1" 262 + id="lib-shares" 263 + class="label" 264 + class:focused={lib.focusedId === "lib-shares"} 265 + data-id="lib-shares" 266 + onclick={(e) => { 267 + e.stopPropagation(); 268 + isSharesExpanded = !isSharesExpanded; 269 + }} 270 + > 271 + {t("shares")} 272 + </button> 273 + {#if isSharesExpanded} 274 + <ul> 275 + {#each lib.shares as item, itemIndex (item.id)} 276 + <TreeItem 277 + id={item.id} 278 + uid={`share-${item.id}-${itemIndex}`} 279 + label={item.description || item.url} 280 + type="share" 251 281 {item} 252 282 /> 253 283 {/each}
+100
src/lib/ShareModal.svelte
··· 1 + <script lang="ts"> 2 + import Modal from "./Modal.svelte"; 3 + import { ui, t, recursiveSongs, loadLib } from "./app.svelte.js"; 4 + import { api } from "./client.svelte.js"; 5 + 6 + let description = $state(""), 7 + expires = $state("0"), 8 + busy = $state(false), 9 + shareUrl = $state(""); 10 + 11 + $effect(() => { 12 + if (ui.shareItemId) { 13 + description = ui.shareItemLabel; 14 + expires = "0"; 15 + shareUrl = ""; 16 + busy = false; 17 + } 18 + }); 19 + 20 + async function create() { 21 + busy = true; 22 + try { 23 + let songs: any[] = []; 24 + if (ui.shareItemType === "song") { 25 + songs = [{ id: ui.shareItemId }]; 26 + } else { 27 + const fetchers: any = { 28 + artist: api.artist, 29 + album: api.album, 30 + playlist: api.playlist, 31 + }; 32 + const childFactories: any = { 33 + artist: (a: any) => () => api.album(a.id), 34 + }; 35 + 36 + const items = await fetchers[ui.shareItemType](ui.shareItemId!); 37 + songs = await recursiveSongs(items, childFactories[ui.shareItemType]); 38 + } 39 + 40 + const ids = songs.map((s) => s.id); 41 + const expDate = 42 + expires === "0" 43 + ? undefined 44 + : Date.now() + parseInt(expires) * 24 * 60 * 60 * 1000; 45 + 46 + const res = await api.createShare(ids, description, expDate); 47 + shareUrl = res.shares.share[0].url; 48 + await loadLib(); 49 + } catch (err) { 50 + console.error(err); 51 + } finally { 52 + busy = false; 53 + } 54 + } 55 + 56 + let copied = $state(false); 57 + function copy() { 58 + navigator.clipboard.writeText(shareUrl); 59 + copied = true; 60 + setTimeout(() => (copied = false), 2000); 61 + } 62 + </script> 63 + 64 + <Modal 65 + bind:open={() => !!ui.shareItemId, (v) => !v && (ui.shareItemId = null)} 66 + title={t("share")} 67 + > 68 + {#if shareUrl} 69 + <div> 70 + <input readonly value={shareUrl} /> 71 + <button onclick={copy}>{copied ? t("link_copied") : t("copy")}</button> 72 + </div> 73 + {:else} 74 + <form 75 + onsubmit={(e) => { 76 + e.preventDefault(); 77 + create(); 78 + }} 79 + > 80 + <input bind:value={description} placeholder={t("description")} /> 81 + <select bind:value={expires}> 82 + <option value="0">{t("never")}</option> 83 + <option value="1">1 {t("day")}</option> 84 + <option value="7">7 {t("days")}</option> 85 + <option value="30">30 {t("days")}</option> 86 + </select> 87 + <button type="submit" disabled={busy}> 88 + {busy ? t("busy") : t("create_share")} 89 + </button> 90 + </form> 91 + {/if} 92 + </Modal> 93 + 94 + <style> 95 + form { 96 + display: flex; 97 + flex-direction: column; 98 + gap: 0.5rem; 99 + } 100 + </style>
+60 -19
src/lib/TreeItem.svelte
··· 20 20 import icBinEmpty from "../assets/famfamfam-silk-svg/icons/Bin empty.svg"; 21 21 import icWorld from "../assets/famfamfam-silk-svg/icons/World.svg"; 22 22 import icHeart from "../assets/famfamfam-silk-svg/icons/Heart.svg"; 23 + import icLink from "../assets/famfamfam-silk-svg/icons/Link.svg"; 23 24 24 25 let { 25 26 id, ··· 42 43 childFactory?: (item: any) => (() => Promise<any[]>) | undefined; 43 44 isTrack?: boolean; 44 45 track?: any; 45 - type?: "artist" | "album" | "playlist" | "song"; 46 + type?: "artist" | "album" | "playlist" | "song" | "share"; 46 47 isReadonly?: boolean; 47 48 owner?: string | null; 48 49 isPublic?: boolean; ··· 147 148 } 148 149 } 149 150 150 - function doDelete() { 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 + } 151 161 if (canDelete) { 152 162 ui.confirmDeletePlaylistId = id; 153 163 ui.confirmDeletePlaylistName = label; ··· 257 267 <img alt="" src={icDisk} /> 258 268 </button> 259 269 {/if} 260 - {#if canDelete} 270 + {#if canDelete || type === "share"} 261 271 <button 262 272 tabindex="-1" 263 273 class="btn action" ··· 265 275 e.stopPropagation(); 266 276 doDelete(); 267 277 }} 268 - title={t("delete_playlist")} 278 + title={type === "share" ? t("delete_share") : t("delete_playlist")} 269 279 > 270 280 <img alt="" src={icBinEmpty} /> 271 281 </button> 272 282 {/if} 273 - <button 274 - tabindex="-1" 275 - class="btn action" 276 - data-action="add" 277 - onclick={(e) => { 278 - e.stopPropagation(); 279 - doAdd(false); 280 - }} 281 - title={t("add_to_queue")} 282 - > 283 - <img alt="" src={icAdd} /> 284 - </button> 283 + {#if type === "share"} 284 + <button 285 + tabindex="-1" 286 + class="btn action" 287 + onclick={(e) => { 288 + e.stopPropagation(); 289 + if (item?.url) navigator.clipboard.writeText(item.url); 290 + }} 291 + title={t("copy_link")} 292 + > 293 + <img alt="" src={icLink} /> 294 + </button> 295 + {/if} 296 + {#if type !== "share"} 297 + <button 298 + tabindex="-1" 299 + class="btn action" 300 + onclick={(e) => { 301 + e.stopPropagation(); 302 + doAdd(false); 303 + }} 304 + title={t("add_to_queue")} 305 + > 306 + <img alt="" src={icAdd} /> 307 + </button> 308 + {/if} 285 309 <button 286 310 tabindex="-1" 287 311 class="btn touch" ··· 324 348 x={menu.x} 325 349 y={menu.y} 326 350 items={[ 327 - { label: t("add"), action: () => doAdd(false) }, 328 - { label: t("add_next"), action: () => doAdd(true) }, 351 + type !== "share" && { label: t("add"), action: () => doAdd(false) }, 352 + type !== "share" && { label: t("add_next"), action: () => doAdd(true) }, 353 + type !== "share" && { type: "separator" }, 354 + lib.isSharingSupported && 355 + type !== "share" && { 356 + label: t("share"), 357 + action: () => { 358 + ui.shareItemId = id; 359 + ui.shareItemLabel = label; 360 + ui.shareItemType = type; 361 + }, 362 + }, 363 + type === "share" && { 364 + label: t("copy_link"), 365 + action: () => { 366 + if (item?.url) navigator.clipboard.writeText(item.url); 367 + }, 368 + }, 329 369 ...(type === "album" || type === "artist" 330 370 ? [ 331 371 { ··· 355 395 : []), 356 396 ] 357 397 : []), 358 - ]} 398 + type === "share" && { label: t("delete"), action: doDelete }, 399 + ].filter(Boolean) as any} 359 400 /> 360 401 {/if} 361 402
+1 -1
src/lib/auth.svelte.ts
··· 36 36 const user = await api.getUser(username); 37 37 admin = user.adminRole === "true" || user.adminRole === true; 38 38 } catch (err) { 39 - console.error("Failed to fetch user info", err); 39 + console.error("failed to fetch user info:", err); 40 40 } 41 41 42 42 Object.assign(auth, { server, user: username, ok: true, admin });
+18
src/lib/client.svelte.ts
··· 22 22 owner?: string; 23 23 public?: boolean; 24 24 } 25 + export interface Share { 26 + id: string; 27 + url: string; 28 + description?: string; 29 + username: string; 30 + created: string; 31 + visitCount: number; 32 + expires?: string; 33 + entry?: Song[]; 34 + } 35 + 25 36 export interface Song { 26 37 id: string; 27 38 title: string; ··· 174 185 savePlayQueue: (id: string[], current?: string, position?: number) => 175 186 request("savePlayQueue", { id, current, position }, true), 176 187 getPlayQueue: () => request("getPlayQueue"), 188 + getShares: () => 189 + request("getShares").then((response) => asArray(response.shares?.share)), 190 + createShare: (id: string[], description?: string, expires?: number) => 191 + request("createShare", { id, description, expires }), 192 + updateShare: (id: string, description?: string, expires?: number) => 193 + request("updateShare", { id, description, expires }), 194 + deleteShare: (id: string) => request("deleteShare", { id }), 177 195 scrobble: (id: string, submission = true) => 178 196 request("scrobble", { id, submission }), 179 197 getUser: (username: string) =>
+13 -1
src/lib/lang/locales/en.ts
··· 32 32 search: "search...", 33 33 selected: "selected", 34 34 settings: "settings", 35 - shared_playlists: "shared playlists", 35 + share: "share", 36 + shares: "shares", 36 37 shortcuts: "shortcuts", 37 38 song: "song", 38 39 songs: "songs", ··· 49 50 add_to_queue: "add to queue", 50 51 clear_queue: "clear queue", 51 52 clear_selection: "clear sel.", 53 + copy_link: "copy link", 54 + create_share: "create share", 55 + day: "day", 56 + days: "days", 52 57 delete_confirm: 'are you sure you want to delete "{name}"?', 53 58 delete_playlist: "delete playlist", 59 + delete_share: "delete share", 54 60 deleting: "deleting...", 61 + description: "description", 62 + expires: "expires", 55 63 export_selection: "export sel.", 56 64 export_to_playlist: "export to playlist", 57 65 exporting: "exporting...", 66 + link_copied: "link copied", 58 67 make_private: "make private", 59 68 make_public: "make public", 60 69 move_down: "move down", 61 70 move_up: "move up", 71 + never: "never", 62 72 new_playlist: "new playlist", 73 + 63 74 no_lyrics: "No lyrics found.", 64 75 now_playing: "now playing", 65 76 play_next: "play next", ··· 67 78 quality: "quality", 68 79 rename_playlist: "rename playlist", 69 80 renaming: "renaming...", 81 + shared_playlists: "shared playlists", 70 82 sort_selection: "sort sel.", 71 83 undo_warning: "this cannot be undone.", 72 84 update_confirm:
+14 -2
src/lib/lang/locales/es.ts
··· 36 36 search: "buscar...", 37 37 selected: "seleccionado", 38 38 settings: "ajustes", 39 - shared_playlists: "listas compartidas", 39 + share: "compartir", 40 + shares: "enlaces compartidos", 40 41 shortcuts: "atajos", 41 42 song: "canción", 42 43 songs: "canciones", ··· 52 53 add_next: "añadir siguiente", 53 54 add_to_queue: "añadir a la cola", 54 55 clear_queue: "limpiar cola", 55 - clear_selection: "limpiar sel.", 56 + clear_selection: "clear sel.", 57 + copy_link: "copiar enlace", 58 + create_share: "crear enlace", 59 + day: "día", 60 + days: "días", 56 61 delete_confirm: '¿estás seguro de que quieres borrar "{name}"?', 57 62 delete_playlist: "borrar lista", 63 + delete_share: "borrar enlace", 58 64 deleting: "borrando...", 65 + description: "descripción", 66 + expires: "caduca", 59 67 export_selection: "exportar sel.", 60 68 export_to_playlist: "exportar a lista", 61 69 exporting: "exportando...", 70 + link_copied: "enlace copiado", 62 71 make_private: "hacer privada", 63 72 make_public: "hacer pública", 64 73 move_down: "bajar", 65 74 move_up: "subir", 75 + never: "nunca", 66 76 new_playlist: "nueva lista", 77 + 67 78 no_lyrics: "No se encontraron letras.", 68 79 now_playing: "reproduciendo ahora", 69 80 play_next: "reproducir siguiente", ··· 71 82 quality: "calidad", 72 83 rename_playlist: "renombrar lista", 73 84 renaming: "renombrando...", 85 + shared_playlists: "listas compartidas", 74 86 sort_selection: "ordenar sel.", 75 87 undo_warning: "esto no se puede deshacer.", 76 88 update_confirm:
+22 -8
src/lib/library.svelte.ts
··· 3 3 type Artist, 4 4 type Playlist, 5 5 type Song, 6 + type Share, 6 7 internSong, 7 8 } from "./client.svelte.js"; 8 9 9 10 export const lib = $state({ 10 11 artists: [] as Artist[], 11 12 playlists: [] as Playlist[], 13 + shares: [] as Share[], 14 + isSharingSupported: true, 12 15 busy: false, 13 16 focusedId: null as string | null, 14 17 }); ··· 22 25 ]); 23 26 lib.artists = artists; 24 27 lib.playlists = playlists; 28 + 29 + try { 30 + lib.shares = await api.getShares(); 31 + lib.isSharingSupported = true; 32 + } catch (err) { 33 + console.warn("sharing not supported by server:", err); 34 + lib.shares = []; 35 + lib.isSharingSupported = false; 36 + } 25 37 } finally { 26 38 lib.busy = false; 27 39 } ··· 33 45 ): Promise<Song[]> => { 34 46 const songs: Song[] = []; 35 47 const walk = async (items: any[], factory?: typeof childFactory) => { 36 - for (const item of items) { 37 - const fetcher = factory?.(item); 38 - if (fetcher) { 39 - await walk(await fetcher(), undefined); 40 - } else { 41 - songs.push(internSong(item)); 42 - } 43 - } 48 + await Promise.all( 49 + items.map(async (item) => { 50 + const fetcher = factory?.(item); 51 + if (fetcher) { 52 + await walk(await fetcher(), undefined); 53 + } else { 54 + songs.push(internSong(item)); 55 + } 56 + }), 57 + ); 44 58 }; 45 59 await walk(items, childFactory); 46 60 return songs;
+2 -2
src/lib/queue.svelte.ts
··· 201 201 await api.setRating(song.id, rating); 202 202 song.userRating = rating; 203 203 } catch (err) { 204 - console.error("Failed to set rating:", err); 204 + console.error("failed to set rating:", err); 205 205 } 206 206 }; 207 207 ··· 385 385 queue.version++; 386 386 } 387 387 } catch (e) { 388 - console.error("Failed to fetch play queue", e); 388 + console.error("failed to fetch play queue:", e); 389 389 } 390 390 }; 391 391
+1 -1
src/lib/theme.svelte.ts
··· 82 82 cache.set(id, theme); 83 83 apply(theme.bg, theme.text, theme.dark); 84 84 } catch (e) { 85 - console.error("Theme fail", e); 85 + console.error("theme fail:", e); 86 86 } 87 87 }; 88 88 });
+5 -1
src/lib/ui.svelte.ts
··· 11 11 renamePlaylistName: "", 12 12 confirmDeletePlaylistId: null as string | null, 13 13 confirmDeletePlaylistName: "", 14 + shareItemId: null as string | null, 15 + shareItemLabel: "", 16 + shareItemType: "" as any, 14 17 lastUpdatedPlaylistId: null as string | null, 15 18 selectionMode: false, 16 19 ··· 21 24 (this.exportPlaylist ? 1 : 0) + 22 25 (this.confirmUpdatePlaylistId ? 1 : 0) + 23 26 (this.renamePlaylistId ? 1 : 0) + 24 - (this.confirmDeletePlaylistId ? 1 : 0) 27 + (this.confirmDeletePlaylistId ? 1 : 0) + 28 + (this.shareItemId ? 1 : 0) 25 29 ); 26 30 }, 27 31 });