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.

refactor: general cleanup and improvements

largely just focusing on making the entire codebase more maintainable, consistent and easy to understand, along with various other improvements along the way

zzz

+546 -573
+1 -1
src/lib/Actions.svelte
··· 5 5 import icShuffle from "../assets/famfamfam-silk-svg/icons/Arrow Switch.svg"; 6 6 import icClear from "../assets/famfamfam-silk-svg/icons/Cross.svg"; 7 7 import icDisk from "../assets/famfamfam-silk-svg/icons/Disk.svg"; 8 - let { onShowSettings } = $props(); 8 + let { onShowSettings }: { onShowSettings: () => void } = $props(); 9 9 10 10 let menu = $state<{ x: number; y: number } | null>(null); 11 11 </script>
+59 -54
src/lib/ContextMenu.svelte
··· 18 18 19 19 let active = $state<number | null>(null), 20 20 subActive = $state<number | null>(null), 21 - w = $state(0), 22 - h = $state(0), 23 - el = $state<HTMLElement>(); 21 + width = $state(0), 22 + height = $state(0), 23 + element = $state<HTMLElement>(); 24 24 25 25 onMount(() => { 26 26 const prevFocus = document.activeElement as HTMLElement | null; 27 - el?.focus(); 27 + element?.focus(); 28 28 return () => prevFocus?.focus(); 29 29 }); 30 30 31 - const exec = (a?: () => void) => { 32 - if (a) { 33 - a(); 31 + const exec = (action?: () => void) => { 32 + if (action) { 33 + action(); 34 34 onclose(); 35 35 } 36 36 }; 37 37 38 - const left = $derived(x + w > window.innerWidth ? Math.max(0, x - w) : x); 39 - const top = $derived(y + h > window.innerHeight ? Math.max(0, y - h) : y); 38 + const left = $derived( 39 + x + width > window.innerWidth ? Math.max(0, x - width) : x, 40 + ); 41 + const top = $derived( 42 + y + height > window.innerHeight ? Math.max(0, y - height) : y, 43 + ); 40 44 41 - function onKey(e: KeyboardEvent) { 42 - e.stopPropagation(); 43 - const k = e.key; 44 - if (k === "Escape") { 45 + function onKey(event: KeyboardEvent) { 46 + event.stopPropagation(); 47 + const key = event.key; 48 + if (key === "Escape") { 45 49 onclose(); 46 - } else if (k === "ArrowDown") { 47 - e.preventDefault(); 50 + } else if (key === "ArrowDown") { 51 + event.preventDefault(); 48 52 if (subActive !== null && active !== null && items[active].items) { 49 - const s = items[active].items!; 50 - let next = (subActive + 1) % s.length; 51 - while (s[next]?.type === "separator") next = (next + 1) % s.length; 53 + const subItems = items[active].items!; 54 + let next = (subActive + 1) % subItems.length; 55 + while (subItems[next]?.type === "separator") 56 + next = (next + 1) % subItems.length; 52 57 subActive = next; 53 58 } else { 54 59 let next = active === null ? 0 : (active + 1) % items.length; ··· 57 62 active = next; 58 63 subActive = null; 59 64 } 60 - } else if (k === "ArrowUp") { 61 - e.preventDefault(); 65 + } else if (key === "ArrowUp") { 66 + event.preventDefault(); 62 67 if (subActive !== null && active !== null && items[active].items) { 63 - const s = items[active].items!; 64 - let next = (subActive - 1 + s.length) % s.length; 65 - while (s[next]?.type === "separator") 66 - next = (next - 1 + s.length) % s.length; 68 + const subItems = items[active].items!; 69 + let next = (subActive - 1 + subItems.length) % subItems.length; 70 + while (subItems[next]?.type === "separator") 71 + next = (next - 1 + subItems.length) % subItems.length; 67 72 subActive = next; 68 73 } else { 69 74 let next = ··· 75 80 active = next; 76 81 subActive = null; 77 82 } 78 - } else if (k === "ArrowRight") { 83 + } else if (key === "ArrowRight") { 79 84 if (active !== null && items[active].items) { 80 - e.preventDefault(); 85 + event.preventDefault(); 81 86 subActive = 0; 82 87 } 83 - } else if (k === "ArrowLeft") { 88 + } else if (key === "ArrowLeft") { 84 89 if (subActive !== null) { 85 - e.preventDefault(); 90 + event.preventDefault(); 86 91 subActive = null; 87 92 } 88 - } else if (k === "Enter") { 89 - e.preventDefault(); 93 + } else if (key === "Enter") { 94 + event.preventDefault(); 90 95 if (active !== null) { 91 96 const item = items[active]; 92 97 if (item.items) { ··· 112 117 subActive = null; 113 118 } 114 119 }} 115 - onclick={(e) => { 116 - e.stopPropagation(); 120 + onclick={(event) => { 121 + event.stopPropagation(); 117 122 if (item.items) { 118 123 active = items.indexOf(item); 119 124 subActive = null; ··· 133 138 <div 134 139 class="overlay" 135 140 onclick={onclose} 136 - oncontextmenu={(e) => { 137 - e.preventDefault(); 141 + oncontextmenu={(event) => { 142 + event.preventDefault(); 138 143 onclose(); 139 144 }} 140 145 > 141 146 <div 142 - bind:this={el} 147 + bind:this={element} 143 148 class="menu" 144 149 style="top: {top}px; left: {left}px;" 145 - onclick={(e) => e.stopPropagation()} 150 + onclick={(event) => event.stopPropagation()} 146 151 onkeydown={onKey} 147 - bind:clientWidth={w} 148 - bind:clientHeight={h} 152 + bind:clientWidth={width} 153 + bind:clientHeight={height} 149 154 tabindex="0" 150 155 > 151 - {#each items as item, i} 156 + {#each items as item, index} 152 157 {#if item.type === "separator"} 153 158 <div class="separator"></div> 154 159 {:else} ··· 158 163 if (subActive === null) active = null; 159 164 }} 160 165 > 161 - {@render menuButton(item, active === i && subActive === null, false)} 162 - {#if item.items && active === i} 166 + {@render menuButton( 167 + item, 168 + active === index && subActive === null, 169 + false, 170 + )} 171 + {#if item.items && active === index} 163 172 <div 164 173 class="submenu" 165 - class:flip-x={left + w + 160 > window.innerWidth} 166 - class:flip-y={top + i * 32 + item.items.length * 32 > 174 + class:flip-x={left + width + 160 > window.innerWidth} 175 + class:flip-y={top + index * 32 + item.items.length * 32 > 167 176 window.innerHeight} 168 177 > 169 - {#each item.items as sub, si} 178 + {#each item.items as sub, subIndex} 170 179 {#if sub.type === "separator"} 171 180 <div class="separator"></div> 172 181 {:else} 173 - {@render menuButton(sub, subActive === si, true)} 182 + {@render menuButton(sub, subActive === subIndex, true)} 174 183 {/if} 175 184 {/each} 176 185 </div> ··· 187 196 inset: 0; 188 197 z-index: 1000; 189 198 } 190 - .menu { 199 + .menu, 200 + .submenu { 191 201 position: absolute; 192 202 background: Canvas; 193 - color: CanvasText; 194 203 border: 1px solid var(--border); 195 204 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 196 205 min-inline-size: 160px; 197 206 display: flex; 198 207 flex-direction: column; 208 + } 209 + .menu { 210 + color: CanvasText; 199 211 outline: none; 200 212 } 201 213 .row { ··· 230 242 opacity: 0.5; 231 243 } 232 244 .submenu { 233 - position: absolute; 234 245 inset-inline-start: 100%; 235 246 inset-block-start: 0; 236 - background: Canvas; 237 - border: 1px solid var(--border); 238 - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 239 - min-inline-size: 160px; 240 - display: flex; 241 - flex-direction: column; 242 247 } 243 248 .submenu.flip-x { 244 249 inset-inline-start: auto;
+148 -153
src/lib/Library.svelte
··· 7 7 let query = $state(""), 8 8 results = $state<any>(null); 9 9 10 - let isArtistsExpanded = $state(true); 11 - let isPlaylistsExpanded = $state(true); 12 - let isSharedPlaylistsExpanded = $state(true); 13 - let isSharesExpanded = $state(true); 10 + let expanded = $state({ 11 + artists: true, 12 + playlists: true, 13 + shared: true, 14 + shares: true, 15 + }); 14 16 15 17 let myPlaylists = $derived( 16 18 lib.playlists.filter( 17 - (p) => 18 - !p.owner || 19 - (auth.user && p.owner.toLowerCase() === auth.user.toLowerCase()), 19 + (playlist) => 20 + !playlist.owner || 21 + (auth.user && playlist.owner.toLowerCase() === auth.user.toLowerCase()), 20 22 ), 21 23 ); 22 24 let sharedPlaylists = $derived( 23 25 lib.playlists.filter( 24 - (p) => 25 - p.owner && 26 + (playlist) => 27 + playlist.owner && 26 28 auth.user && 27 - p.owner.toLowerCase() !== auth.user.toLowerCase(), 29 + playlist.owner.toLowerCase() !== auth.user.toLowerCase(), 28 30 ), 29 31 ); 30 32 ··· 39 41 return () => clearTimeout(timer); 40 42 }); 41 43 42 - function onKey(e: KeyboardEvent) { 43 - if ((e.target as HTMLElement).tagName === "INPUT") return; 44 + function onKey(event: KeyboardEvent) { 45 + if ((event.target as HTMLElement).tagName === "INPUT") return; 44 46 const elements = Array.from( 45 47 document.querySelectorAll("#library [data-id]"), 46 48 ) as HTMLElement[]; 47 - const focusedIndex = elements.findIndex((el) => 48 - el.classList.contains("focused"), 49 + const focusedIndex = elements.findIndex((element) => 50 + element.classList.contains("focused"), 49 51 ); 50 - const { key, code, altKey: alt, shiftKey: shift } = e; 52 + const { key, code, altKey: isAltPressed, shiftKey: isShiftPressed } = event; 51 53 52 - if (key === "Escape") return (e.preventDefault(), (lib.focusedId = null)); 54 + if (key === "Escape") 55 + return (event.preventDefault(), (lib.focusedId = null)); 53 56 54 57 const offsets: Record<string, number> = { 55 58 ArrowDown: 1, ··· 61 64 }; 62 65 63 66 if (key in offsets) { 64 - e.preventDefault(); 67 + event.preventDefault(); 65 68 const targetIndex = Math.max( 66 69 0, 67 70 Math.min(elements.length - 1, focusedIndex + offsets[key]), ··· 72 75 next.scrollIntoView({ block: "nearest" }); 73 76 } 74 77 } else if (code === "Enter") { 75 - e.preventDefault(); 78 + event.preventDefault(); 76 79 elements[focusedIndex]?.click(); 77 80 } else if (code === "KeyA") { 78 - e.preventDefault(); 81 + event.preventDefault(); 79 82 elements[focusedIndex]?.dispatchEvent( 80 - new CustomEvent("actionadd", { detail: alt }), 83 + new CustomEvent("actionadd", { detail: isAltPressed }), 81 84 ); 82 85 } else if ( 83 86 key === "ContextMenu" || 84 87 key === "`" || 85 - (key === "F10" && shift) 88 + (key === "F10" && isShiftPressed) 86 89 ) { 87 - e.preventDefault(); 88 - const el = elements[focusedIndex] as HTMLElement; 89 - if (el) { 90 - const rect = el.getBoundingClientRect(); 91 - el.dispatchEvent( 90 + event.preventDefault(); 91 + const element = elements[focusedIndex] as HTMLElement; 92 + if (element) { 93 + const bounds = element.getBoundingClientRect(); 94 + element.dispatchEvent( 92 95 new MouseEvent("contextmenu", { 93 96 bubbles: true, 94 97 cancelable: true, 95 - clientX: rect.left + 50, 96 - clientY: rect.bottom, 98 + clientX: bounds.left + 50, 99 + clientY: bounds.bottom, 97 100 }), 98 101 ); 99 102 } ··· 101 104 } 102 105 </script> 103 106 107 + {#snippet section( 108 + label: string, 109 + id: string, 110 + expanded = true, 111 + toggle?: () => void, 112 + children?: any, 113 + )} 114 + <li class="section"> 115 + <button 116 + tabindex="-1" 117 + {id} 118 + class="label" 119 + class:focused={lib.focusedId === id} 120 + data-id={id} 121 + onclick={(e) => { 122 + e.stopPropagation(); 123 + toggle?.(); 124 + }} 125 + > 126 + {label} 127 + </button> 128 + {#if expanded} 129 + {@render children?.()} 130 + {/if} 131 + </li> 132 + {/snippet} 133 + 104 134 <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 105 135 <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 106 136 <nav ··· 125 155 {#each ["artist", "album", "song"] as type} 126 156 {@const items = asArray(results[type])} 127 157 {#if items.length} 128 - <li class="section"> 129 - <button 130 - tabindex="-1" 131 - class="label" 132 - class:focused={lib.focusedId === `search-${type}`} 133 - data-id={`search-${type}`} 134 - onclick={(e) => e.stopPropagation()} 135 - > 136 - {t((type + "s") as any)} 137 - </button> 158 + {@render section( 159 + t((type + "s") as any), 160 + `search-${type}`, 161 + true, 162 + undefined, 163 + searchItems, 164 + )} 165 + {#snippet searchItems()} 138 166 <ul> 139 167 {#each items as item, itemIndex (item.id + itemIndex)} 140 168 <TreeItem ··· 156 184 /> 157 185 {/each} 158 186 </ul> 159 - </li> 187 + {/snippet} 160 188 {/if} 161 189 {/each} 162 190 {:else} 163 - <li class="section"> 164 - <button 165 - tabindex="-1" 166 - id="lib-artists" 167 - class="label" 168 - class:focused={lib.focusedId === "lib-artists"} 169 - data-id="lib-artists" 170 - onclick={(e) => { 171 - e.stopPropagation(); 172 - isArtistsExpanded = !isArtistsExpanded; 173 - }} 174 - > 175 - {t("artists")} 176 - </button> 177 - {#if isArtistsExpanded} 191 + {@render section( 192 + t("artists"), 193 + "lib-artists", 194 + expanded.artists, 195 + () => (expanded.artists = !expanded.artists), 196 + artists, 197 + )} 198 + {#snippet artists()} 199 + <ul> 200 + {#each lib.artists as item, itemIndex (item.id)} 201 + <TreeItem 202 + id={item.id} 203 + uid={`artist-${item.id}-${itemIndex}`} 204 + label={item.name} 205 + fetcher={() => api.artist(item.id)} 206 + childFactory={(a: any) => () => api.album(a.id)} 207 + type="artist" 208 + {item} 209 + /> 210 + {/each} 211 + </ul> 212 + {/snippet} 213 + 214 + {@render section( 215 + t("playlists"), 216 + "lib-playlists", 217 + expanded.playlists, 218 + () => (expanded.playlists = !expanded.playlists), 219 + playlists, 220 + )} 221 + {#snippet playlists()} 222 + <ul> 223 + {#each myPlaylists as item, itemIndex (item.id)} 224 + <TreeItem 225 + id={item.id} 226 + uid={`playlist-${item.id}-${itemIndex}`} 227 + label={item.name} 228 + fetcher={() => api.playlist(item.id)} 229 + type="playlist" 230 + isReadonly={item.readonly === true} 231 + owner={item.owner} 232 + isPublic={item.public} 233 + {item} 234 + /> 235 + {/each} 236 + </ul> 237 + {/snippet} 238 + 239 + {#if sharedPlaylists.length} 240 + {@render section( 241 + t("shared_playlists"), 242 + "lib-shared-playlists", 243 + expanded.shared, 244 + () => (expanded.shared = !expanded.shared), 245 + shared, 246 + )} 247 + {#snippet shared()} 178 248 <ul> 179 - {#each lib.artists as item, itemIndex (item.id)} 249 + {#each sharedPlaylists as item, itemIndex (item.id)} 180 250 <TreeItem 181 251 id={item.id} 182 - uid={`artist-${item.id}-${itemIndex}`} 183 - label={item.name} 184 - fetcher={() => api.artist(item.id)} 185 - childFactory={(a: any) => () => api.album(a.id)} 186 - type="artist" 187 - {item} 188 - /> 189 - {/each} 190 - </ul> 191 - {/if} 192 - </li> 193 - <li class="section"> 194 - <button 195 - tabindex="-1" 196 - id="lib-playlists" 197 - class="label" 198 - class:focused={lib.focusedId === "lib-playlists"} 199 - data-id="lib-playlists" 200 - onclick={(e) => { 201 - e.stopPropagation(); 202 - isPlaylistsExpanded = !isPlaylistsExpanded; 203 - }} 204 - > 205 - {t("playlists")} 206 - </button> 207 - {#if isPlaylistsExpanded} 208 - <ul> 209 - {#each myPlaylists as item, itemIndex (item.id)} 210 - <TreeItem 211 - id={item.id} 212 - uid={`playlist-${item.id}-${itemIndex}`} 252 + uid={`shared-playlist-${item.id}-${itemIndex}`} 213 253 label={item.name} 214 254 fetcher={() => api.playlist(item.id)} 215 255 type="playlist" ··· 220 260 /> 221 261 {/each} 222 262 </ul> 223 - {/if} 224 - </li> 225 - {#if sharedPlaylists.length} 226 - <li class="section"> 227 - <button 228 - tabindex="-1" 229 - id="lib-shared-playlists" 230 - class="label" 231 - class:focused={lib.focusedId === "lib-shared-playlists"} 232 - data-id="lib-shared-playlists" 233 - onclick={(e) => { 234 - e.stopPropagation(); 235 - isSharedPlaylistsExpanded = !isSharedPlaylistsExpanded; 236 - }} 237 - > 238 - {t("shared_playlists")} 239 - </button> 240 - {#if isSharedPlaylistsExpanded} 241 - <ul> 242 - {#each sharedPlaylists as item, itemIndex (item.id)} 243 - <TreeItem 244 - id={item.id} 245 - uid={`shared-playlist-${item.id}-${itemIndex}`} 246 - label={item.name} 247 - fetcher={() => api.playlist(item.id)} 248 - type="playlist" 249 - isReadonly={item.readonly === true} 250 - owner={item.owner} 251 - isPublic={item.public} 252 - {item} 253 - /> 254 - {/each} 255 - </ul> 256 - {/if} 257 - </li> 263 + {/snippet} 258 264 {/if} 265 + 259 266 {#if lib.isSharingSupported && lib.shares.length} 260 - <li class="section"> 261 - <button 262 - tabindex="-1" 263 - id="lib-shares" 264 - class="label" 265 - class:focused={lib.focusedId === "lib-shares"} 266 - data-id="lib-shares" 267 - onclick={(e) => { 268 - e.stopPropagation(); 269 - isSharesExpanded = !isSharesExpanded; 270 - }} 271 - > 272 - {t("shares")} 273 - </button> 274 - {#if isSharesExpanded} 275 - <ul> 276 - {#each lib.shares as item, itemIndex (item.id)} 277 - <TreeItem 278 - id={item.id} 279 - uid={`share-${item.id}-${itemIndex}`} 280 - label={item.description || item.url} 281 - type="share" 282 - {item} 283 - /> 284 - {/each} 285 - </ul> 286 - {/if} 287 - </li> 267 + {@render section( 268 + t("shares"), 269 + "lib-shares", 270 + expanded.shares, 271 + () => (expanded.shares = !expanded.shares), 272 + shares, 273 + )} 274 + {#snippet shares()} 275 + <ul> 276 + {#each lib.shares as item, itemIndex (item.id)} 277 + <TreeItem 278 + id={item.id} 279 + uid={`share-${item.id}-${itemIndex}`} 280 + label={item.description || item.url} 281 + type="share" 282 + {item} 283 + /> 284 + {/each} 285 + </ul> 286 + {/snippet} 288 287 {/if} 289 288 {/if} 290 289 </ul> ··· 299 298 } 300 299 #search { 301 300 inline-size: 100%; 302 - } 303 - .tree { 304 - padding: 0; 305 - margin: 0; 306 301 } 307 302 ul { 308 303 padding: 0;
+10 -17
src/lib/Modal.svelte
··· 1 1 <script lang="ts"> 2 + import type { Snippet } from "svelte"; 2 3 import icCross from "../assets/famfamfam-silk-svg/icons/Cross.svg"; 3 4 import { ui, t } from "./app.svelte.js"; 4 5 ··· 12 13 } = $props<{ 13 14 open: boolean; 14 15 title?: string; 15 - children?: any; 16 + children?: Snippet; 16 17 modal?: boolean; 17 18 closable?: boolean; 18 19 onclose?: () => void; 19 20 }>(); 20 21 let dialog: HTMLDialogElement; 21 - let isOpen = false; 22 22 23 23 $effect(() => { 24 - if (open && !isOpen) { 25 - isOpen = true; 24 + if (open && !dialog.open) { 26 25 modal ? dialog.showModal() : dialog.show(); 27 - } else if (!open && isOpen) { 28 - isOpen = false; 29 - if (dialog.open) dialog.close(); 30 - if (onclose) onclose(); 26 + } else if (!open && dialog.open) { 27 + dialog.close(); 28 + onclose?.(); 31 29 } 32 30 }); 33 - 34 - function handleClose() { 35 - if (closable) { 36 - open = false; 37 - } else if (open) { 38 - dialog.showModal(); 39 - } 40 - } 41 31 </script> 42 32 43 33 <!-- svelte-ignore a11y_click_events_have_key_events --> ··· 45 35 <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 46 36 <dialog 47 37 bind:this={dialog} 48 - onclose={handleClose} 38 + onclose={() => { 39 + if (closable) open = false; 40 + else if (open) dialog.showModal(); 41 + }} 49 42 oncancel={(e) => !closable && e.preventDefault()} 50 43 onclick={(e) => closable && modal && e.target === dialog && (open = false)} 51 44 >
+29 -32
src/lib/NowPlaying.svelte
··· 2 2 import { player, settings, t } from "./app.svelte.js"; 3 3 import { api } from "./client.svelte.js"; 4 4 5 - let lyrics = $state({ 6 - full: "", 7 - sync: [] as { time: number; text: string }[], 8 - }), 9 - show = $state(false); 10 - 11 - let currentLine = $derived.by(() => { 12 - let res = ""; 13 - for (const line of lyrics.sync) { 14 - if (line.time <= player.time) res = line.text; 15 - else break; 16 - } 17 - return res; 18 - }); 19 - 20 5 const parse = (text: string) => 21 6 text 22 7 .split("\n") ··· 34 19 ) 35 20 .sort((a, b) => a.time - b.time); 36 21 22 + let lyrics = $state({ 23 + full: "", 24 + sync: [] as { time: number; text: string }[], 25 + }), 26 + show = $state(false); 27 + 28 + let currentLine = $derived( 29 + lyrics.sync.findLast((line) => line.time <= player.time)?.text || "", 30 + ); 31 + 37 32 async function load() { 38 33 if (!player.track) return ((lyrics.full = ""), (lyrics.sync = [])); 39 34 lyrics.full = ""; 40 35 lyrics.sync = []; 41 36 try { 42 - const response = await api.lyricsById(player.track.id); 43 - const structuredLyrics = response.lyricsList?.structuredLyrics?.[0]; 37 + let response = await api.lyricsById(player.track.id); 38 + let structuredLyrics = response.lyricsList?.structuredLyrics?.[0]; 44 39 if (structuredLyrics?.synced) { 45 40 lyrics.sync = structuredLyrics.line 46 - .map((l: any) => ({ time: +l.start / 1000, text: l.value.trim() })) 47 - .filter((l: any) => l.text); 41 + .map((line: any) => ({ 42 + time: +line.start / 1000, 43 + text: line.value.trim(), 44 + })) 45 + .filter((line: any) => line.text); 48 46 } 49 - const raw = 47 + const getRaw = (response: any) => 50 48 response.lyrics?.value || 51 49 (typeof response.lyrics === "string" ? response.lyrics : ""); 50 + 51 + let rawLyrics = getRaw(response); 52 52 lyrics.full = 53 - raw || lyrics.sync.map((l) => l.text).join("\n") || t("no_lyrics"); 54 - if (!lyrics.sync.length && raw) lyrics.sync = parse(raw); 53 + rawLyrics || 54 + lyrics.sync.map((line) => line.text).join("\n") || 55 + t("no_lyrics"); 56 + if (!lyrics.sync.length && rawLyrics) lyrics.sync = parse(rawLyrics); 55 57 56 58 if (!lyrics.full || lyrics.full === t("no_lyrics")) { 57 - const response2 = await api.lyrics( 58 - player.track.artist, 59 - player.track.title, 60 - ); 61 - const raw2 = 62 - response2.lyrics?.value || 63 - (typeof response2.lyrics === "string" ? response2.lyrics : ""); 64 - if (raw2) { 65 - lyrics.full = raw2; 66 - lyrics.sync = parse(raw2); 59 + response = await api.lyrics(player.track.artist, player.track.title); 60 + rawLyrics = getRaw(response); 61 + if (rawLyrics) { 62 + lyrics.full = rawLyrics; 63 + lyrics.sync = parse(rawLyrics); 67 64 } 68 65 } 69 66 } catch {
+25 -27
src/lib/Playback.svelte
··· 18 18 </script> 19 19 20 20 <div id="playback" style="grid-area: playback"> 21 - <div class="ctrl"> 21 + <div class="controls"> 22 22 <button tabindex="-1" onclick={prev} title={t("prev")} 23 23 ><img src={icPrev} alt="" /></button 24 24 > ··· 42 42 </button> 43 43 </div> 44 44 45 - <div class="bar"> 45 + <div class="progress"> 46 46 <input 47 47 tabindex="-1" 48 48 type="range" 49 49 min="0" 50 - max={player.dur || 0} 50 + max={player.duration || 0} 51 51 step="0.1" 52 52 value={player.time} 53 - oninput={(e) => seek(+e.currentTarget.value)} 53 + oninput={(event) => seek(+event.currentTarget.value)} 54 54 /> 55 - <span>{formatTime(player.time)}/{formatTime(player.dur)}</span> 55 + <span>{formatTime(player.time)}/{formatTime(player.duration)}</span> 56 56 </div> 57 57 58 - <div class="vol"> 58 + <div class="volume"> 59 59 <img src={player.vol === 0 ? icMute : icVol} alt="" /> 60 60 <input 61 61 tabindex="-1" ··· 64 64 max="1" 65 65 step="0.01" 66 66 value={player.vol} 67 - oninput={(e) => (player.vol = +e.currentTarget.value)} 67 + oninput={(event) => (player.vol = +event.currentTarget.value)} 68 68 /> 69 69 </div> 70 70 </div> ··· 83 83 #playback { 84 84 grid-template-columns: auto auto; 85 85 grid-template-areas: 86 - "ctrl vol" 87 - "bar bar"; 86 + "controls volume" 87 + "progress progress"; 88 88 } 89 - .ctrl { 90 - grid-area: ctrl; 89 + .controls { 90 + grid-area: controls; 91 91 } 92 - .vol { 93 - grid-area: vol; 92 + .volume { 93 + grid-area: volume; 94 94 justify-content: flex-end; 95 95 } 96 - .bar { 97 - grid-area: bar; 96 + .progress { 97 + grid-area: progress; 98 98 padding-inline: 0.5rem; 99 99 } 100 100 } 101 - .ctrl { 101 + .controls, 102 + .progress, 103 + .volume { 102 104 display: flex; 103 105 align-items: center; 106 + } 107 + .controls, 108 + .volume { 104 109 padding-inline: 0.25rem; 105 110 } 106 - .bar { 107 - display: flex; 108 - align-items: center; 111 + .progress, 112 + .volume { 109 113 gap: 0.5rem; 110 114 } 111 - .bar input { 115 + .progress input { 112 116 flex: 1; 113 117 } 114 - .vol { 115 - display: flex; 116 - align-items: center; 117 - padding-inline: 0.25rem; 118 - gap: 0.5rem; 119 - } 120 - .vol input { 118 + .volume input { 121 119 inline-size: 6rem; 122 120 } 123 121 button {
+68 -68
src/lib/Queue.svelte
··· 32 32 33 33 let menu = $state<{ x: number; y: number; index: number } | null>(null), 34 34 drag = $state<{ index: number; isAfter: boolean } | null>(null), 35 - scrollToIndex = $state<(i: number) => void>(); 35 + scrollToIndex = $state<(index: number) => void>(); 36 36 37 37 $effect(() => { 38 38 if (nav.head >= 0) scrollToIndex?.(nav.head); 39 39 }); 40 40 41 - function onDragStart(e: DragEvent, index: number) { 41 + function onDragStart(event: DragEvent, index: number) { 42 42 if (!queue.sel.includes(index)) select(index); 43 - e.dataTransfer!.effectAllowed = "move"; 43 + event.dataTransfer!.effectAllowed = "move"; 44 44 } 45 45 46 - function onDragOver(e: DragEvent, index: number) { 47 - e.preventDefault(); 48 - const isLibrary = e.dataTransfer!.types.includes( 46 + function onDragOver(event: DragEvent, index: number) { 47 + event.preventDefault(); 48 + const isLibrary = event.dataTransfer!.types.includes( 49 49 "application/tinysub-library", 50 50 ); 51 - e.dataTransfer!.dropEffect = isLibrary ? "copy" : "move"; 51 + event.dataTransfer!.dropEffect = isLibrary ? "copy" : "move"; 52 52 53 - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); 54 - drag = { index, isAfter: e.clientY > rect.top + rect.height / 2 }; 53 + const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect(); 54 + drag = { index, isAfter: event.clientY > bounds.top + bounds.height / 2 }; 55 55 } 56 56 57 - async function onDrop(e: DragEvent) { 57 + async function onDrop(event: DragEvent) { 58 58 if (!drag) return; 59 59 const targetIndex = drag.index + (drag.isAfter ? 1 : 0); 60 60 61 - if (e.dataTransfer!.types.includes("application/tinysub-library")) { 61 + if (event.dataTransfer!.types.includes("application/tinysub-library")) { 62 62 const resolver = (window as any)._tinysub_drag; 63 63 if (resolver) { 64 64 const songs = await resolver(); ··· 70 70 drag = null; 71 71 } 72 72 73 - function onKey(e: KeyboardEvent) { 74 - if ((e.target as HTMLElement).tagName === "INPUT") return; 75 - const key = e.key, 76 - shift = e.shiftKey, 77 - alt = e.altKey; 78 - if (e.ctrlKey || e.metaKey) { 73 + function onKey(event: KeyboardEvent) { 74 + if ((event.target as HTMLElement).tagName === "INPUT") return; 75 + const key = event.key, 76 + isShiftPressed = event.shiftKey, 77 + isAltPressed = event.altKey; 78 + if (event.ctrlKey || event.metaKey) { 79 79 if (key === "a") { 80 - e.preventDefault(); 80 + event.preventDefault(); 81 81 selectAll(); 82 82 } else if (key === "c") { 83 - e.preventDefault(); 83 + event.preventDefault(); 84 84 copy(); 85 85 } else if (key === "x") { 86 - e.preventDefault(); 86 + event.preventDefault(); 87 87 cut(); 88 88 } else if (key === "v") { 89 - e.preventDefault(); 89 + event.preventDefault(); 90 90 paste(); 91 91 } 92 92 return; 93 93 } 94 94 95 95 const moves: Record<string, () => void> = { 96 - ArrowUp: () => (alt ? reorder(-1) : moveHead(-1, shift)), 97 - ArrowDown: () => (alt ? reorder(1) : moveHead(1, shift)), 98 - Home: () => select(0, false, shift), 99 - End: () => select(queue.tracks.length - 1, false, shift), 100 - PageUp: () => moveHead(-10, shift), 101 - PageDown: () => moveHead(10, shift), 96 + ArrowUp: () => 97 + isAltPressed ? reorder(-1) : moveHead(-1, isShiftPressed), 98 + ArrowDown: () => 99 + isAltPressed ? reorder(1) : moveHead(1, isShiftPressed), 100 + Home: () => select(0, false, isShiftPressed), 101 + End: () => select(queue.tracks.length - 1, false, isShiftPressed), 102 + PageUp: () => moveHead(-10, isShiftPressed), 103 + PageDown: () => moveHead(10, isShiftPressed), 102 104 Enter: () => queue.sel.length && goto(queue.sel[0]), 103 105 Delete: remove, 104 106 Backspace: remove, 105 107 Escape: clearSel, 106 108 ContextMenu: () => showMenu(nav.head), 107 109 "`": () => showMenu(nav.head), 108 - F10: () => e.shiftKey && showMenu(nav.head), 110 + F10: () => event.shiftKey && showMenu(nav.head), 109 111 }; 110 112 111 113 if (moves[key]) { 112 - e.preventDefault(); 114 + event.preventDefault(); 113 115 moves[key](); 114 116 } 115 117 } ··· 153 155 <div class="col">{t("title")}</div> 154 156 {#if settings.enableArtist}<div class="col">{t("artist")}</div>{/if} 155 157 {#if settings.enableAlbum}<div class="col">{t("album")}</div>{/if} 156 - {#if settings.enableQuality}<div class="col-num">{t("quality")}</div>{/if} 157 - {#if settings.enableDuration}<div class="col-num">{t("time")}</div>{/if} 158 - {#if settings.enableDisc}<div class="col-num">{t("disc")}</div>{/if} 159 - {#if settings.enableTrackNum}<div class="col-num">{t("track")}</div>{/if} 160 - {#if settings.enableQueueNum}<div class="col-num">{t("queue")}</div>{/if} 161 - {#if settings.enableFavorites}<div class="col-fav">♥</div>{/if} 162 - {#if settings.enableRatings}<div class="col-rate">{t("rating")}</div>{/if} 158 + {#if settings.enableQuality}<div class="col-number"> 159 + {t("quality")} 160 + </div>{/if} 161 + {#if settings.enableDuration}<div class="col-number">{t("time")}</div>{/if} 162 + {#if settings.enableDisc}<div class="col-number">{t("disc")}</div>{/if} 163 + {#if settings.enableTrackNum}<div class="col-number">{t("track")}</div>{/if} 164 + {#if settings.enableQueueNum}<div class="col-number">{t("queue")}</div>{/if} 165 + {#if settings.enableFavorites}<div class="col-favorite">♥</div>{/if} 166 + {#if settings.enableRatings}<div class="col-rating">{t("rating")}</div>{/if} 163 167 <div class="col-touch"> 164 168 <button 165 169 tabindex="-1" ··· 173 177 </div> 174 178 175 179 <div class="body"> 180 + <!-- +2 for 1px padding top and bottom around icon --> 176 181 <VirtualList 177 182 items={queue.tracks} 178 183 itemHeight={Math.max(19, settings.artSong + 2)} 179 - // +2 for 1px padding top and bottom around icon 180 184 bind:scrollToIndex 181 185 > 182 186 {#snippet children(track, index)} ··· 187 191 class:playing={queue.pos === index} 188 192 class:selected={queue.sel.includes(index)} 189 193 class:odd={index % 2 !== 0} 190 - class:over-a={drag?.index === index && !drag.isAfter} 191 - class:over-b={drag?.index === index && drag.isAfter} 194 + class:over-above={drag?.index === index && !drag.isAfter} 195 + class:over-below={drag?.index === index && drag.isAfter} 192 196 style="block-size: {Math.max(19, settings.artSong + 2)}px;" 193 - // +2 for 1px padding top and bottom around icon 194 197 draggable={true} 195 198 ondragstart={(e) => onDragStart(e, index)} 196 199 ondragover={(e) => onDragOver(e, index)} ··· 216 219 > 217 220 {#if settings.artSong > 0} 218 221 {@const artId = track.albumId || track.coverArt || track.id} 219 - <div class="col-art" style="inline-size: {settings.artSong}px;"> 222 + <div class="col-artwork" style="inline-size: {settings.artSong}px;"> 220 223 <img 221 - class="art" 224 + class="artwork" 222 225 src={api.art(artId, settings.artSong)} 223 226 srcset="{api.art(artId, settings.artSong)} 1x, {api.art( 224 227 artId, ··· 234 237 {#if settings.enableArtist}<div class="col">{track.artist}</div>{/if} 235 238 {#if settings.enableAlbum}<div class="col">{track.album}</div>{/if} 236 239 {#if settings.enableQuality} 237 - <div class="col-num"> 240 + <div class="col-number"> 238 241 {track.suffix || ""}{#if track.suffix && track.bitRate} 239 242 {" "}{/if}{track.bitRate || ""} 240 243 </div> 241 244 {/if} 242 245 {#if settings.enableDuration} 243 - <div class="col-num">{formatTime(track.duration)}</div> 246 + <div class="col-number">{formatTime(track.duration)}</div> 244 247 {/if} 245 - {#if settings.enableDisc}<div class="col-num"> 248 + {#if settings.enableDisc}<div class="col-number"> 246 249 {track.discNumber || ""} 247 250 </div>{/if} 248 - {#if settings.enableTrackNum}<div class="col-num"> 251 + {#if settings.enableTrackNum}<div class="col-number"> 249 252 {track.track || ""} 250 253 </div>{/if} 251 - {#if settings.enableQueueNum}<div class="col-num"> 254 + {#if settings.enableQueueNum}<div class="col-number"> 252 255 {index + 1} 253 256 </div>{/if} 254 257 {#if settings.enableFavorites} 255 - <div class="col-fav"> 258 + <div class="col-favorite"> 256 259 <button 257 260 tabindex="-1" 258 - class="btn-fav" 261 + class="btn-favorite" 259 262 class:active={track.starred} 260 263 onclick={(e) => { 261 264 e.stopPropagation(); ··· 267 270 </div> 268 271 {/if} 269 272 {#if settings.enableRatings} 270 - <div class="col-rate"> 273 + <div class="col-rating"> 271 274 {#each [1, 2, 3, 4, 5] as rating} 272 275 <button 273 276 tabindex="-1" 274 - class="btn-rate" 277 + class="btn-rating" 275 278 class:active={track.userRating >= rating} 276 279 onclick={(e) => { 277 280 e.stopPropagation(); ··· 382 385 overflow: hidden; 383 386 text-overflow: ellipsis; 384 387 } 385 - .col-art { 388 + .col-artwork { 386 389 display: flex; 387 390 justify-content: center; 388 391 } 389 - .col-num { 392 + .col-number, 393 + .col-rating { 390 394 inline-size: 6rem; 391 395 text-align: end; 392 396 } 393 - .col-fav { 397 + .col-favorite { 394 398 inline-size: 2rem; 395 - text-align: end; 396 - } 397 - .col-rate { 398 - inline-size: 6rem; 399 399 text-align: end; 400 400 } 401 401 .col-touch { ··· 421 421 background: GrayText; 422 422 color: HighlightText; 423 423 } 424 - .row.over-a { 424 + .row.over-above { 425 425 box-shadow: inset 0 2px 0 var(--text); 426 426 } 427 - .row.over-b { 427 + .row.over-below { 428 428 box-shadow: inset 0 -2px 0 var(--text); 429 429 } 430 - .btn-fav, 431 - .btn-rate, 430 + .btn-favorite, 431 + .btn-rating, 432 432 .btn-touch { 433 433 background: none; 434 434 border: none; ··· 439 439 width: 100%; 440 440 text-align: end; 441 441 } 442 - .btn-fav:hover, 443 - .btn-rate:hover, 442 + .btn-favorite:hover, 443 + .btn-rating:hover, 444 444 .btn-touch:hover { 445 445 opacity: 0.75; 446 446 } 447 - .btn-rate { 447 + .btn-rating { 448 448 transform: scale(0.75); 449 449 } 450 - .btn-fav.active { 450 + .btn-favorite.active { 451 451 color: #ff0008; 452 452 opacity: 1; 453 453 } 454 - .btn-rate.active { 454 + .btn-rating.active { 455 455 color: #ffd700; 456 456 opacity: 1; 457 457 transform: scale(1); 458 458 } 459 - :global(body.rounded-art) #queue .art, 459 + :global(body.rounded-art) #queue .artwork, 460 460 :global(body.rounded-art) #queue .row { 461 461 border-radius: 2px; 462 462 }
+1 -1
src/lib/SettingsModal.svelte
··· 1 1 <script lang="ts"> 2 2 import Modal from "./Modal.svelte"; 3 3 import { logout, settings, t } from "./app.svelte.js"; 4 - let { open = $bindable(false) } = $props(); 4 + let { open = $bindable(false) }: { open?: boolean } = $props(); 5 5 </script> 6 6 7 7 <Modal bind:open title={t("settings")}>
+2 -1
src/lib/ShareModal.svelte
··· 62 62 </script> 63 63 64 64 <Modal 65 - bind:open={() => !!ui.shareItemId, (v) => !v && (ui.shareItemId = null)} 65 + open={!!ui.shareItemId} 66 + onclose={() => (ui.shareItemId = null)} 66 67 title={t("share")} 67 68 > 68 69 {#if shareUrl}
+1 -1
src/lib/ShortcutsModal.svelte
··· 1 1 <script lang="ts"> 2 2 import Modal from "./Modal.svelte"; 3 3 import { t } from "./app.svelte.js"; 4 - let { open = $bindable(false) } = $props(); 4 + let { open = $bindable(false) }: { open?: boolean } = $props(); 5 5 6 6 const sections = [ 7 7 {
+11 -21
src/lib/Sidebar.svelte
··· 4 4 import { settings } from "./app.svelte.js"; 5 5 6 6 let dragging = $state(false); 7 - 8 - function onMouseDown(e: MouseEvent) { 9 - dragging = true; 10 - window.addEventListener("mousemove", onMouseMove); 11 - window.addEventListener("mouseup", onMouseUp); 12 - } 13 - 14 - function onMouseMove(e: MouseEvent) { 15 - if (!dragging) return; 16 - const base = parseFloat( 17 - getComputedStyle(document.documentElement).fontSize, 18 - ); 19 - settings.sidebarWidth = Math.max(16, Math.min(48, e.clientX / base)); 20 - } 21 - 22 - function onMouseUp() { 23 - dragging = false; 24 - window.removeEventListener("mousemove", onMouseMove); 25 - window.removeEventListener("mouseup", onMouseUp); 26 - } 7 + let base = 16; 27 8 28 9 $effect(() => { 29 10 if (dragging) { 11 + base = parseFloat(getComputedStyle(document.documentElement).fontSize); 30 12 document.body.style.cursor = "col-resize"; 31 13 return () => (document.body.style.cursor = ""); 32 14 } 33 15 }); 34 16 </script> 35 17 18 + <svelte:window 19 + onmousemove={(e) => { 20 + if (!dragging) return; 21 + settings.sidebarWidth = Math.max(16, Math.min(48, e.clientX / base)); 22 + }} 23 + onmouseup={() => (dragging = false)} 24 + /> 25 + 36 26 <div id="sidebar"> 37 27 <Library /> 38 28 <NowPlaying /> 39 29 <!-- svelte-ignore a11y_no_static_element_interactions --> 40 - <div class="resizer" onmousedown={onMouseDown}></div> 30 + <div class="resizer" onmousedown={() => (dragging = true)}></div> 41 31 </div> 42 32 43 33 <style>
+8 -11
src/lib/TreeItem.svelte
··· 168 168 const onAdd = (e: any) => doAdd(e.detail); 169 169 node.addEventListener("actionadd", onAdd); 170 170 return { 171 - destroy() { 172 - node.removeEventListener("actionadd", onAdd); 173 - }, 171 + destroy: () => node.removeEventListener("actionadd", onAdd), 174 172 }; 175 173 } 176 174 ··· 189 187 } 190 188 191 189 const artSize = $derived( 192 - type === "artist" 193 - ? settings.artArtist 194 - : type === "album" 195 - ? settings.artAlbum 196 - : type === "playlist" 197 - ? settings.artPlaylist 198 - : 0, 190 + ( 191 + { 192 + artist: settings.artArtist, 193 + album: settings.artAlbum, 194 + playlist: settings.artPlaylist, 195 + } as any 196 + )[type] ?? 0, 199 197 ); 200 198 201 199 const isOwner = $derived(!owner || (auth.user && owner === auth.user)); ··· 428 426 .main { 429 427 flex: 1; 430 428 overflow: hidden; 431 - padding: 0rem; 432 429 gap: 0.5rem; 433 430 } 434 431 @media (pointer: coarse) {
+2 -2
src/lib/VirtualList.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from "svelte"; 2 + import { onMount, type Snippet } from "svelte"; 3 3 4 4 let { 5 5 items, ··· 9 9 } = $props<{ 10 10 items: any[]; 11 11 itemHeight: number; 12 - children: (item: any, index: number) => any; 12 + children: Snippet<[any, number]>; 13 13 scrollToIndex?: (index: number) => void; 14 14 }>(); 15 15
+2 -2
src/lib/app.svelte.ts
··· 15 15 import { initTheme } from "./theme.svelte.js"; 16 16 17 17 export const onGlobalKey = (e: KeyboardEvent) => { 18 - if (document.activeElement?.matches("input, textarea") || ui.modalsOpen > 0) 18 + if (document.activeElement?.matches("input, textarea") || ui.hasModalOpen) 19 19 return; 20 20 21 21 const { code, key, shiftKey: shift, altKey: alt } = e; ··· 35 35 alt ? prev() : seek(Math.max(0, player.time - 10)); 36 36 } else if (code === "KeyL") { 37 37 e.preventDefault(); 38 - alt ? next() : seek(Math.min(player.dur, player.time + 10)); 38 + alt ? next() : seek(Math.min(player.duration, player.time + 10)); 39 39 } else if (key === "+" || key === "=") { 40 40 e.preventDefault(); 41 41 player.vol = Math.min(1, player.vol + 0.05);
+2 -2
src/lib/auth.svelte.ts
··· 55 55 localStorage.clear(); 56 56 indexedDB.deleteDatabase("tinysub"); 57 57 if (indexedDB.databases) { 58 - const dbs = await indexedDB.databases(); 59 - for (const db of dbs) indexedDB.deleteDatabase(db.name!); 58 + const databases = await indexedDB.databases(); 59 + for (const database of databases) indexedDB.deleteDatabase(database.name!); 60 60 } 61 61 location.reload(); 62 62 };
+37 -34
src/lib/client.svelte.ts
··· 61 61 } 62 62 63 63 export let credentials: Credentials | null = null; 64 + const base = () => credentials!.server.replace(/\/$/, ""); 64 65 65 - export const setCredentials = (c: Credentials | null) => { 66 - credentials = c; 67 - c 68 - ? localStorage.setItem("tinysub_credentials", JSON.stringify(c)) 66 + export const setCredentials = (creds: Credentials | null) => { 67 + credentials = creds; 68 + creds 69 + ? localStorage.setItem("tinysub_credentials", JSON.stringify(creds)) 69 70 : localStorage.removeItem("tinysub_credentials"); 70 71 }; 71 72 72 - export const asArray = (v: any) => (Array.isArray(v) ? v : v ? [v] : []); 73 + export const asArray = (value: any) => 74 + Array.isArray(value) ? value : value ? [value] : []; 73 75 74 76 const getParams = (params: any = {}) => { 75 77 if (!credentials) throw "Auth required"; 76 - const p = new URLSearchParams({ 78 + const searchParams = new URLSearchParams({ 77 79 u: credentials.username, 78 80 t: credentials.token, 79 81 s: credentials.salt, ··· 81 83 c: "tinysub", 82 84 f: "json", 83 85 }); 84 - Object.entries(params).forEach(([k, v]) => { 85 - if (Array.isArray(v)) v.forEach((val) => p.append(k, val)); 86 - else if (v !== undefined) p.append(k, String(v)); 86 + Object.entries(params).forEach(([key, value]) => { 87 + if (Array.isArray(value)) 88 + value.forEach((val) => searchParams.append(key, val)); 89 + else if (value !== undefined) searchParams.append(key, String(value)); 87 90 }); 88 - return p; 91 + return searchParams; 89 92 }; 90 93 91 - export const buildUrl = (method: string, params: any = {}) => 92 - `${credentials!.server.replace(/\/$/, "")}/rest/${method}?${getParams(params)}`; 93 - 94 94 const request = async (method: string, params: any = {}, isPost = false) => { 95 - const url = buildUrl(method, isPost ? {} : params); 96 - const options: RequestInit = isPost 97 - ? { 98 - method: "POST", 99 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 100 - body: getParams(params).toString(), 101 - } 102 - : {}; 103 - const res = await fetch(isPost ? url.split("?")[0] : url, options); 104 - const response = (await res.json())["subsonic-response"]; 105 - if (response.status === "failed") 106 - throw response.error?.message || "API error"; 107 - return response; 95 + const response = await fetch( 96 + isPost 97 + ? `${base()}/rest/${method}` 98 + : `${base()}/rest/${method}?${getParams(params)}`, 99 + isPost 100 + ? { 101 + method: "POST", 102 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 103 + body: getParams(params).toString(), 104 + } 105 + : {}, 106 + ); 107 + const data = (await response.json())["subsonic-response"]; 108 + if (data.status === "failed") throw data.error?.message || "API error"; 109 + return data; 108 110 }; 109 111 110 112 export const songCache = new Map<string, Song>(); 111 - export const internSong = (s: any): Song => { 112 - if (!s || !s.id) return s; 113 - const cached = songCache.get(s.id); 113 + export const internSong = (song: any): Song => { 114 + if (!song || !song.id) return song; 115 + const cached = songCache.get(song.id); 114 116 if (cached) { 115 - Object.assign(cached, s); 117 + Object.assign(cached, song); 116 118 return cached; 117 119 } 118 - const fresh = $state({ ...s }); 119 - songCache.set(s.id, fresh); 120 + const fresh = $state({ ...song }); 121 + songCache.set(song.id, fresh); 120 122 return fresh; 121 123 }; 122 124 ··· 150 152 if (results.song) results.song = asArray(results.song).map(internSong); 151 153 return results; 152 154 }), 153 - stream: (id: string) => buildUrl("stream", { id }), 154 - art: (id: string, size = 128) => buildUrl("getCoverArt", { id, size }), 155 + stream: (id: string) => `${base()}/rest/stream?${getParams({ id })}`, 156 + art: (id: string, size = 128) => 157 + `${base()}/rest/getCoverArt?${getParams({ id, size })}`, 155 158 star: (params: { id?: string; albumId?: string; artistId?: string }) => 156 159 request("star", params), 157 160 unstar: (params: { id?: string; albumId?: string; artistId?: string }) =>
+44 -63
src/lib/player.svelte.ts
··· 8 8 paused: true, 9 9 audio: new Audio(), 10 10 time: 0, 11 - dur: 0, 11 + duration: 0, 12 12 vol: 1, 13 13 loop: false, 14 14 }); 15 15 16 - export const formatTime = (seconds = 0) => { 17 - const min = Math.floor(seconds / 60), 18 - sec = Math.floor(seconds % 60); 19 - return `${min}:${sec.toString().padStart(2, "0")}`; 20 - }; 16 + export const formatTime = (seconds = 0) => 17 + `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60) 18 + .toString() 19 + .padStart(2, "0")}`; 21 20 22 21 player.audio.onended = () => { 23 - if (settings.scrobbling && player.track) { 22 + if (settings.scrobbling && player.track) 24 23 api.scrobble(player.track.id).catch(() => {}); 25 - } 26 24 next(); 27 25 }; 28 26 ··· 30 28 31 29 let pendingTime = 0; 32 30 player.audio.onloadedmetadata = () => { 33 - player.dur = player.audio.duration; 34 - if (pendingTime > 0) { 35 - player.audio.currentTime = pendingTime; 36 - player.time = pendingTime; 37 - pendingTime = 0; 38 - } 39 - if (navigator.mediaSession?.setPositionState && player.dur > 0) { 31 + player.duration = player.audio.duration; 32 + if (pendingTime > 0) player.audio.currentTime = player.time = pendingTime; 33 + pendingTime = 0; 34 + updateMediaPosition(); 35 + }; 36 + 37 + const updateMediaPosition = () => { 38 + if (navigator.mediaSession?.setPositionState && player.duration > 0) { 40 39 navigator.mediaSession.setPositionState({ 41 - duration: player.dur, 40 + duration: player.duration, 42 41 playbackRate: player.audio.playbackRate, 43 42 position: player.audio.currentTime, 44 43 }); 45 44 } 46 45 }; 47 46 48 - const load = (song: Song, time = 0, autoplay = false) => { 47 + export const load = (song: Song, time = 0, autoplay = false) => { 49 48 player.track = song; 50 - player.dur = song.duration || 0; 49 + player.duration = song.duration || 0; 51 50 player.time = pendingTime = time; 52 51 player.audio.src = api.stream(song.id); 53 - if (autoplay) player.audio.play(); 54 - else player.audio.load(); 52 + autoplay ? player.audio.play() : player.audio.load(); 55 53 player.paused = !autoplay; 56 54 }; 57 - 58 - export const restoreTrack = (song: Song, time: number) => load(song, time); 59 55 60 56 export const play = (song: Song) => { 61 57 if (player.track?.id === song.id) { ··· 81 77 82 78 export const seek = (time: number) => { 83 79 player.audio.currentTime = time; 84 - if ( 85 - navigator.mediaSession?.setPositionState && 86 - player.dur > 0 && 87 - time <= player.dur 88 - ) { 89 - navigator.mediaSession.setPositionState({ 90 - duration: player.dur, 91 - playbackRate: player.audio.playbackRate, 92 - position: time, 93 - }); 94 - } 80 + updateMediaPosition(); 95 81 }; 96 82 97 83 export const setupMediaSession = () => { 98 84 if (!navigator.mediaSession) return; 99 - 100 85 navigator.mediaSession.setActionHandler("play", toggle); 101 86 navigator.mediaSession.setActionHandler("pause", toggle); 102 87 navigator.mediaSession.setActionHandler("previoustrack", prev); 103 88 navigator.mediaSession.setActionHandler("nexttrack", next); 104 - navigator.mediaSession.setActionHandler("seekto", (e) => { 105 - if (e.seekTime !== undefined) seek(e.seekTime); 106 - }); 89 + navigator.mediaSession.setActionHandler( 90 + "seekto", 91 + (e) => e.seekTime !== undefined && seek(e.seekTime), 92 + ); 107 93 }; 108 94 109 95 $effect.root(() => { 110 96 let lastNowPlayingId = ""; 97 + 111 98 $effect(() => { 112 - const track = player.track; 113 - const mode = settings.replayGainMode; 114 - const baseVol = player.vol; 115 - 116 - let multiplier = 1; 117 - if (track?.replayGain && mode !== "off") { 118 - const gain = 119 - mode === "album" 120 - ? track.replayGain.albumGain 121 - : track.replayGain.trackGain; 122 - if (gain !== undefined) { 123 - multiplier = Math.pow(10, gain / 20); 124 - } 125 - } 126 - 127 - player.audio.volume = Math.min(1, Math.max(0, baseVol * multiplier)); 99 + const gain = 100 + settings.replayGainMode === "album" 101 + ? player.track?.replayGain?.albumGain 102 + : player.track?.replayGain?.trackGain; 103 + player.audio.volume = Math.min( 104 + 1, 105 + Math.max( 106 + 0, 107 + player.vol * (gain === undefined ? 1 : Math.pow(10, gain / 20)), 108 + ), 109 + ); 128 110 }); 129 111 130 112 $effect(() => { 131 - const track = player.track; 113 + const { track } = player; 132 114 const link = document.querySelector('link[rel="icon"]') as HTMLLinkElement; 115 + 133 116 if (track) { 134 - const artId = track.albumId || track.coverArt || track.id; 117 + const id = track.albumId || track.coverArt || track.id; 135 118 if ( 136 119 settings.scrobbling && 137 120 !player.paused && ··· 140 123 lastNowPlayingId = track.id; 141 124 api.scrobble(track.id, false).catch(() => {}); 142 125 } 126 + 143 127 document.title = `${track.artist} - ${track.title} - tinysub`; 128 + 144 129 if (link) { 145 - if (settings.dynamicFavicon) { 146 - link.href = api.art(artId, 512); 147 - link.type = "image/jpeg"; 148 - } else { 149 - link.href = icLogo; 150 - link.type = "image/svg+xml"; 151 - } 130 + link.href = settings.dynamicFavicon ? api.art(id, 512) : icLogo; 131 + link.type = settings.dynamicFavicon ? "image/jpeg" : "image/svg+xml"; 152 132 } 133 + 153 134 if (navigator.mediaSession) { 154 135 navigator.mediaSession.metadata = new MediaMetadata({ 155 136 title: track.title, 156 137 artist: track.artist, 157 138 album: track.album, 158 139 artwork: [ 159 - { src: api.art(artId, 512), sizes: "512x512", type: "image/jpeg" }, 140 + { src: api.art(id, 512), sizes: "512x512", type: "image/jpeg" }, 160 141 ], 161 142 }); 162 143 }
+73 -64
src/lib/queue.svelte.ts
··· 1 1 import { api, type Song, asArray, internSong } from "./client.svelte.js"; 2 - import { player, play, stop, restoreTrack } from "./player.svelte.js"; 2 + import { player, play, stop, load } from "./player.svelte.js"; 3 3 import { nav, ui } from "./ui.svelte.js"; 4 4 import { auth } from "./auth.svelte.js"; 5 5 import { t } from "./lang.svelte.js"; ··· 13 13 }); 14 14 15 15 const history: { tracks: Song[]; pos: number }[] = []; 16 - let hIndex = -1; 16 + let historyIndex = -1; 17 17 let _undoing = false; 18 18 19 19 export const copy = () => { 20 - if (queue.sel.length) queue.clipboard = queue.sel.map((i) => queue.tracks[i]); 20 + if (queue.sel.length) 21 + queue.clipboard = queue.sel.map((index) => queue.tracks[index]); 21 22 }; 22 23 23 24 export const cut = () => { ··· 52 53 53 54 export const record = () => { 54 55 const { tracks, pos } = queue; 55 - if (hIndex >= 0) { 56 - const h = history[hIndex]; 56 + if (historyIndex >= 0) { 57 + const entry = history[historyIndex]; 57 58 if ( 58 - h.tracks.length === tracks.length && 59 - h.tracks.every((t, i) => t === tracks[i]) 59 + entry.tracks.length === tracks.length && 60 + entry.tracks.every((track, i) => track === tracks[i]) 60 61 ) 61 62 return; 62 63 } 63 64 64 - history.splice(hIndex + 1); 65 + history.splice(historyIndex + 1); 65 66 history.push({ tracks: [...tracks], pos }); 66 67 if (history.length > 50) history.shift(); 67 - else hIndex++; 68 + else historyIndex++; 68 69 }; 69 70 70 71 export const undo = () => { 71 - if (hIndex > 0) { 72 + if (historyIndex > 0) { 72 73 _undoing = true; 73 - const state = history[--hIndex]; 74 + const state = history[--historyIndex]; 74 75 queue.tracks = [...state.tracks]; 75 - const idx = player.track ? queue.tracks.indexOf(player.track) : -1; 76 - queue.pos = idx !== -1 ? idx : state.pos; 76 + const index = player.track ? queue.tracks.indexOf(player.track) : -1; 77 + queue.pos = index !== -1 ? index : state.pos; 77 78 clearSel(); 78 79 queue.version++; 79 80 } 80 81 }; 81 82 82 83 export const redo = () => { 83 - if (hIndex < history.length - 1) { 84 + if (historyIndex < history.length - 1) { 84 85 _undoing = true; 85 - const state = history[++hIndex]; 86 + const state = history[++historyIndex]; 86 87 queue.tracks = [...state.tracks]; 87 - const idx = player.track ? queue.tracks.indexOf(player.track) : -1; 88 - queue.pos = idx !== -1 ? idx : state.pos; 88 + const index = player.track ? queue.tracks.indexOf(player.track) : -1; 89 + queue.pos = index !== -1 ? index : state.pos; 89 90 clearSel(); 90 91 queue.version++; 91 92 } ··· 96 97 if (range && nav.anchor !== -1) { 97 98 const start = Math.min(nav.anchor, index), 98 99 end = Math.max(nav.anchor, index); 99 - const ids = Array.from({ length: end - start + 1 }, (_, k) => start + k); 100 + const ids = Array.from( 101 + { length: end - start + 1 }, 102 + (_, offset) => start + offset, 103 + ); 100 104 queue.sel = multi ? [...new Set([...queue.sel, ...ids])] : ids; 101 105 } else if (multi) { 102 106 queue.sel = queue.sel.includes(index) ··· 119 123 120 124 export const reorder = (direction: -1 | 1) => { 121 125 if (!queue.sel.length) return; 122 - const ids = [...queue.sel].sort((a, b) => a - b); 126 + const indices = [...queue.sel].sort((a, b) => a - b); 123 127 if ( 124 - (direction === -1 && ids[0] === 0) || 125 - (direction === 1 && ids[ids.length - 1] === queue.tracks.length - 1) 128 + (direction === -1 && indices[0] === 0) || 129 + (direction === 1 && indices[indices.length - 1] === queue.tracks.length - 1) 126 130 ) 127 131 return; 128 - const move = direction === -1 ? ids : ids.reverse(); 132 + const move = direction === -1 ? indices : indices.reverse(); 129 133 for (const index of move) { 130 134 const to = index + direction; 131 135 [queue.tracks[index], queue.tracks[to]] = [ ··· 151 155 152 156 const playing = queue.sel.includes(queue.pos), 153 157 oldPos = queue.pos, 154 - removedBefore = queue.sel.filter((i) => i < oldPos).length; 158 + removedBefore = queue.sel.filter((index) => index < oldPos).length; 155 159 156 - queue.tracks = queue.tracks.filter((_, i) => !queue.sel.includes(i)); 160 + queue.tracks = queue.tracks.filter((_, index) => !queue.sel.includes(index)); 157 161 158 162 if (playing) { 159 163 if (queue.tracks.length) { 160 164 queue.pos = Math.min(oldPos - removedBefore, queue.tracks.length - 1); 161 165 const track = queue.tracks[queue.pos]; 162 166 if (!player.paused) play(track); 163 - else restoreTrack(track, 0); 167 + else load(track, 0); 164 168 } else stop(); 165 169 } else if (queue.pos !== -1) { 166 170 queue.pos = oldPos - removedBefore; ··· 209 213 if (!queue.sel.length) return; 210 214 211 215 const playing = queue.tracks[queue.pos]; 212 - const moved = queue.sel.sort((a, b) => a - b).map((i) => queue.tracks[i]); 213 - const remaining = queue.tracks.filter((_, i) => !queue.sel.includes(i)); 216 + const moved = queue.sel 217 + .sort((a, b) => a - b) 218 + .map((index) => queue.tracks[index]); 219 + const remaining = queue.tracks.filter( 220 + (_, index) => !queue.sel.includes(index), 221 + ); 214 222 215 - const removedBefore = queue.sel.filter((i) => i < targetIndex).length; 223 + const removedBefore = queue.sel.filter((index) => index < targetIndex).length; 216 224 const actualTarget = Math.max(0, targetIndex - removedBefore); 217 225 218 226 remaining.splice(actualTarget, 0, ...moved); ··· 236 244 ? queue.tracks.map((_, i) => i) 237 245 : [...queue.sel].sort((a, b) => a - b); 238 246 239 - const targets = indices.map((idx) => ({ 240 - t: queue.tracks[idx], 241 - p: idx === queue.pos, 247 + const targets = indices.map((index) => ({ 248 + track: queue.tracks[index], 249 + playing: index === queue.pos, 242 250 })); 243 251 244 - const cmp = (a: any, b: any) => 252 + const compare = (a: any, b: any) => 245 253 (a || "").toString().localeCompare((b || "").toString(), undefined, { 246 254 numeric: true, 247 255 }); 248 256 249 - const trackCmp = (a: Song, b: Song) => 257 + const trackCompare = (a: Song, b: Song) => 250 258 a.discNumber !== b.discNumber 251 259 ? (a.discNumber || 0) - (b.discNumber || 0) 252 260 : (a.track || 0) - (b.track || 0); 253 261 254 262 targets.sort((a, b) => { 255 - const s1 = a.t, 256 - s2 = b.t; 257 - let res = 0; 263 + const track1 = a.track, 264 + track2 = b.track; 265 + let result = 0; 258 266 if (field === "artist") { 259 - res = 260 - cmp(s1.artist, s2.artist) || 261 - cmp(s1.album, s2.album) || 262 - trackCmp(s1, s2); 267 + result = 268 + compare(track1.artist, track2.artist) || 269 + compare(track1.album, track2.album) || 270 + trackCompare(track1, track2); 263 271 } else if (field === "album") { 264 - res = cmp(s1.album, s2.album) || trackCmp(s1, s2); 272 + result = 273 + compare(track1.album, track2.album) || trackCompare(track1, track2); 265 274 } else { 266 - const val = (s: Song) => { 267 - if (field === "stars") return !!s.starred ? 1 : 0; 268 - if (field === "rating") return s.userRating || 0; 269 - if (field === "duration") return s.duration || 0; 270 - return (s as any)[field]; 275 + const getValue = (song: Song) => { 276 + if (field === "stars") return !!song.starred ? 1 : 0; 277 + if (field === "rating") return song.userRating || 0; 278 + if (field === "duration") return song.duration || 0; 279 + return (song as any)[field]; 271 280 }; 272 - const av = val(s1), 273 - bv = val(s2); 274 - res = typeof av === "number" ? av - bv : cmp(av, bv); 281 + const val1 = getValue(track1), 282 + val2 = getValue(track2); 283 + result = typeof val1 === "number" ? val1 - val2 : compare(val1, val2); 275 284 } 276 - return asc ? res : -res; 285 + return asc ? result : -result; 277 286 }); 278 287 279 - indices.forEach((idx, i) => { 280 - queue.tracks[idx] = targets[i].t; 281 - if (targets[i].p) queue.pos = idx; 288 + indices.forEach((index, i) => { 289 + queue.tracks[index] = targets[i].track; 290 + if (targets[i].playing) queue.pos = index; 282 291 }); 283 292 284 293 queue.version++; ··· 286 295 287 296 export const starSelected = (star: boolean) => 288 297 queue.sel.forEach( 289 - (i) => !!queue.tracks[i].starred !== star && toggleStar(queue.tracks[i]), 298 + (index) => 299 + !!queue.tracks[index].starred !== star && toggleStar(queue.tracks[index]), 290 300 ); 291 301 292 302 export const rateSelected = (rating: number) => 293 - queue.sel.forEach((i) => setRating(queue.tracks[i], rating)); 303 + queue.sel.forEach((index) => setRating(queue.tracks[index], rating)); 294 304 295 305 export const selectAll = () => { 296 306 queue.sel = queue.tracks.map((_, i) => i); ··· 312 322 ? queue.tracks.map((_, i) => i) 313 323 : [...queue.sel].sort((a, b) => a - b); 314 324 315 - const items = indices.map((idx) => ({ 316 - t: queue.tracks[idx], 317 - p: idx === queue.pos, 325 + const items = indices.map((index) => ({ 326 + track: queue.tracks[index], 327 + playing: index === queue.pos, 318 328 })); 319 329 320 330 for (let i = items.length - 1; i > 0; i--) { ··· 322 332 [items[i], items[j]] = [items[j], items[i]]; 323 333 } 324 334 325 - indices.forEach((idx, i) => { 326 - queue.tracks[idx] = items[i].t; 327 - if (items[i].p) queue.pos = idx; 335 + indices.forEach((index, i) => { 336 + queue.tracks[index] = items[i].track; 337 + if (items[i].playing) queue.pos = index; 328 338 }); 329 339 330 340 queue.version++; ··· 374 384 const idx = queue.tracks.findIndex((t) => t.id === saved.current); 375 385 if (idx !== -1) { 376 386 queue.pos = idx; 377 - if (saved.position) 378 - restoreTrack(queue.tracks[idx], saved.position / 1000); 387 + if (saved.position) load(queue.tracks[idx], saved.position / 1000); 379 388 else { 380 389 player.track = queue.tracks[idx]; 381 390 player.audio.src = api.stream(player.track.id); ··· 404 413 if (player.track) stop(); 405 414 } else if (track.id !== player.track?.id) { 406 415 if (!player.paused) play(track); 407 - else restoreTrack(track, 0); 416 + else load(track, 0); 408 417 } 409 418 410 419 if (ui.busy || !auth.ok) return;
+14 -10
src/lib/theme.svelte.ts
··· 4 4 import { getSwatches, getColor } from "colorthief"; 5 5 6 6 const getLuminance = (rgb: number[]) => { 7 - const [rs, gs, bs] = rgb.map((v) => { 8 - const val = v / 255; 9 - return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); 7 + const [red, green, blue] = rgb.map((value) => { 8 + const normalizedValue = value / 255; 9 + return normalizedValue <= 0.03928 10 + ? normalizedValue / 12.92 11 + : Math.pow((normalizedValue + 0.055) / 1.055, 2.4); 10 12 }); 11 - return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; 13 + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; 12 14 }; 13 15 14 - const getContrast = (c1: number[], c2: number[]) => { 15 - const l1 = getLuminance(c1) + 0.05; 16 - const l2 = getLuminance(c2) + 0.05; 17 - return l1 > l2 ? l1 / l2 : l2 / l1; 16 + const getContrast = (color1: number[], color2: number[]) => { 17 + const luminance1 = getLuminance(color1) + 0.05; 18 + const luminance2 = getLuminance(color2) + 0.05; 19 + return luminance1 > luminance2 20 + ? luminance1 / luminance2 21 + : luminance2 / luminance1; 18 22 }; 19 23 20 24 const cache = new Map<string, { bg: string; text: string; dark: boolean }>(); ··· 81 85 82 86 cache.set(id, theme); 83 87 apply(theme.bg, theme.text, theme.dark); 84 - } catch (e) { 85 - console.error("theme fail:", e); 88 + } catch (err) { 89 + console.error("theme fail:", err); 86 90 } 87 91 }; 88 92 });
+9 -9
src/lib/ui.svelte.ts
··· 17 17 lastUpdatedPlaylistId: null as string | null, 18 18 selectionMode: false, 19 19 20 - get modalsOpen() { 21 - return ( 22 - (this.showSettings ? 1 : 0) + 23 - (this.showKeys ? 1 : 0) + 24 - (this.exportPlaylist ? 1 : 0) + 25 - (this.confirmUpdatePlaylistId ? 1 : 0) + 26 - (this.renamePlaylistId ? 1 : 0) + 27 - (this.confirmDeletePlaylistId ? 1 : 0) + 28 - (this.shareItemId ? 1 : 0) 20 + get hasModalOpen() { 21 + return !!( 22 + this.showSettings || 23 + this.showKeys || 24 + this.exportPlaylist || 25 + this.confirmUpdatePlaylistId || 26 + this.renamePlaylistId || 27 + this.confirmDeletePlaylistId || 28 + this.shareItemId 29 29 ); 30 30 }, 31 31 });