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

Configure Feed

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

at main 350 lines 9.7 kB view raw
1import { api, type Song, asArray, internSong } from "./client.svelte.js"; 2import { player, play, stop, restoreTrack } from "./player.svelte.js"; 3import { nav, ui } from "./ui.svelte.js"; 4import { auth } from "./auth.svelte.js"; 5import { t } from "./lang.svelte.js"; 6 7export const queue = $state({ 8 tracks: [] as Song[], 9 pos: -1, 10 sel: [] as number[], 11 version: 0, 12}); 13 14export const select = (index: number, multi = false, range = false) => { 15 if (index < 0 || index >= queue.tracks.length) return; 16 if (range && nav.anchor !== -1) { 17 const start = Math.min(nav.anchor, index), 18 end = Math.max(nav.anchor, index); 19 const ids = Array.from({ length: end - start + 1 }, (_, k) => start + k); 20 queue.sel = multi ? [...new Set([...queue.sel, ...ids])] : ids; 21 } else if (multi) { 22 queue.sel = queue.sel.includes(index) 23 ? queue.sel.filter((i) => i !== index) 24 : [...queue.sel, index]; 25 nav.anchor = index; 26 } else { 27 queue.sel = [index]; 28 nav.anchor = index; 29 } 30 nav.head = index; 31}; 32 33export const moveHead = (direction: number, expand = false) => { 34 if (nav.anchor === -1) return queue.tracks.length > 0 && select(0); 35 const nextIdx = nav.head + direction; 36 if (nextIdx >= 0 && nextIdx < queue.tracks.length) 37 select(nextIdx, false, expand); 38}; 39 40export const reorder = (direction: -1 | 1) => { 41 if (!queue.sel.length) return; 42 const ids = [...queue.sel].sort((a, b) => a - b); 43 if ( 44 (direction === -1 && ids[0] === 0) || 45 (direction === 1 && ids[ids.length - 1] === queue.tracks.length - 1) 46 ) 47 return; 48 const move = direction === -1 ? ids : ids.reverse(); 49 for (const index of move) { 50 const to = index + direction; 51 [queue.tracks[index], queue.tracks[to]] = [ 52 queue.tracks[to], 53 queue.tracks[index], 54 ]; 55 queue.sel[queue.sel.indexOf(index)] = to; 56 if (queue.pos === index) queue.pos = to; 57 else if (queue.pos === to) queue.pos = index; 58 if (nav.anchor === index) nav.anchor = to; 59 if (nav.head === index) nav.head = to; 60 } 61 queue.version++; 62}; 63 64export const clearSel = () => { 65 queue.sel = []; 66 nav.anchor = nav.head = -1; 67}; 68 69export const add = (songs: Song | Song[], next = false) => { 70 const items = asArray(songs).map(internSong); 71 if (next) { 72 queue.tracks.splice(queue.pos + 1, 0, ...items); 73 } else queue.tracks.push(...items); 74 queue.version++; 75}; 76 77export const remove = () => { 78 if (!queue.sel.length) return; 79 80 const playing = queue.sel.includes(queue.pos), 81 oldPos = queue.pos, 82 removedBefore = queue.sel.filter((i) => i < oldPos).length; 83 84 queue.tracks = queue.tracks.filter((_, i) => !queue.sel.includes(i)); 85 86 if (playing) { 87 if (queue.tracks.length) { 88 queue.pos = Math.min(oldPos - removedBefore, queue.tracks.length - 1); 89 const track = queue.tracks[queue.pos]; 90 if (!player.paused) play(track); 91 else restoreTrack(track, 0); 92 } else stop(); 93 } else if (queue.pos !== -1) { 94 queue.pos = oldPos - removedBefore; 95 } 96 97 clearSel(); 98 queue.version++; 99}; 100 101export const goto = (index: number) => { 102 if (index >= 0 && index < queue.tracks.length) { 103 queue.pos = index; 104 play(queue.tracks[index]); 105 queue.version++; 106 } else if (player.loop && queue.tracks.length) goto(0); 107 else stop(); 108}; 109 110export const next = () => goto(queue.pos + 1); 111export const prev = () => 112 queue.pos <= 0 && player.loop && queue.tracks.length 113 ? goto(queue.tracks.length - 1) 114 : goto(queue.pos - 1); 115 116export const toggleStar = async (song: Song) => { 117 const isStarred = !!song.starred; 118 try { 119 isStarred ? await api.unstar(song.id) : await api.star(song.id); 120 song.starred = isStarred ? undefined : new Date().toISOString(); 121 } catch (err) { 122 console.error("Failed to toggle star:", err); 123 } 124}; 125 126export const setRating = async (song: Song, rating: number) => { 127 try { 128 await api.setRating(song.id, rating); 129 song.userRating = rating; 130 } catch (err) { 131 console.error("Failed to set rating:", err); 132 } 133}; 134 135export const moveSelected = (targetIndex: number) => { 136 if (!queue.sel.length) return; 137 138 const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); 139 const selIndices = [...queue.sel].sort((a, b) => a - b); 140 const moved = selIndices.map((i) => items[i]); 141 const remaining = items.filter((_, i) => !queue.sel.includes(i)); 142 143 const removedBefore = queue.sel.filter((i) => i < targetIndex).length; 144 const actualTarget = Math.max(0, targetIndex - removedBefore); 145 146 remaining.splice(actualTarget, 0, ...moved); 147 148 queue.tracks = remaining.map((x) => x.t); 149 queue.pos = remaining.findIndex((x) => x.p); 150 151 queue.sel = Array.from({ length: moved.length }, (_, i) => actualTarget + i); 152 nav.anchor = queue.sel[0]; 153 nav.head = queue.sel[queue.sel.length - 1]; 154 queue.version++; 155}; 156 157export const playNext = () => { 158 if (!queue.sel.length || queue.pos === -1) return; 159 160 const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); 161 const selIndices = [...queue.sel].sort((a, b) => a - b); 162 const moved = selIndices.map((i) => items[i]); 163 const remaining = items.filter((_, i) => !queue.sel.includes(i)); 164 165 const newPos = remaining.findIndex((x) => x.p); 166 remaining.splice(newPos + 1, 0, ...moved); 167 168 queue.tracks = remaining.map((x) => x.t); 169 queue.pos = remaining.findIndex((x) => x.p); 170 queue.sel = moved.map((_, i) => newPos + 1 + i); 171 queue.version++; 172}; 173 174export const moveUp = () => reorder(-1); 175export const moveDown = () => reorder(1); 176 177export const sortQueue = (field: string, asc = true) => { 178 const isAll = !queue.sel.length; 179 const indices = isAll 180 ? queue.tracks.map((_, i) => i) 181 : [...queue.sel].sort((a, b) => a - b); 182 183 const targets = indices.map((idx) => ({ 184 t: queue.tracks[idx], 185 p: idx === queue.pos, 186 })); 187 188 const cmp = (a: any, b: any) => 189 (a || "").toString().localeCompare((b || "").toString(), undefined, { 190 numeric: true, 191 }); 192 193 const trackCmp = (a: Song, b: Song) => 194 a.discNumber !== b.discNumber 195 ? (a.discNumber || 0) - (b.discNumber || 0) 196 : (a.track || 0) - (b.track || 0); 197 198 targets.sort((a, b) => { 199 const s1 = a.t, 200 s2 = b.t; 201 let res = 0; 202 if (field === "artist") { 203 res = 204 cmp(s1.artist, s2.artist) || 205 cmp(s1.album, s2.album) || 206 trackCmp(s1, s2); 207 } else if (field === "album") { 208 res = cmp(s1.album, s2.album) || trackCmp(s1, s2); 209 } else { 210 const val = (s: Song) => { 211 if (field === "stars") return !!s.starred ? 1 : 0; 212 if (field === "rating") return s.userRating || 0; 213 if (field === "duration") return s.duration || 0; 214 return (s as any)[field]; 215 }; 216 const av = val(s1), 217 bv = val(s2); 218 res = typeof av === "number" ? av - bv : cmp(av, bv); 219 } 220 return asc ? res : -res; 221 }); 222 223 indices.forEach((idx, i) => { 224 queue.tracks[idx] = targets[i].t; 225 if (targets[i].p) queue.pos = idx; 226 }); 227 228 queue.version++; 229}; 230 231export const starSelected = (star: boolean) => 232 queue.sel.forEach( 233 (i) => !!queue.tracks[i].starred !== star && toggleStar(queue.tracks[i]), 234 ); 235 236export const rateSelected = (rating: number) => 237 queue.sel.forEach((i) => setRating(queue.tracks[i], rating)); 238 239export const selectAll = () => { 240 queue.sel = queue.tracks.map((_, i) => i); 241 nav.anchor = 0; 242 nav.head = queue.tracks.length - 1; 243}; 244 245export const clear = () => { 246 if (queue.sel.length) return remove(); 247 queue.tracks = []; 248 stop(); 249 clearSel(); 250 queue.version++; 251}; 252 253export const shuffle = () => { 254 const isAll = !queue.sel.length; 255 const indices = isAll 256 ? queue.tracks.map((_, i) => i) 257 : [...queue.sel].sort((a, b) => a - b); 258 259 const items = indices.map((idx) => ({ 260 t: queue.tracks[idx], 261 p: idx === queue.pos, 262 })); 263 264 for (let i = items.length - 1; i > 0; i--) { 265 const j = Math.floor(Math.random() * (i + 1)); 266 [items[i], items[j]] = [items[j], items[i]]; 267 } 268 269 indices.forEach((idx, i) => { 270 queue.tracks[idx] = items[i].t; 271 if (items[i].p) queue.pos = idx; 272 }); 273 274 queue.version++; 275}; 276 277export const getSortItems = () => [ 278 { label: t("shuffle"), action: shuffle }, 279 { label: t("title_az"), action: () => sortQueue("title", true) }, 280 { label: t("title_za"), action: () => sortQueue("title", false) }, 281 { label: t("artist_az"), action: () => sortQueue("artist", true) }, 282 { label: t("artist_za"), action: () => sortQueue("artist", false) }, 283 { label: t("album_az"), action: () => sortQueue("album", true) }, 284 { label: t("album_za"), action: () => sortQueue("album", false) }, 285 { 286 label: t("fav_first"), 287 action: () => sortQueue("stars", false), 288 }, 289 { 290 label: t("fav_last"), 291 action: () => sortQueue("stars", true), 292 }, 293 { 294 label: t("rating_high"), 295 action: () => sortQueue("rating", false), 296 }, 297 { 298 label: t("rating_low"), 299 action: () => sortQueue("rating", true), 300 }, 301 { 302 label: t("time_long"), 303 action: () => sortQueue("duration", false), 304 }, 305 { 306 label: t("time_short"), 307 action: () => sortQueue("duration", true), 308 }, 309]; 310 311export const syncQueue = async () => { 312 try { 313 const saved = (await api.getPlayQueue()).playQueue; 314 if (saved) { 315 const entries = asArray(saved.entry).map(internSong); 316 queue.tracks = entries; 317 if (saved.current) { 318 const idx = queue.tracks.findIndex((t) => t.id === saved.current); 319 if (idx !== -1) { 320 queue.pos = idx; 321 if (saved.position) 322 restoreTrack(queue.tracks[idx], saved.position / 1000); 323 else { 324 player.track = queue.tracks[idx]; 325 player.audio.src = api.stream(player.track.id); 326 } 327 } 328 } 329 } 330 } catch (e) { 331 console.error("Failed to fetch play queue", e); 332 } 333}; 334 335$effect.root(() => { 336 let timer: any; 337 $effect(() => { 338 queue.version; 339 if (ui.busy || !auth.ok) return; 340 clearTimeout(timer); 341 timer = setTimeout(() => { 342 if (!queue.tracks.length) return api.savePlayQueue([]); 343 api.savePlayQueue( 344 queue.tracks.map((t) => t.id), 345 player.track?.id || queue.tracks[0].id, 346 Math.floor(player.time * 1000), 347 ); 348 }, 1000); 349 }); 350});