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: various improvements to playlist and library

+179 -67
+18 -4
src/lib/Library.svelte
··· 12 12 let isSharedPlaylistsExpanded = $state(true); 13 13 14 14 let myPlaylists = $derived( 15 - lib.playlists.filter((p) => !p.owner || p.owner === auth.user), 15 + lib.playlists.filter( 16 + (p) => 17 + !p.owner || 18 + (auth.user && p.owner.toLowerCase() === auth.user.toLowerCase()), 19 + ), 16 20 ); 17 21 let sharedPlaylists = $derived( 18 - lib.playlists.filter((p) => p.owner && p.owner !== auth.user), 22 + lib.playlists.filter( 23 + (p) => 24 + p.owner && 25 + auth.user && 26 + p.owner.toLowerCase() !== auth.user.toLowerCase(), 27 + ), 19 28 ); 20 29 21 30 $effect(() => { ··· 141 150 isTrack={type === "song"} 142 151 track={type === "song" ? item : null} 143 152 type={type as any} 153 + {item} 144 154 /> 145 155 {/each} 146 156 </ul> ··· 172 182 fetcher={() => api.artist(item.id)} 173 183 childFactory={(a: any) => () => api.album(a.id)} 174 184 type="artist" 185 + {item} 175 186 /> 176 187 {/each} 177 188 </ul> ··· 200 211 label={item.name} 201 212 fetcher={() => api.playlist(item.id)} 202 213 type="playlist" 203 - isReadonly={item.readonly} 214 + isReadonly={item.readonly === true || item.readonly === "true"} 204 215 owner={item.owner} 205 216 isPublic={item.public} 217 + {item} 206 218 /> 207 219 {/each} 208 220 </ul> ··· 232 244 label={item.name} 233 245 fetcher={() => api.playlist(item.id)} 234 246 type="playlist" 235 - isReadonly={item.readonly} 247 + isReadonly={item.readonly === true || 248 + item.readonly === "true"} 236 249 owner={item.owner} 237 250 isPublic={item.public} 251 + {item} 238 252 /> 239 253 {/each} 240 254 </ul>
+135 -57
src/lib/TreeItem.svelte
··· 18 18 import icAdd from "../assets/famfamfam-silk-svg/icons/Add.svg"; 19 19 import icDisk from "../assets/famfamfam-silk-svg/icons/Disk.svg"; 20 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"; 21 23 22 24 let { 23 25 id, ··· 31 33 isReadonly = false, 32 34 owner = null, 33 35 isPublic = false, 36 + item = null, 34 37 } = $props<{ 35 38 id: string; 36 39 uid?: string; ··· 43 46 isReadonly?: boolean; 44 47 owner?: string | null; 45 48 isPublic?: boolean; 49 + item?: any; 46 50 }>(); 47 51 48 52 let open = $state(false), ··· 53 57 let publicOverride = $state<boolean | null>(null); 54 58 let isPublicState = $derived(publicOverride ?? isPublic); 55 59 60 + let starredOverride = $state<string | undefined | null>(null); 61 + let isStarred = $derived(starredOverride ?? item?.starred); 62 + 63 + async function doToggleStar() { 64 + if (type !== "album" && type !== "artist") return; 65 + const next = !isStarred; 66 + try { 67 + const params = { id }; 68 + starredOverride = next ? new Date().toISOString() : undefined; 69 + next ? await api.star(params) : await api.unstar(params); 70 + if (item) item.starred = starredOverride; 71 + starredOverride = null; 72 + } catch (err) { 73 + console.error(err); 74 + starredOverride = null; 75 + } 76 + } 77 + 56 78 async function doTogglePublic() { 57 - if (type !== "playlist" || owner !== auth.user) return; 79 + if (type !== "playlist" || (owner !== auth.user && !auth.admin)) return; 58 80 try { 59 81 const next = !isPublicState; 60 82 await api.updatePlaylist(id, undefined, undefined, undefined, next); ··· 112 134 } 113 135 114 136 function doUpdate() { 115 - if (type === "playlist" && !isReadonly) { 137 + if (canUpdate) { 116 138 ui.confirmUpdatePlaylistId = id; 117 139 ui.confirmUpdatePlaylistName = label; 118 140 } 119 141 } 120 142 121 143 function doRename() { 122 - if (type === "playlist" && (owner === auth.user || !isReadonly)) { 144 + if (canDelete) { 123 145 ui.renamePlaylistId = id; 124 146 ui.renamePlaylistName = label; 125 147 } 126 148 } 127 149 128 150 function doDelete() { 129 - if (type === "playlist" && (owner === auth.user || !isReadonly)) { 151 + if (canDelete) { 130 152 ui.confirmDeletePlaylistId = id; 131 153 ui.confirmDeletePlaylistName = label; 132 154 } ··· 165 187 ? settings.artPlaylist 166 188 : 0, 167 189 ); 190 + 191 + const isOwner = $derived(!owner || (auth.user && owner === auth.user)); 192 + const canUpdate = $derived( 193 + type === "playlist" && 194 + (auth.admin ? (isOwner ? !isReadonly : true) : isOwner && !isReadonly), 195 + ); 196 + const canDelete = $derived( 197 + type === "playlist" && (auth.admin || (isOwner && !isReadonly)), 198 + ); 168 199 </script> 169 200 170 201 <li> 171 202 <div class="row"> 172 203 <button 173 204 tabindex="-1" 174 - class="btn main" 175 205 class:focused={lib.focusedId === uid} 206 + class="btn main" 176 207 data-id={uid} 177 - use:keyboardActions 178 208 draggable={true} 179 - ondragstart={onDragStart} 209 + onclick={toggle} 180 210 ondragend={onDragEnd} 181 - onclick={toggle} 211 + ondragstart={onDragStart} 182 212 oncontextmenu={(e) => { 183 213 e.preventDefault(); 184 214 menu = { x: e.clientX, y: e.clientY }; 185 215 }} 216 + use:keyboardActions 186 217 > 187 218 {#if artSize > 0} 188 219 {@const artId = track?.albumId || track?.coverArt || id} 189 - <img 190 - class="art" 191 - src={api.art(artId, artSize)} 192 - srcset="{api.art(artId, artSize)} 1x, {api.art( 193 - artId, 194 - artSize * 2, 195 - )} 2x" 196 - style="inline-size: {artSize}px; block-size: {artSize}px;" 197 - alt="" 198 - loading="lazy" 199 - /> 220 + <div class="art-container"> 221 + <img 222 + alt="" 223 + class="art" 224 + loading="lazy" 225 + src={api.art(artId, artSize)} 226 + srcset="{api.art(artId, artSize)} 1x, {api.art( 227 + artId, 228 + artSize * 2, 229 + )} 2x" 230 + style="inline-size: {artSize}px; block-size: {artSize}px;" 231 + /> 232 + {#if type === "playlist" && isPublicState} 233 + <img alt="" class="badge br" src={icWorld} title={t("public")} /> 234 + {/if} 235 + {#if (type === "album" || type === "artist") && isStarred} 236 + <img alt="" class="badge br" src={icHeart} title={t("favorite")} /> 237 + {/if} 238 + </div> 200 239 {/if} 201 - <span class="label">{label}</span> 240 + <div class="label-stack"> 241 + <span class="label">{label}</span> 242 + {#if owner && type === "playlist" && owner.toLowerCase() !== auth.user?.toLowerCase()} 243 + <span class="owner">owner: {owner}</span> 244 + {/if} 245 + </div> 202 246 </button> 203 - {#if type === "playlist"} 204 - {#if !isReadonly} 205 - <button 206 - tabindex="-1" 207 - class="btn action" 208 - onclick={(e) => { 209 - e.stopPropagation(); 210 - doUpdate(); 211 - }} 212 - title={t("update_playlist")} 213 - > 214 - <img src={icDisk} alt="" /> 215 - </button> 216 - {/if} 217 - {#if owner === auth.user || !isReadonly} 218 - <button 219 - tabindex="-1" 220 - class="btn action" 221 - onclick={(e) => { 222 - e.stopPropagation(); 223 - doDelete(); 224 - }} 225 - title={t("delete_playlist")} 226 - > 227 - <img src={icBinEmpty} alt="" /> 228 - </button> 229 - {/if} 247 + {#if canUpdate} 248 + <button 249 + tabindex="-1" 250 + class="btn action" 251 + onclick={(e) => { 252 + e.stopPropagation(); 253 + doUpdate(); 254 + }} 255 + title={t("update_playlist")} 256 + > 257 + <img alt="" src={icDisk} /> 258 + </button> 259 + {/if} 260 + {#if canDelete} 261 + <button 262 + tabindex="-1" 263 + class="btn action" 264 + onclick={(e) => { 265 + e.stopPropagation(); 266 + doDelete(); 267 + }} 268 + title={t("delete_playlist")} 269 + > 270 + <img alt="" src={icBinEmpty} /> 271 + </button> 230 272 {/if} 231 273 <button 232 274 tabindex="-1" ··· 238 280 }} 239 281 title={t("add_to_queue")} 240 282 > 241 - <img src={icAdd} alt="" /> 283 + <img alt="" src={icAdd} /> 242 284 </button> 243 285 <button 244 286 tabindex="-1" ··· 255 297 256 298 {#if open} 257 299 <ul> 258 - {#if busy}<li>{t("busy")}</li> 300 + {#if busy} 301 + <li>{t("busy")}</li> 259 302 {:else if items} 260 303 {#each items as item, i (item.id + i)} 261 304 <TreeItem 262 - id={item.id} 263 - uid={id + ":" + item.id + ":" + i} 264 - label={item.name || item.title} 305 + childFactory={undefined} 265 306 fetcher={childFactory?.(item)} 266 - childFactory={undefined} 307 + id={item.id} 267 308 isTrack={type !== "artist"} 309 + {item} 310 + label={item.name || item.title} 268 311 track={type !== "artist" ? item : null} 269 312 type={type === "artist" ? "album" : "song"} 313 + uid={id + ":" + item.id + ":" + i} 270 314 /> 271 315 {/each} 272 316 {/if} ··· 276 320 277 321 {#if menu} 278 322 <ContextMenu 323 + onclose={() => (menu = null)} 279 324 x={menu.x} 280 325 y={menu.y} 281 - onclose={() => (menu = null)} 282 326 items={[ 283 327 { label: t("add"), action: () => doAdd(false) }, 284 328 { label: t("add_next"), action: () => doAdd(true) }, 329 + ...(type === "album" || type === "artist" 330 + ? [ 331 + { 332 + label: isStarred ? t("unfavorite") : t("favorite"), 333 + action: doToggleStar, 334 + }, 335 + ] 336 + : []), 285 337 ...(type === "playlist" 286 338 ? [ 287 - ...(!isReadonly ? [{ label: t("update"), action: doUpdate }] : []), 288 - ...(owner === auth.user || !isReadonly 339 + ...(canUpdate ? [{ label: t("update"), action: doUpdate }] : []), 340 + ...(canDelete 289 341 ? [ 290 342 { label: t("rename"), action: doRename }, 291 - ...(owner === auth.user 343 + ...(isOwner || auth.admin 292 344 ? [ 293 345 { 294 346 label: isPublicState ··· 360 412 overflow: hidden; 361 413 text-overflow: ellipsis; 362 414 white-space: nowrap; 415 + inline-size: 100%; 416 + text-align: start; 417 + } 418 + .label-stack { 419 + display: flex; 420 + flex-direction: column; 421 + align-items: flex-start; 422 + overflow: hidden; 423 + flex: 1; 424 + } 425 + .owner { 426 + font-size: 0.8rem; 427 + opacity: 0.75; 428 + } 429 + .art-container { 430 + position: relative; 431 + display: flex; 432 + } 433 + .badge { 434 + position: absolute; 435 + inline-size: 16px; 436 + block-size: 16px; 437 + } 438 + .badge.br { 439 + inset-block-end: 0; 440 + inset-inline-end: 0; 363 441 } 364 442 img { 365 443 display: block;
+12 -2
src/lib/auth.svelte.ts
··· 13 13 err: null as string | null, 14 14 server: "", 15 15 user: "", 16 + admin: false, 16 17 }); 17 18 18 19 export const login = async ( ··· 26 27 if (password) { 27 28 setCredentials({ server, username, ...createToken(password) }); 28 29 } 29 - await api.ping(); 30 + const ping = await api.ping(); 30 31 await loadLib(); 31 32 await syncQueue(); 32 - Object.assign(auth, { server, user: username, ok: true }); 33 + 34 + let admin = false; 35 + try { 36 + const user = await api.getUser(username); 37 + admin = user.adminRole === "true" || user.adminRole === true; 38 + } catch (err) { 39 + console.error("Failed to fetch user info", err); 40 + } 41 + 42 + Object.assign(auth, { server, user: username, ok: true, admin }); 33 43 return true; 34 44 } catch (err: any) { 35 45 setCredentials(null);
+8 -2
src/lib/client.svelte.ts
··· 4 4 id: string; 5 5 name: string; 6 6 coverArt?: string; 7 + starred?: string; 7 8 } 8 9 export interface Album { 9 10 id: string; ··· 11 12 artist: string; 12 13 artistId: string; 13 14 coverArt?: string; 15 + starred?: string; 14 16 } 15 17 export interface Playlist { 16 18 id: string; ··· 139 141 }), 140 142 stream: (id: string) => buildUrl("stream", { id }), 141 143 art: (id: string, size = 128) => buildUrl("getCoverArt", { id, size }), 142 - star: (id: string) => request("star", { id }), 143 - unstar: (id: string) => request("unstar", { id }), 144 + star: (params: { id?: string; albumId?: string; artistId?: string }) => 145 + request("star", params), 146 + unstar: (params: { id?: string; albumId?: string; artistId?: string }) => 147 + request("unstar", params), 144 148 setRating: (id: string, rating: number) => 145 149 request("setRating", { id, rating }), 146 150 lyricsById: (id: string) => request("getLyricsBySongId", { id }), ··· 172 176 getPlayQueue: () => request("getPlayQueue"), 173 177 scrobble: (id: string, submission = true) => 174 178 request("scrobble", { id, submission }), 179 + getUser: (username: string) => 180 + request("getUser", { username }).then((response) => response.user), 175 181 }; 176 182 177 183 export const createToken = (password: string) => {
+1
src/lib/lang/locales/en.ts
··· 22 22 playlists: "playlists", 23 23 shared_playlists: "shared playlists", 24 24 prev: "prev", 25 + public: "public", 25 26 queue: "queue", 26 27 rating: "rating", 27 28 redo: "redo",
+1
src/lib/lang/locales/es.ts
··· 26 26 playlists: "listas", 27 27 shared_playlists: "listas compartidas", 28 28 prev: "anterior", 29 + public: "público", 29 30 queue: "cola", 30 31 rating: "calificación", 31 32 redo: "rehacer",
+4 -2
src/lib/queue.svelte.ts
··· 157 157 export const toggleStar = async (song: Song) => { 158 158 const isStarred = !!song.starred; 159 159 try { 160 - isStarred ? await api.unstar(song.id) : await api.star(song.id); 160 + isStarred 161 + ? await api.unstar({ id: song.id }) 162 + : await api.star({ id: song.id }); 161 163 song.starred = isStarred ? undefined : new Date().toISOString(); 162 164 } catch (err) { 163 - console.error("Failed to toggle star:", err); 165 + console.error(err); 164 166 } 165 167 }; 166 168