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.

feat: various improvements and fixes

closes #38, #40, #43, #45, #46, #49, #52

also fixes several other little issues i found, and general code readability improvements

ill probably be focusing on Tauri and offline mode afterwards. i think i have a pretty good plan of how to implement it cleanly.

+687 -365
+12 -1
src/App.svelte
··· 13 13 onMount(init); 14 14 15 15 $effect(() => { 16 - document.body.classList.toggle("dynamic", !!settings.dynamicColors); 16 + const app = document.getElementById("app"); 17 + if (app) { 18 + app.style.setProperty("--sidebar-width", `${settings.sidebarWidth}rem`); 19 + } 20 + }); 21 + 22 + $effect(() => { 23 + const classList = document.body.classList; 24 + classList.toggle("dynamic", !!settings.dynamicColors); 25 + classList.toggle("rounded-art", !!settings.roundedArt); 26 + classList.toggle("transparent-bg", !!settings.transparentBackgrounds); 27 + classList.toggle("transparent-borders", !!settings.transparentOutlines); 17 28 }); 18 29 </script> 19 30
+11 -1
src/app.css
··· 38 38 "sidebar queue" 39 39 "playback playback" 40 40 "actions actions"; 41 - grid-template-columns: 20rem 1fr; 41 + grid-template-columns: var(--sidebar-width, 20rem) 1fr; 42 42 grid-template-rows: 1fr auto auto; 43 43 block-size: 100dvh; 44 44 inline-size: 100dvw; ··· 55 55 grid-template-rows: 1fr 1fr auto auto; 56 56 } 57 57 } 58 + 59 + body.transparent-bg { 60 + --bg-secondary: transparent; 61 + --bg-tertiary: transparent; 62 + } 63 + 64 + body.transparent-borders { 65 + --border: transparent; 66 + --border-subtle: transparent; 67 + }
+8 -2
src/lib/Actions.svelte
··· 1 1 <script lang="ts"> 2 - import { clear, getSortItems, ui, queue, t } from "./app.svelte.js"; 2 + import { clear, getSortItems, ui, queue, settings, t } from "./app.svelte.js"; 3 3 import ContextMenu from "./ContextMenu.svelte"; 4 4 import icCog from "../assets/famfamfam-silk-svg/icons/Cog.svg"; 5 5 import icShuffle from "../assets/famfamfam-silk-svg/icons/Arrow Switch.svg"; ··· 14 14 <a 15 15 href="https://tangled.org/devins.page/tinysub" 16 16 class="version" 17 - tabindex="-1">tinysub 2.1.2</a 17 + tabindex="-1" 18 + >tinysub 2.1.2{#if settings.showQueueStats && queue.tracks.length > 0} 19 + {" "}• {queue.tracks.length} 20 + {t( 21 + queue.tracks.length === 1 ? "song" : "songs", 22 + )}{#if queue.sel.length > 0} 23 + {" "}({queue.sel.length} {t("selected")}){/if}{/if}</a 18 24 > 19 25 <div class="btns"> 20 26 <button
+7 -7
src/lib/AuthModal.svelte
··· 2 2 import { auth, login, t } from "./app.svelte.js"; 3 3 import Modal from "./Modal.svelte"; 4 4 5 - let s = $state(""), 6 - u = $state(""), 7 - p = $state(""); 5 + let server = $state(""), 6 + user = $state(""), 7 + password = $state(""); 8 8 </script> 9 9 10 10 <Modal open={!auth.ok} title={t("login")} closable={false}> 11 11 <form 12 12 onsubmit={(e) => { 13 13 e.preventDefault(); 14 - login(s, u, p); 14 + login(server, user, password); 15 15 }} 16 16 > 17 17 <input 18 - bind:value={s} 18 + bind:value={server} 19 19 placeholder={t("server_placeholder")} 20 20 required 21 21 autocomplete="url" 22 22 /> 23 23 <input 24 - bind:value={u} 24 + bind:value={user} 25 25 placeholder={t("username_placeholder")} 26 26 required 27 27 autocomplete="username" 28 28 /> 29 29 <input 30 - bind:value={p} 30 + bind:value={password} 31 31 type="password" 32 32 placeholder={t("password_placeholder")} 33 33 autocomplete="current-password"
+9 -2
src/lib/ContextMenu.svelte
··· 36 36 37 37 function onKey(e: KeyboardEvent) { 38 38 const k = e.key; 39 - if (k === "Escape") onclose(); 40 - else if (k === "ArrowDown") { 39 + if (k === "Escape") { 40 + e.stopPropagation(); 41 + onclose(); 42 + } else if (k === "ArrowDown") { 43 + e.stopPropagation(); 41 44 e.preventDefault(); 42 45 if (subActive !== null && active !== null && items[active].items) { 43 46 const s = items[active].items!; ··· 47 50 subActive = null; 48 51 } 49 52 } else if (k === "ArrowUp") { 53 + e.stopPropagation(); 50 54 e.preventDefault(); 51 55 if (subActive !== null && active !== null && items[active].items) { 52 56 const s = items[active].items!; ··· 57 61 } 58 62 } else if (k === "ArrowRight") { 59 63 if (active !== null && items[active].items) { 64 + e.stopPropagation(); 60 65 e.preventDefault(); 61 66 subActive = 0; 62 67 } 63 68 } else if (k === "ArrowLeft") { 64 69 if (subActive !== null) { 70 + e.stopPropagation(); 65 71 e.preventDefault(); 66 72 subActive = null; 67 73 } 68 74 } else if (k === "Enter") { 75 + e.stopPropagation(); 69 76 e.preventDefault(); 70 77 if (active !== null) { 71 78 const item = items[active];
+38 -17
src/lib/Library.svelte
··· 5 5 import TreeItem from "./TreeItem.svelte"; 6 6 7 7 let query = $state(""), 8 - results = $state<any>(null), 9 - debounceTimer: any; 8 + results = $state<any>(null); 10 9 11 10 let isArtistsExpanded = $state(true); 12 11 let isPlaylistsExpanded = $state(true); 13 12 14 13 $effect(() => { 15 - clearTimeout(debounceTimer); 16 14 if (!query.trim()) { 17 15 results = null; 18 16 return; 19 17 } 20 - debounceTimer = setTimeout(async () => { 18 + const timer = setTimeout(async () => { 21 19 results = (await api.search(query)) || {}; 22 20 }, 300); 21 + return () => clearTimeout(timer); 23 22 }); 24 23 25 24 function onKey(e: KeyboardEvent) { ··· 27 26 const elements = Array.from( 28 27 document.querySelectorAll("#library [data-id]"), 29 28 ) as HTMLElement[]; 30 - const i = elements.findIndex((el) => el.classList.contains("focused")); 29 + const focusedIndex = elements.findIndex((el) => 30 + el.classList.contains("focused"), 31 + ); 31 32 const { key, code, altKey: alt, shiftKey: shift } = e; 32 33 33 34 if (key === "Escape") return (e.preventDefault(), (lib.focusedId = null)); ··· 45 46 e.preventDefault(); 46 47 const targetIndex = Math.max( 47 48 0, 48 - Math.min(elements.length - 1, i + offsets[key]), 49 + Math.min(elements.length - 1, focusedIndex + offsets[key]), 49 50 ); 50 51 const next = elements[targetIndex]; 51 52 if (next) { ··· 54 55 } 55 56 } else if (code === "Enter") { 56 57 e.preventDefault(); 57 - elements[i]?.click(); 58 - } else if (code === "KeyP") { 58 + elements[focusedIndex]?.click(); 59 + } else if (code === "KeyA") { 59 60 e.preventDefault(); 60 - elements[i]?.dispatchEvent(new CustomEvent("actionadd", { detail: alt })); 61 + elements[focusedIndex]?.dispatchEvent( 62 + new CustomEvent("actionadd", { detail: alt }), 63 + ); 61 64 } else if (code === "KeyU") { 62 65 e.preventDefault(); 63 - elements[i]?.dispatchEvent( 64 - new CustomEvent(alt ? "actiondelete" : "actionupdate"), 65 - ); 66 + elements[focusedIndex]?.dispatchEvent(new CustomEvent("actionupdate")); 67 + } else if (code === "KeyR") { 68 + e.preventDefault(); 69 + elements[focusedIndex]?.dispatchEvent(new CustomEvent("actionrename")); 70 + } else if (code === "KeyD" || key === "Delete") { 71 + e.preventDefault(); 72 + elements[focusedIndex]?.dispatchEvent(new CustomEvent("actiondelete")); 66 73 } else if ( 67 74 key === "ContextMenu" || 68 75 key === "`" || 69 76 (key === "F10" && shift) 70 77 ) { 71 78 e.preventDefault(); 72 - const el = elements[i] as HTMLElement; 79 + const el = elements[focusedIndex] as HTMLElement; 73 80 if (el) { 74 81 const rect = el.getBoundingClientRect(); 75 82 el.dispatchEvent( ··· 119 126 {t((type + "s") as any)} 120 127 </button> 121 128 <ul> 122 - {#each items as item, i (item.id + i)} 129 + {#each items as item, itemIndex (item.id + itemIndex)} 123 130 <TreeItem 124 131 id={item.id} 132 + uid={`search-${type}-${item.id}-${itemIndex}`} 125 133 label={item.name || item.title} 126 134 fetcher={type === "artist" 127 135 ? () => api.artist(item.id) ··· 157 165 </button> 158 166 {#if isArtistsExpanded} 159 167 <ul> 160 - {#each lib.artists as item (item.id)} 168 + {#each lib.artists as item, itemIndex (item.id)} 161 169 <TreeItem 162 170 id={item.id} 171 + uid={`artist-${item.id}-${itemIndex}`} 163 172 label={item.name} 164 173 fetcher={() => api.artist(item.id)} 165 174 childFactory={(a: any) => () => api.album(a.id)} ··· 185 194 </button> 186 195 {#if isPlaylistsExpanded} 187 196 <ul> 188 - {#each lib.playlists as item (item.id)} 197 + {#each lib.playlists as item, itemIndex (item.id)} 189 198 <TreeItem 190 199 id={item.id} 200 + uid={`playlist-${item.id}-${itemIndex}`} 191 201 label={item.name} 192 202 fetcher={() => api.playlist(item.id)} 193 203 type="playlist" 194 204 isReadonly={item.readonly} 205 + owner={item.owner} 206 + isPublic={item.public} 195 207 /> 196 208 {/each} 197 209 </ul> ··· 203 215 204 216 <style> 205 217 #library { 206 - background: var(--bg-secondary); 207 218 flex: 1; 208 219 overflow-y: auto; 209 220 padding-block: 0.5rem; ··· 243 254 } 244 255 button { 245 256 color: inherit; 257 + } 258 + :global(body.dynamic) #search { 259 + background-color: var(--bg-secondary); 260 + color: var(--text); 261 + border: 1px solid var(--border); 262 + border-radius: 4px; 263 + } 264 + :global(body.dynamic) #search::placeholder { 265 + color: var(--text); 266 + opacity: 0.5; 246 267 } 247 268 </style>
+5 -4
src/lib/Modal.svelte
··· 1 1 <script lang="ts"> 2 - import { untrack } from "svelte"; 3 2 import icCross from "../assets/famfamfam-silk-svg/icons/Cross.svg"; 4 3 import { ui, t } from "./app.svelte.js"; 5 4 ··· 24 23 $effect(() => { 25 24 if (open && !isOpen) { 26 25 isOpen = true; 27 - untrack(() => ui.modalsOpen++); 28 26 modal ? dialog.showModal() : dialog.show(); 29 27 } else if (!open && isOpen) { 30 28 isOpen = false; 31 - untrack(() => ui.modalsOpen--); 32 29 if (dialog.open) dialog.close(); 33 30 if (onclose) onclose(); 34 31 } 35 32 }); 36 33 37 34 function handleClose() { 38 - if (open) open = false; 35 + if (closable) { 36 + open = false; 37 + } else if (open) { 38 + dialog.showModal(); 39 + } 39 40 } 40 41 </script> 41 42
+11 -7
src/lib/NowPlaying.svelte
··· 20 20 const parse = (text: string) => 21 21 text 22 22 .split("\n") 23 - .map((l) => { 24 - const match = l.match(/\[(\d+):(\d+)\.(\d+)\](.*)/); 23 + .map((line) => { 24 + const match = line.match(/\[(\d+):(\d+)\.(\d+)\](.*)/); 25 25 return match 26 26 ? { 27 27 time: +match[1] * 60 + +match[2] + +match[3] / 1000, ··· 29 29 } 30 30 : null; 31 31 }) 32 - .filter((l): l is { time: number; text: string } => !!(l && l.text)) 32 + .filter( 33 + (line): line is { time: number; text: string } => !!(line && line.text), 34 + ) 33 35 .sort((a, b) => a.time - b.time); 34 36 35 37 async function load() { ··· 89 91 <div 90 92 class="media" 91 93 onclick={() => (show = !show)} 92 - style="--sz: {settings.artNow}px" 94 + style="inline-size: {settings.artNow}px; block-size: {settings.artNow}px;" 93 95 > 94 96 {#if show}<div class="lyrics">{lyrics.full}</div> 95 97 {:else}<img 98 + class="art" 96 99 src={api.art( 97 100 player.track.albumId || player.track.coverArt || player.track.id, 98 101 512, ··· 112 115 <style> 113 116 .now { 114 117 border-block-start: 1px solid var(--border-subtle); 115 - background: var(--bg-secondary); 116 118 padding: 1rem; 117 119 gap: 1rem; 118 120 display: flex; ··· 137 139 } 138 140 .media { 139 141 background: #000; 140 - inline-size: var(--sz); 141 - block-size: var(--sz); 142 142 } 143 143 img { 144 144 inline-size: 100%; ··· 165 165 } 166 166 .synced { 167 167 font-style: italic; 168 + } 169 + :global(body.rounded-art) .art, 170 + :global(body.rounded-art) .media { 171 + border-radius: 4px; 168 172 } 169 173 </style>
+4 -2
src/lib/Playback.svelte
··· 5 5 player, 6 6 toggle, 7 7 seek, 8 - setVol, 9 8 formatTime, 10 9 t, 11 10 } from "./app.svelte.js"; ··· 65 64 max="1" 66 65 step="0.01" 67 66 value={player.vol} 68 - oninput={(e) => setVol(+e.currentTarget.value)} 67 + oninput={(e) => (player.vol = +e.currentTarget.value)} 69 68 /> 70 69 </div> 71 70 </div> ··· 132 131 } 133 132 .off { 134 133 opacity: 0.5; 134 + } 135 + :global(body.dynamic) input[type="range"] { 136 + accent-color: var(--text); 135 137 } 136 138 </style>
+56 -27
src/lib/PlaylistModals.svelte
··· 7 7 let exportName = $state(""); 8 8 let isExporting = $state(false); 9 9 let isUpdating = $state(false); 10 + let isRenaming = $state(false); 10 11 let isDeleting = $state(false); 11 12 12 13 async function doExport() { ··· 28 29 } 29 30 } 30 31 31 - let showUpdate = $state(false); 32 - let showDelete = $state(false); 33 32 let countdown = $state(0); 34 33 35 34 $effect(() => { 36 - if (ui.confirmUpdatePlaylistId) showUpdate = true; 37 - }); 38 - $effect(() => { 39 - if (ui.confirmDeletePlaylistId) showDelete = true; 40 - }); 41 - 42 - $effect(() => { 43 - if (showUpdate || showDelete) { 35 + if (ui.confirmUpdatePlaylistId || ui.confirmDeletePlaylistId) { 44 36 countdown = 2; 45 37 const interval = setInterval(() => { 46 38 countdown--; ··· 50 42 } 51 43 }); 52 44 53 - function closeUpdate() { 54 - showUpdate = false; 55 - ui.confirmUpdatePlaylistId = null; 56 - } 57 - 58 - function closeDelete() { 59 - showDelete = false; 60 - ui.confirmDeletePlaylistId = null; 61 - } 62 - 63 45 async function doUpdate() { 64 46 if (!ui.confirmUpdatePlaylistId) return; 65 47 isUpdating = true; ··· 67 49 const songIds = queue.tracks.map((t) => t.id); 68 50 if (songIds.length > 0) { 69 51 await api.createPlaylist( 70 - ui.confirmUpdatePlaylistName, 52 + undefined, 71 53 songIds, 72 54 ui.confirmUpdatePlaylistId, 73 55 ); ··· 77 59 await api.updatePlaylist( 78 60 ui.confirmUpdatePlaylistId, 79 61 undefined, 62 + undefined, 80 63 curSongs.map((_, i) => i), 81 64 ); 82 65 } 83 66 } 84 67 ui.lastUpdatedPlaylistId = `${ui.confirmUpdatePlaylistId}:${Date.now()}`; 85 - closeUpdate(); 68 + ui.confirmUpdatePlaylistId = null; 86 69 } catch (err) { 87 70 console.error(err); 88 71 alert(t("failed_update")); ··· 91 74 } 92 75 } 93 76 77 + async function doRename() { 78 + if (!ui.renamePlaylistId || !ui.renamePlaylistName.trim()) return; 79 + isRenaming = true; 80 + try { 81 + await api.updatePlaylist( 82 + ui.renamePlaylistId, 83 + ui.renamePlaylistName.trim(), 84 + ); 85 + await loadLib(); 86 + ui.renamePlaylistId = null; 87 + } catch (err) { 88 + console.error(err); 89 + alert(t("failed_rename")); 90 + } finally { 91 + isRenaming = false; 92 + } 93 + } 94 + 94 95 async function doDelete() { 95 96 if (!ui.confirmDeletePlaylistId) return; 96 97 isDeleting = true; 97 98 try { 98 99 await api.deletePlaylist(ui.confirmDeletePlaylistId); 99 100 await loadLib(); 100 - closeDelete(); 101 + ui.confirmDeletePlaylistId = null; 101 102 } catch (err) { 102 103 console.error(err); 103 104 alert(t("failed_delete")); ··· 126 127 </Modal> 127 128 128 129 <Modal 129 - bind:open={showUpdate} 130 - onclose={closeUpdate} 130 + open={!!ui.confirmUpdatePlaylistId} 131 + onclose={() => (ui.confirmUpdatePlaylistId = null)} 131 132 title={t("update_playlist")} 132 133 > 133 134 <p> ··· 146 147 </Modal> 147 148 148 149 <Modal 149 - bind:open={showDelete} 150 - onclose={closeDelete} 150 + open={!!ui.renamePlaylistId} 151 + onclose={() => (ui.renamePlaylistId = null)} 152 + title={t("rename_playlist")} 153 + > 154 + <div class="field"> 155 + <label for="pl-rename-name">{t("playlist_name")}</label> 156 + <input 157 + id="pl-rename-name" 158 + bind:value={ui.renamePlaylistName} 159 + placeholder={t("playlist_name")} 160 + onkeydown={(e) => 161 + e.key === "Enter" && 162 + !isRenaming && 163 + ui.renamePlaylistName.trim() && 164 + doRename()} 165 + /> 166 + </div> 167 + <div class="actions"> 168 + <button 169 + onclick={doRename} 170 + disabled={isRenaming || !ui.renamePlaylistName.trim()} 171 + > 172 + {isRenaming ? t("renaming") : t("rename")} 173 + </button> 174 + </div> 175 + </Modal> 176 + 177 + <Modal 178 + open={!!ui.confirmDeletePlaylistId} 179 + onclose={() => (ui.confirmDeletePlaylistId = null)} 151 180 title={t("delete_playlist")} 152 181 > 153 182 <p>{t("delete_confirm", { name: ui.confirmDeletePlaylistName })}</p>
+29 -13
src/lib/Queue.svelte
··· 35 35 if (nav.head >= 0) scrollToIndex?.(nav.head); 36 36 }); 37 37 38 - function onDragStart(e: DragEvent, i: number) { 39 - if (!queue.sel.includes(i)) select(i); 38 + function onDragStart(e: DragEvent, index: number) { 39 + if (!queue.sel.includes(index)) select(index); 40 40 e.dataTransfer!.effectAllowed = "move"; 41 41 } 42 42 43 - function onDragOver(e: DragEvent, i: number) { 43 + function onDragOver(e: DragEvent, index: number) { 44 44 e.preventDefault(); 45 45 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); 46 - drag = { index: i, isAfter: e.clientY > rect.top + rect.height / 2 }; 46 + drag = { index, isAfter: e.clientY > rect.top + rect.height / 2 }; 47 47 } 48 48 49 49 function onDrop() { ··· 53 53 54 54 function onKey(e: KeyboardEvent) { 55 55 if ((e.target as HTMLElement).tagName === "INPUT") return; 56 - const k = e.key, 56 + const key = e.key, 57 57 shift = e.shiftKey, 58 58 alt = e.altKey; 59 59 if (e.ctrlKey || e.metaKey) { 60 - if (k === "a") { 60 + if (key === "a") { 61 61 e.preventDefault(); 62 62 selectAll(); 63 63 } ··· 80 80 F10: () => e.shiftKey && showMenu(nav.head), 81 81 }; 82 82 83 - if (moves[k]) { 83 + if (moves[key]) { 84 84 e.preventDefault(); 85 - moves[k](); 85 + moves[key](); 86 86 } 87 87 } 88 88 ··· 119 119 <div class="col">{t("title")}</div> 120 120 {#if settings.enableArtist}<div class="col">{t("artist")}</div>{/if} 121 121 {#if settings.enableAlbum}<div class="col">{t("album")}</div>{/if} 122 + {#if settings.enableQuality}<div class="col-num">{t("quality")}</div>{/if} 122 123 {#if settings.enableDuration}<div class="col-num">{t("time")}</div>{/if} 123 124 {#if settings.enableDisc}<div class="col-num">{t("disc")}</div>{/if} 124 125 {#if settings.enableTrackNum}<div class="col-num">{t("track")}</div>{/if} ··· 154 155 e.stopPropagation(); 155 156 select(index, e.ctrlKey || e.metaKey, e.shiftKey); 156 157 }} 157 - ondblclick={() => goto(index)} 158 + ondblclick={() => { 159 + goto(index); 160 + clearSel(); 161 + }} 158 162 oncontextmenu={(e) => { 159 163 e.preventDefault(); 160 164 if (!queue.sel.includes(index)) select(index); ··· 165 169 {@const artId = track.albumId || track.coverArt || track.id} 166 170 <div class="col-art" style="inline-size: {settings.artSong}px;"> 167 171 <img 172 + class="art" 168 173 src={api.art(artId, settings.artSong)} 169 174 srcset="{api.art(artId, settings.artSong)} 1x, {api.art( 170 175 artId, ··· 179 184 <div class="col">{track.title}</div> 180 185 {#if settings.enableArtist}<div class="col">{track.artist}</div>{/if} 181 186 {#if settings.enableAlbum}<div class="col">{track.album}</div>{/if} 187 + {#if settings.enableQuality} 188 + <div class="col-num"> 189 + {track.suffix || ""}{#if track.suffix && track.bitRate} 190 + {" "}{/if}{track.bitRate || ""} 191 + </div> 192 + {/if} 182 193 {#if settings.enableDuration} 183 194 <div class="col-num">{formatTime(track.duration)}</div> 184 195 {/if} ··· 208 219 {/if} 209 220 {#if settings.enableRatings} 210 221 <div class="col-rate"> 211 - {#each [1, 2, 3, 4, 5] as r} 222 + {#each [1, 2, 3, 4, 5] as rating} 212 223 <button 213 224 tabindex="-1" 214 225 class="btn-rate" 215 - class:active={track.userRating >= r} 226 + class:active={track.userRating >= rating} 216 227 onclick={(e) => { 217 228 e.stopPropagation(); 218 - setRating(track, track.userRating === r ? 0 : r); 229 + setRating(track, track.userRating === rating ? 0 : rating); 219 230 }} 220 231 > 221 232 ··· 254 265 onclick={(e) => { 255 266 e.stopPropagation(); 256 267 goto(index); 268 + clearSel(); 257 269 }} 258 270 title={t("play")} 259 271 > ··· 333 345 text-overflow: ellipsis; 334 346 } 335 347 .col-num { 336 - inline-size: 4rem; 348 + inline-size: 6rem; 337 349 text-align: end; 338 350 } 339 351 .col-fav { ··· 412 424 justify-content: center; 413 425 inline-size: 1rem; 414 426 block-size: 1rem; 427 + } 428 + :global(body.rounded-art) #queue .art, 429 + :global(body.rounded-art) #queue .row { 430 + border-radius: 2px; 415 431 } 416 432 </style>
+63 -14
src/lib/SettingsModal.svelte
··· 5 5 </script> 6 6 7 7 <Modal bind:open title={t("settings")}> 8 - <div class="settings"> 8 + <div class="grid"> 9 9 <div class="sec"> 10 10 <h3>{t("general")}</h3> 11 - <div class="row"> 12 - <span>{t("dynamic_favicon")}</span> 13 - <input type="checkbox" bind:checked={settings.dynamicFavicon} /> 14 - </div> 15 - <div class="row"> 16 - <span>{t("dynamic_colors")}</span> 17 - <input type="checkbox" bind:checked={settings.dynamicColors} /> 18 - </div> 19 11 <div class="row"> 20 12 <span>{t("scrobbling")}</span> 21 13 <input type="checkbox" bind:checked={settings.scrobbling} /> ··· 37 29 </div> 38 30 </div> 39 31 <div class="sec"> 32 + <h3>{t("ui")}</h3> 33 + <div class="row"> 34 + <span>{t("dynamic_colors")}</span> 35 + <input type="checkbox" bind:checked={settings.dynamicColors} /> 36 + </div> 37 + <div class="row"> 38 + <span>{t("dynamic_favicon")}</span> 39 + <input type="checkbox" bind:checked={settings.dynamicFavicon} /> 40 + </div> 41 + <div class="row"> 42 + <span>{t("rounded_covers")}</span> 43 + <input type="checkbox" bind:checked={settings.roundedArt} /> 44 + </div> 45 + <div class="row"> 46 + <span>{t("transparent_bg")}</span> 47 + <input type="checkbox" bind:checked={settings.transparentBackgrounds} /> 48 + </div> 49 + <div class="row"> 50 + <span>{t("transparent_borders")}</span> 51 + <input type="checkbox" bind:checked={settings.transparentOutlines} /> 52 + </div> 53 + <div class="row"> 54 + <span>{t("show_queue_stats")}</span> 55 + <input type="checkbox" bind:checked={settings.showQueueStats} /> 56 + </div> 57 + <div class="row"> 58 + <span 59 + >{t("sidebar_width")} (<span class="num" 60 + >{Math.round(settings.sidebarWidth)}rem</span 61 + >)</span 62 + > 63 + <div class="val"> 64 + <input 65 + type="range" 66 + min="16" 67 + max="48" 68 + step="1" 69 + bind:value={settings.sidebarWidth} 70 + /> 71 + </div> 72 + </div> 73 + </div> 74 + <div class="sec"> 40 75 <h3>{t("table_columns")}</h3> 41 76 <div class="row"> 42 77 <span>{t("show_artist")}</span> ··· 45 80 <div class="row"> 46 81 <span>{t("show_album")}</span> 47 82 <input type="checkbox" bind:checked={settings.enableAlbum} /> 83 + </div> 84 + <div class="row"> 85 + <span>{t("show_quality")}</span> 86 + <input type="checkbox" bind:checked={settings.enableQuality} /> 48 87 </div> 49 88 <div class="row"> 50 89 <span>{t("show_duration")}</span> ··· 92 131 </div> 93 132 {/each} 94 133 </div> 95 - <button onclick={logout}>{t("logout")}</button> 96 134 </div> 135 + <button class="logout" onclick={logout}>{t("logout")}</button> 97 136 </Modal> 98 137 99 138 <style> ··· 102 141 padding-block-end: 0.25rem; 103 142 margin-block: 0 0.5rem; 104 143 } 105 - .settings { 106 - display: flex; 107 - flex-direction: column; 108 - gap: 1rem; 144 + .grid { 145 + display: grid; 146 + grid-template-columns: repeat(2, 1fr); 147 + gap: 1.5rem; 148 + margin-block-end: 1rem; 149 + } 150 + @media (max-width: 32rem) { 151 + .grid { 152 + grid-template-columns: 1fr; 153 + } 109 154 } 110 155 .sec { 111 156 display: flex; ··· 116 161 display: flex; 117 162 justify-content: space-between; 118 163 align-items: center; 164 + gap: 1rem; 165 + } 166 + .logout { 167 + inline-size: 100%; 119 168 } 120 169 .val { 121 170 display: flex;
+16 -10
src/lib/ShortcutsModal.svelte
··· 26 26 { k: "↑ / ↓", d: t("navigate") }, 27 27 { k: "Home / End", d: t("first_last") }, 28 28 { k: "Enter", d: t("expand_collapse") }, 29 - { k: "P / Alt+P", d: t("add") + " / " + t("play_next") }, 30 - { k: "U / Alt+U", d: t("update") + " / " + t("delete") }, 29 + { k: "A / Alt+A", d: t("add") + " / " + t("play_next") }, 30 + { k: "U", d: t("update") }, 31 + { k: "R", d: t("rename") }, 32 + { k: "D / Delete", d: t("delete") }, 31 33 { k: "`", d: t("context_menu") }, 32 34 ], 33 35 }, ··· 51 53 {#each sections as section} 52 54 <div class="sec"> 53 55 <h3>{section.title}</h3> 54 - <div class="items"> 55 - {#each section.items as { k, d }} 56 - <div class="row"><kbd>{k}</kbd><span>{d}</span></div> 57 - {/each} 58 - </div> 56 + {#each section.items as { k: key, d: desc }} 57 + <div class="row"><kbd>{key}</kbd><span>{desc}</span></div> 58 + {/each} 59 59 </div> 60 60 {/each} 61 61 </div> ··· 69 69 } 70 70 .grid { 71 71 display: grid; 72 - grid-template-columns: 1fr 1fr; 73 - gap: 1rem; 72 + grid-template-columns: repeat(2, 1fr); 73 + gap: 1.5rem; 74 + margin-block-end: 1rem; 74 75 } 75 - .items { 76 + @media (max-width: 32rem) { 77 + .grid { 78 + grid-template-columns: 1fr; 79 + } 80 + } 81 + .sec { 76 82 display: flex; 77 83 flex-direction: column; 78 84 gap: 0.5rem;
+48
src/lib/Sidebar.svelte
··· 1 1 <script lang="ts"> 2 2 import Library from "./Library.svelte"; 3 3 import NowPlaying from "./NowPlaying.svelte"; 4 + import { settings } from "./app.svelte.js"; 5 + 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 + } 27 + 28 + $effect(() => { 29 + if (dragging) { 30 + document.body.style.cursor = "col-resize"; 31 + return () => (document.body.style.cursor = ""); 32 + } 33 + }); 4 34 </script> 5 35 6 36 <div id="sidebar"> 7 37 <Library /> 8 38 <NowPlaying /> 39 + <!-- svelte-ignore a11y_no_static_element_interactions --> 40 + <div class="resizer" onmousedown={onMouseDown}></div> 9 41 </div> 10 42 11 43 <style> 12 44 #sidebar { 45 + background: var(--bg-secondary); 13 46 grid-area: sidebar; 14 47 display: flex; 15 48 flex-direction: column; 16 49 border-inline-end: 1px solid var(--border); 17 50 overflow: hidden; 51 + position: relative; 52 + } 53 + .resizer { 54 + position: absolute; 55 + inset-block: 0; 56 + inset-inline-end: 0; 57 + inline-size: 4px; 58 + cursor: col-resize; 59 + z-index: 10; 60 + } 61 + .resizer:hover { 62 + background: var(--text); 18 63 } 19 64 @media (max-width: 32rem) { 20 65 #sidebar { 21 66 border-inline-end: none; 22 67 border-block-start: 1px solid var(--border); 68 + } 69 + .resizer { 70 + display: none; 23 71 } 24 72 } 25 73 </style>
+72 -34
src/lib/TreeItem.svelte
··· 1 1 <script lang="ts"> 2 + import { untrack } from "svelte"; 2 3 import { api } from "./client.svelte.js"; 3 4 import { 4 5 add, 6 + auth, 7 + loadLib, 5 8 player, 6 9 recursiveSongs, 7 10 settings, ··· 17 20 18 21 let { 19 22 id, 23 + uid = id, 20 24 label, 21 25 fetcher, 22 26 childFactory, ··· 24 28 track = null, 25 29 type = "artist", 26 30 isReadonly = false, 31 + owner = null, 32 + isPublic = false, 27 33 } = $props<{ 28 34 id: string; 35 + uid?: string; 29 36 label: string; 30 37 fetcher?: () => Promise<any[]>; 31 38 childFactory?: (item: any) => (() => Promise<any[]>) | undefined; ··· 33 40 track?: any; 34 41 type?: "artist" | "album" | "playlist" | "song"; 35 42 isReadonly?: boolean; 43 + owner?: string | null; 44 + isPublic?: boolean; 36 45 }>(); 37 46 38 47 let open = $state(false), ··· 40 49 busy = $state(false), 41 50 menu = $state<{ x: number; y: number } | null>(null); 42 51 52 + let publicOverride = $state<boolean | null>(null); 53 + let isPublicState = $derived(publicOverride ?? isPublic); 54 + 55 + async function doTogglePublic() { 56 + if (type !== "playlist" || owner !== auth.user) return; 57 + try { 58 + const next = !isPublicState; 59 + await api.updatePlaylist(id, undefined, undefined, undefined, next); 60 + publicOverride = next; 61 + await loadLib(); 62 + publicOverride = null; 63 + } catch (err) { 64 + console.error(err); 65 + } 66 + } 67 + 43 68 $effect(() => { 44 69 const signal = ui.lastUpdatedPlaylistId; 45 70 if (type === "playlist" && signal?.startsWith(id + ":")) { 46 - if (open && fetcher) { 71 + if (untrack(() => open) && fetcher) { 47 72 busy = true; 48 73 fetcher() 49 74 .then((res: any[]) => (items = res)) 50 75 .catch((err: any) => console.error(err)) 51 76 .finally(() => (busy = false)); 77 + } else { 78 + items = null; 52 79 } 53 80 } 54 81 }); ··· 89 116 } 90 117 } 91 118 119 + function doRename() { 120 + if (type === "playlist" && (owner === auth.user || !isReadonly)) { 121 + ui.renamePlaylistId = id; 122 + ui.renamePlaylistName = label; 123 + } 124 + } 125 + 92 126 function doDelete() { 93 - if (type === "playlist") { 127 + if (type === "playlist" && (owner === auth.user || !isReadonly)) { 94 128 ui.confirmDeletePlaylistId = id; 95 129 ui.confirmDeletePlaylistName = label; 96 130 } ··· 100 134 const onAdd = (e: any) => doAdd(e.detail); 101 135 node.addEventListener("actionadd", onAdd); 102 136 node.addEventListener("actionupdate", doUpdate); 137 + node.addEventListener("actionrename", doRename); 103 138 node.addEventListener("actiondelete", doDelete); 104 139 return { 105 140 destroy() { 106 141 node.removeEventListener("actionadd", onAdd); 107 142 node.removeEventListener("actionupdate", doUpdate); 143 + node.removeEventListener("actionrename", doRename); 108 144 node.removeEventListener("actiondelete", doDelete); 109 145 }, 110 146 }; ··· 122 158 </script> 123 159 124 160 <li> 125 - <div class="row" class:playing={player.track?.id === id}> 161 + <div class="row"> 126 162 <button 127 163 tabindex="-1" 128 164 class="btn main" 129 - class:focused={lib.focusedId === id} 130 - data-id={id} 165 + class:focused={lib.focusedId === uid} 166 + data-id={uid} 131 167 use:keyboardActions 132 168 onclick={toggle} 133 169 oncontextmenu={(e) => { ··· 165 201 <img src={icDisk} alt="" /> 166 202 </button> 167 203 {/if} 168 - <button 169 - tabindex="-1" 170 - class="btn add" 171 - onclick={(e) => { 172 - e.stopPropagation(); 173 - doDelete(); 174 - }} 175 - title={t("delete_playlist")} 176 - > 177 - <img src={icBinEmpty} alt="" /> 178 - </button> 204 + {#if owner === auth.user || !isReadonly} 205 + <button 206 + tabindex="-1" 207 + class="btn add" 208 + onclick={(e) => { 209 + e.stopPropagation(); 210 + doDelete(); 211 + }} 212 + title={t("delete_playlist")} 213 + > 214 + <img src={icBinEmpty} alt="" /> 215 + </button> 216 + {/if} 179 217 {/if} 180 218 <button 181 219 tabindex="-1" ··· 198 236 {#each items as item, i (item.id + i)} 199 237 <TreeItem 200 238 id={item.id} 239 + uid={id + ":" + item.id + ":" + i} 201 240 label={item.name || item.title} 202 241 fetcher={childFactory?.(item)} 203 242 childFactory={undefined} ··· 221 260 { label: t("add_next"), action: () => doAdd(true) }, 222 261 ...(type === "playlist" 223 262 ? [ 224 - ...(!isReadonly 263 + ...(!isReadonly ? [{ label: t("update"), action: doUpdate }] : []), 264 + ...(owner === auth.user || !isReadonly 225 265 ? [ 226 - { 227 - label: t("update"), 228 - action: () => { 229 - ui.confirmUpdatePlaylistId = id; 230 - ui.confirmUpdatePlaylistName = label; 231 - }, 232 - }, 266 + { label: t("rename"), action: doRename }, 267 + ...(owner === auth.user 268 + ? [ 269 + { 270 + label: isPublicState 271 + ? t("make_private") 272 + : t("make_public"), 273 + action: doTogglePublic, 274 + }, 275 + ] 276 + : []), 277 + { label: t("delete"), action: doDelete }, 233 278 ] 234 279 : []), 235 - { 236 - label: t("delete"), 237 - action: () => { 238 - ui.confirmDeletePlaylistId = id; 239 - ui.confirmDeletePlaylistName = label; 240 - }, 241 - }, 242 280 ] 243 281 : []), 244 282 ]} ··· 255 293 align-items: center; 256 294 gap: 0.25rem; 257 295 margin-block-start: 0.5rem; 258 - } 259 - .row.playing { 260 - background: var(--playing); 261 296 } 262 297 .btn.main.focused { 263 298 background: Highlight; ··· 299 334 } 300 335 button { 301 336 color: inherit; 337 + } 338 + :global(body.rounded-art) .art { 339 + border-radius: 4px; 302 340 } 303 341 </style>
+2 -1
src/lib/VirtualList.svelte
··· 41 41 let visibleIndices = $derived( 42 42 Array.from( 43 43 { length: visibleEnd - visibleStart }, 44 - (_, i) => visibleStart + i, 44 + (_, index) => visibleStart + index, 45 45 ), 46 46 ); 47 47 ··· 58 58 bind:this={container} 59 59 class="virtual-container" 60 60 onscroll={(e) => (scrollTop = (e.target as HTMLDivElement).scrollTop)} 61 + tabindex="-1" 61 62 > 62 63 <div class="virtual-spacer" style="block-size: {items.length * itemHeight}px"> 63 64 {#each visibleIndices as index (index)}
+10 -7
src/lib/app.svelte.ts
··· 8 8 9 9 import { ui } from "./ui.svelte.js"; 10 10 import { settings } from "./settings.svelte.js"; 11 - import { setCreds } from "./client.svelte.js"; 11 + import { setCredentials } from "./client.svelte.js"; 12 12 import { login, logout } from "./auth.svelte.js"; 13 13 import { setupMediaSession, toggle, seek, player } from "./player.svelte.js"; 14 14 import { prev, next } from "./queue.svelte.js"; ··· 44 44 export const init = async () => { 45 45 setupMediaSession(); 46 46 initTheme(); 47 - const credsStr = localStorage.getItem("tinysub_credentials"); 48 - if (credsStr) { 49 - const parsed = JSON.parse(credsStr); 50 - setCreds(parsed); 47 + const storedCredentials = localStorage.getItem("tinysub_credentials"); 48 + if (storedCredentials) { 49 + const parsed = JSON.parse(storedCredentials); 50 + setCredentials(parsed); 51 51 await login(parsed.server, parsed.username).catch(logout); 52 52 } 53 53 54 - const setsStr = localStorage.getItem("tinysub_settings"); 55 - if (setsStr) Object.assign(settings, JSON.parse(setsStr)); 54 + const storedSettings = localStorage.getItem("tinysub_settings"); 55 + if (storedSettings) { 56 + const parsed = JSON.parse(storedSettings); 57 + Object.assign(settings, parsed); 58 + } 56 59 57 60 ui.busy = false; 58 61 };
+8 -3
src/lib/auth.svelte.ts
··· 1 - import { api, setCreds, createToken, songCache } from "./client.svelte.js"; 1 + import { 2 + api, 3 + setCredentials, 4 + createToken, 5 + songCache, 6 + } from "./client.svelte.js"; 2 7 import { loadLib } from "./library.svelte.js"; 3 8 import { syncQueue } from "./queue.svelte.js"; 4 9 ··· 19 24 auth.err = null; 20 25 try { 21 26 if (password) { 22 - setCreds({ server, username, ...createToken(password) }); 27 + setCredentials({ server, username, ...createToken(password) }); 23 28 } 24 29 await api.ping(); 25 30 await loadLib(); ··· 27 32 Object.assign(auth, { server, user: username, ok: true }); 28 33 return true; 29 34 } catch (err: any) { 30 - setCreds(null); 35 + setCredentials(null); 31 36 auth.err = err; 32 37 return false; 33 38 } finally {
+69 -57
src/lib/client.svelte.ts
··· 17 17 name: string; 18 18 coverArt?: string; 19 19 readonly?: boolean; 20 + owner?: string; 21 + public?: boolean; 20 22 } 21 23 export interface Song { 22 24 id: string; ··· 45 47 salt: string; 46 48 } 47 49 48 - export let creds: Credentials | null = null; 50 + export let credentials: Credentials | null = null; 49 51 50 - export const setCreds = (c: Credentials | null) => { 51 - creds = c; 52 + export const setCredentials = (c: Credentials | null) => { 53 + credentials = c; 52 54 c 53 55 ? localStorage.setItem("tinysub_credentials", JSON.stringify(c)) 54 56 : localStorage.removeItem("tinysub_credentials"); ··· 56 58 57 59 export const asArray = (v: any) => (Array.isArray(v) ? v : v ? [v] : []); 58 60 59 - let cachedAuth: any = null; 60 - const authParams = () => { 61 - if (!creds) throw "Auth required"; 62 - if (cachedAuth?.u === creds.username) return cachedAuth; 63 - return (cachedAuth = { 64 - u: creds.username, 65 - t: creds.token, 66 - s: creds.salt, 61 + const getParams = (params: any = {}) => { 62 + if (!credentials) throw "Auth required"; 63 + const p = new URLSearchParams({ 64 + u: credentials.username, 65 + t: credentials.token, 66 + s: credentials.salt, 67 67 v: "1.16.1", 68 68 c: "tinysub", 69 69 f: "json", 70 70 }); 71 - }; 72 - 73 - const getParams = (params: any = {}) => { 74 - const p = new URLSearchParams(); 75 - Object.entries(authParams()).forEach(([k, v]) => p.append(k, v as string)); 76 71 Object.entries(params).forEach(([k, v]) => { 77 72 if (Array.isArray(v)) v.forEach((val) => p.append(k, val)); 78 - else if (v !== undefined) p.append(k, v as string); 73 + else if (v !== undefined) p.append(k, String(v)); 79 74 }); 80 75 return p; 81 76 }; 82 77 83 78 export const buildUrl = (method: string, params: any = {}) => 84 - `${creds!.server.replace(/\/$/, "")}/rest/${method}?${getParams(params)}`; 79 + `${credentials!.server.replace(/\/$/, "")}/rest/${method}?${getParams(params)}`; 85 80 86 - const req = async (method: string, params: any = {}) => { 87 - const response = (await (await fetch(buildUrl(method, params))).json())[ 88 - "subsonic-response" 89 - ]; 90 - if (response.status === "failed") 91 - throw response.error?.message || "API error"; 92 - return response; 93 - }; 94 - 95 - const post = async (method: string, params: any = {}) => { 96 - const res = await fetch(buildUrl(method).split("?")[0], { 97 - method: "POST", 98 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 99 - body: getParams(params).toString(), 100 - }); 81 + const request = async (method: string, params: any = {}, isPost = false) => { 82 + const url = buildUrl(method, isPost ? {} : params); 83 + const options: RequestInit = isPost 84 + ? { 85 + method: "POST", 86 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 87 + body: getParams(params).toString(), 88 + } 89 + : {}; 90 + const res = await fetch(isPost ? url.split("?")[0] : url, options); 101 91 const response = (await res.json())["subsonic-response"]; 102 92 if (response.status === "failed") 103 93 throw response.error?.message || "API error"; ··· 118 108 }; 119 109 120 110 export const api = { 121 - ping: () => req("ping"), 111 + ping: () => request("ping"), 122 112 artists: () => 123 - req("getArtists").then((r) => 124 - asArray(r.artists?.index).flatMap((i: any) => asArray(i.artist)), 113 + request("getArtists").then((response) => 114 + asArray(response.artists?.index).flatMap((index: any) => 115 + asArray(index.artist), 116 + ), 125 117 ), 126 118 playlists: () => 127 - req("getPlaylists").then((r) => asArray(r.playlists?.playlist)), 119 + request("getPlaylists").then((response) => 120 + asArray(response.playlists?.playlist), 121 + ), 128 122 artist: (id: string) => 129 - req("getArtist", { id }).then((r) => asArray(r.artist.album)), 123 + request("getArtist", { id }).then((response) => 124 + asArray(response.artist.album), 125 + ), 130 126 album: (id: string) => 131 - req("getAlbum", { id }).then((r) => asArray(r.album.song).map(internSong)), 127 + request("getAlbum", { id }).then((response) => 128 + asArray(response.album.song).map(internSong), 129 + ), 132 130 playlist: (id: string) => 133 - req("getPlaylist", { id }).then((r) => 134 - asArray(r.playlist.entry).map(internSong), 131 + request("getPlaylist", { id }).then((response) => 132 + asArray(response.playlist.entry).map(internSong), 135 133 ), 136 134 search: (query: string) => 137 - req("search3", { query }).then((r) => { 138 - const res = r.searchResult3 || {}; 139 - if (res.song) res.song = asArray(res.song).map(internSong); 140 - return res; 135 + request("search3", { query }).then((response) => { 136 + const results = response.searchResult3 || {}; 137 + if (results.song) results.song = asArray(results.song).map(internSong); 138 + return results; 141 139 }), 142 140 stream: (id: string) => buildUrl("stream", { id }), 143 141 art: (id: string, size = 128) => buildUrl("getCoverArt", { id, size }), 144 - star: (id: string) => req("star", { id }), 145 - unstar: (id: string) => req("unstar", { id }), 146 - setRating: (id: string, rating: number) => req("setRating", { id, rating }), 147 - lyricsById: (id: string) => req("getLyricsBySongId", { id }), 142 + star: (id: string) => request("star", { id }), 143 + unstar: (id: string) => request("unstar", { id }), 144 + setRating: (id: string, rating: number) => 145 + request("setRating", { id, rating }), 146 + lyricsById: (id: string) => request("getLyricsBySongId", { id }), 148 147 lyrics: (artist: string, title: string) => 149 - req("getLyrics", { artist, title }), 150 - createPlaylist: (name: string, songId: string[], playlistId?: string) => 151 - post("createPlaylist", { name, songId, playlistId }), 148 + request("getLyrics", { artist, title }), 149 + createPlaylist: (name?: string, songId?: string[], playlistId?: string) => 150 + request("createPlaylist", { name, songId, playlistId }, true), 152 151 updatePlaylist: ( 153 152 playlistId: string, 153 + name?: string, 154 154 songIdToAdd?: string[], 155 155 songIndexToRemove?: number[], 156 - ) => post("updatePlaylist", { playlistId, songIdToAdd, songIndexToRemove }), 157 - deletePlaylist: (id: string) => req("deletePlaylist", { id }), 156 + isPublic?: boolean, 157 + ) => 158 + request( 159 + "updatePlaylist", 160 + { 161 + playlistId, 162 + name, 163 + songIdToAdd, 164 + songIndexToRemove, 165 + public: isPublic, 166 + }, 167 + true, 168 + ), 169 + deletePlaylist: (id: string) => request("deletePlaylist", { id }), 158 170 savePlayQueue: (id: string[], current?: string, position?: number) => 159 - post("savePlayQueue", { id, current, position }), 160 - getPlayQueue: () => req("getPlayQueue"), 171 + request("savePlayQueue", { id, current, position }, true), 172 + getPlayQueue: () => request("getPlayQueue"), 161 173 scrobble: (id: string, submission = true) => 162 - req("scrobble", { id, submission }), 174 + request("scrobble", { id, submission }), 163 175 }; 164 176 165 177 export const createToken = (password: string) => {
+8 -5
src/lib/lang.svelte.ts
··· 8 8 export type TranslationKey = keyof BaseDict; 9 9 10 10 export const t = ( 11 - key: TranslationKey, 11 + translationKey: TranslationKey, 12 12 params?: Record<string, string | number>, 13 13 ): string => { 14 - let str = dict[settings.lang]?.[key] || en[key] || key; 14 + let translation = 15 + dict[settings.lang]?.[translationKey] || 16 + en[translationKey] || 17 + translationKey; 15 18 if (params) { 16 - for (const [k, v] of Object.entries(params)) { 17 - str = str.replace(`{${k}}`, String(v)); 19 + for (const [key, value] of Object.entries(params)) { 20 + translation = translation.replace(`{${key}}`, String(value)); 18 21 } 19 22 } 20 - return str; 23 + return translation; 21 24 };
+16
src/lib/lang/locales/en.ts
··· 23 23 prev: "prev", 24 24 queue: "queue", 25 25 rating: "rating", 26 + rename: "rename", 26 27 search: "search...", 28 + selected: "selected", 27 29 settings: "settings", 28 30 shortcuts: "shortcuts", 29 31 song: "song", 32 + songs: "songs", 30 33 sort: "sort", 31 34 time: "time", 32 35 title: "title", ··· 44 47 deleting: "deleting...", 45 48 export_to_playlist: "export to playlist", 46 49 exporting: "exporting...", 50 + make_private: "make private", 51 + make_public: "make public", 47 52 move_down: "move down", 48 53 move_up: "move up", 49 54 new_playlist: "new playlist", ··· 51 56 now_playing: "now playing", 52 57 play_next: "play next", 53 58 playlist_name: "playlist name", 59 + quality: "quality", 60 + rename_playlist: "rename playlist", 61 + renaming: "renaming...", 54 62 sort_all: "sort all", 55 63 undo_warning: "this cannot be undone.", 56 64 update_confirm: ··· 66 74 language: "language", 67 75 off: "off", 68 76 replaygain: "replaygain", 77 + rounded_covers: "rounded corners", 69 78 scrobbling: "scrobbling", 70 79 show_album: "show album", 71 80 show_artist: "show artist", 72 81 show_disc: "show disc #", 73 82 show_duration: "show duration", 74 83 show_favorites: "show favorites", 84 + show_quality: "show quality", 75 85 show_queue_num: "show queue #", 86 + show_queue_stats: "show queue stats", 76 87 show_ratings: "show ratings", 77 88 show_track: "show track #", 89 + sidebar_width: "sidebar width", 78 90 table_columns: "table columns", 91 + transparent_bg: "transparent backgrounds", 92 + transparent_borders: "transparent outlines", 93 + ui: "ui", 79 94 80 95 // shortcuts 81 96 context_menu: "context menu", ··· 93 108 // api / auth 94 109 failed_delete: "failed to delete playlist", 95 110 failed_export: "failed to export playlist", 111 + failed_rename: "failed to rename playlist", 96 112 failed_update: "failed to update playlist", 97 113 password_placeholder: "password", 98 114 server_placeholder: "server (e.g. http://localhost:4533)",
+16
src/lib/lang/locales/es.ts
··· 27 27 prev: "anterior", 28 28 queue: "cola", 29 29 rating: "calificación", 30 + rename: "renombrar", 30 31 search: "buscar...", 32 + selected: "seleccionado", 31 33 settings: "ajustes", 32 34 shortcuts: "atajos", 33 35 song: "canción", 36 + songs: "canciones", 34 37 sort: "ordenar", 35 38 time: "tiempo", 36 39 title: "título", ··· 48 51 deleting: "borrando...", 49 52 export_to_playlist: "exportar a lista", 50 53 exporting: "exportando...", 54 + make_private: "hacer privada", 55 + make_public: "hacer pública", 51 56 move_down: "bajar", 52 57 move_up: "subir", 53 58 new_playlist: "nueva lista", ··· 55 60 now_playing: "reproduciendo ahora", 56 61 play_next: "reproducir siguiente", 57 62 playlist_name: "nombre de la lista", 63 + quality: "calidad", 64 + rename_playlist: "renombrar lista", 65 + renaming: "renombrando...", 58 66 sort_all: "ordenar todo", 59 67 undo_warning: "esto no se puede deshacer.", 60 68 update_confirm: ··· 70 78 language: "idioma", 71 79 off: "desactivado", 72 80 replaygain: "replaygain", 81 + rounded_covers: "esquinas redondeadas", 73 82 scrobbling: "scrobbling", 74 83 show_album: "mostrar álbum", 75 84 show_artist: "mostrar artista", 76 85 show_disc: "mostrar nº disco", 77 86 show_duration: "mostrar duración", 78 87 show_favorites: "mostrar favoritos", 88 + show_quality: "mostrar calidad", 79 89 show_queue_num: "mostrar nº cola", 90 + show_queue_stats: "mostrar estadísticas de cola", 80 91 show_ratings: "mostrar calificaciones", 81 92 show_track: "mostrar nº pista", 93 + sidebar_width: "ancho de barra lateral", 82 94 table_columns: "columnas de la tabla", 95 + transparent_bg: "fondos transparentes", 96 + transparent_borders: "bordes transparentes", 97 + ui: "interfaz", 83 98 84 99 // shortcuts 85 100 context_menu: "menú contextual", ··· 97 112 // api / auth 98 113 failed_delete: "error al borrar lista", 99 114 failed_export: "error al exportar lista", 115 + failed_rename: "error al renombrar lista", 100 116 failed_update: "error al actualizar lista", 101 117 password_placeholder: "contraseña", 102 118 server_placeholder: "servidor (ej. http://localhost:4533)",
+2 -2
src/lib/library.svelte.ts
··· 28 28 }; 29 29 30 30 export const recursiveSongs = async ( 31 - list: any[], 31 + items: any[], 32 32 childFactory?: (item: any) => (() => Promise<any[]>) | undefined, 33 33 ): Promise<Song[]> => { 34 34 const songs: Song[] = []; ··· 42 42 } 43 43 } 44 44 }; 45 - await walk(list, childFactory); 45 + await walk(items, childFactory); 46 46 return songs; 47 47 };
+22 -34
src/lib/player.svelte.ts
··· 13 13 loop: false, 14 14 }); 15 15 16 - export const formatTime = (seconds?: number) => { 17 - if (seconds === undefined || isNaN(seconds)) return "0:00"; 18 - const minutes = Math.floor(seconds / 60), 19 - remainingSeconds = Math.floor(seconds % 60); 20 - return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; 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")}`; 21 20 }; 22 21 23 22 player.audio.onended = () => { ··· 26 25 } 27 26 next(); 28 27 }; 28 + 29 29 player.audio.ontimeupdate = () => (player.time = player.audio.currentTime); 30 - let pendingRestoreTime = 0; 31 30 31 + let pendingTime = 0; 32 32 player.audio.onloadedmetadata = () => { 33 33 player.dur = player.audio.duration; 34 - if (pendingRestoreTime > 0) { 35 - player.audio.currentTime = pendingRestoreTime; 36 - player.time = pendingRestoreTime; 37 - pendingRestoreTime = 0; 34 + if (pendingTime > 0) { 35 + player.audio.currentTime = pendingTime; 36 + player.time = pendingTime; 37 + pendingTime = 0; 38 38 } 39 - if ( 40 - navigator.mediaSession && 41 - "setPositionState" in navigator.mediaSession && 42 - player.dur > 0 43 - ) { 39 + if (navigator.mediaSession?.setPositionState && player.dur > 0) { 44 40 navigator.mediaSession.setPositionState({ 45 41 duration: player.dur, 46 42 playbackRate: player.audio.playbackRate, ··· 49 45 } 50 46 }; 51 47 52 - export const restoreTrack = (song: Song, position: number) => { 48 + const load = (song: Song, time = 0, autoplay = false) => { 53 49 player.track = song; 54 50 player.dur = song.duration || 0; 55 - pendingRestoreTime = position; 56 - player.time = position; 51 + player.time = pendingTime = time; 57 52 player.audio.src = api.stream(song.id); 53 + if (autoplay) player.audio.play(); 54 + else player.audio.load(); 55 + player.paused = !autoplay; 58 56 }; 59 57 58 + export const restoreTrack = (song: Song, time: number) => load(song, time); 59 + 60 60 export const play = (song: Song) => { 61 61 if (player.track?.id === song.id) { 62 62 seek(0); 63 63 player.audio.play(); 64 64 player.paused = false; 65 - return; 66 - } 67 - player.track = song; 68 - player.dur = song.duration || 0; 69 - pendingRestoreTime = 0; 70 - player.time = 0; 71 - player.audio.src = api.stream(song.id); 72 - player.audio.play(); 73 - player.paused = false; 65 + } else load(song, 0, true); 74 66 }; 75 67 76 68 export const toggle = () => { 77 69 if (!player.track) 78 70 return queue.tracks.length > 0 && goto(queue.pos === -1 ? 0 : queue.pos); 79 - player.audio.paused ? player.audio.play() : player.audio.pause(); 71 + player.paused ? player.audio.play() : player.audio.pause(); 80 72 player.paused = player.audio.paused; 81 73 }; 82 74 83 75 export const stop = () => { 84 76 player.audio.pause(); 85 77 player.audio.src = ""; 86 - player.track = null; 87 - player.paused = true; 88 - player.time = 0; 78 + Object.assign(player, { track: null, paused: true, time: 0 }); 89 79 queue.pos = -1; 90 80 }; 91 81 92 82 export const seek = (time: number) => { 93 83 player.audio.currentTime = time; 94 84 if ( 95 - navigator.mediaSession && 96 - "setPositionState" in navigator.mediaSession && 85 + navigator.mediaSession?.setPositionState && 97 86 player.dur > 0 && 98 87 time <= player.dur 99 88 ) { ··· 104 93 }); 105 94 } 106 95 }; 107 - export const setVol = (volume: number) => (player.vol = volume); 108 96 109 97 export const setupMediaSession = () => { 110 98 if (!navigator.mediaSession) return;
+87 -90
src/lib/queue.svelte.ts
··· 14 14 export const select = (index: number, multi = false, range = false) => { 15 15 if (index < 0 || index >= queue.tracks.length) return; 16 16 if (range && nav.anchor !== -1) { 17 - nav.head = index; 18 - const [start, end] = [ 19 - Math.min(nav.anchor, nav.head), 20 - Math.max(nav.anchor, nav.head), 21 - ]; 17 + const start = Math.min(nav.anchor, index), 18 + end = Math.max(nav.anchor, index); 22 19 const ids = Array.from({ length: end - start + 1 }, (_, k) => start + k); 23 - queue.sel = multi ? Array.from(new Set([...queue.sel, ...ids])) : ids; 20 + queue.sel = multi ? [...new Set([...queue.sel, ...ids])] : ids; 24 21 } else if (multi) { 25 - const idx = queue.sel.indexOf(index); 26 - idx !== -1 ? queue.sel.splice(idx, 1) : queue.sel.push(index); 27 - nav.anchor = nav.head = index; 22 + queue.sel = queue.sel.includes(index) 23 + ? queue.sel.filter((i) => i !== index) 24 + : [...queue.sel, index]; 25 + nav.anchor = index; 28 26 } else { 29 27 queue.sel = [index]; 30 - nav.anchor = nav.head = index; 28 + nav.anchor = index; 31 29 } 30 + nav.head = index; 32 31 }; 33 32 34 33 export const moveHead = (direction: number, expand = false) => { ··· 133 132 134 133 export const moveSelected = (targetIndex: number) => { 135 134 if (!queue.sel.length) return; 136 - const selectedIndices = new Set(queue.sel); 137 - const movedTracks = queue.sel 138 - .sort((a, b) => a - b) 139 - .map((i) => queue.tracks[i]); 140 - const playingTrack = player.track; 141 135 142 - const remainingTracks = queue.tracks.filter( 143 - (_, i) => !selectedIndices.has(i), 144 - ); 136 + const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); 137 + const selIndices = [...queue.sel].sort((a, b) => a - b); 138 + const moved = selIndices.map((i) => items[i]); 139 + const remaining = items.filter((_, i) => !queue.sel.includes(i)); 145 140 146 141 const removedBefore = queue.sel.filter((i) => i < targetIndex).length; 147 142 const actualTarget = Math.max(0, targetIndex - removedBefore); 148 143 149 - remainingTracks.splice(actualTarget, 0, ...movedTracks); 150 - queue.tracks = remainingTracks; 144 + remaining.splice(actualTarget, 0, ...moved); 145 + 146 + queue.tracks = remaining.map((x) => x.t); 147 + queue.pos = remaining.findIndex((x) => x.p); 151 148 152 - queue.sel = Array.from( 153 - { length: movedTracks.length }, 154 - (_, i) => actualTarget + i, 155 - ); 156 - if (playingTrack) { 157 - queue.pos = queue.tracks.findIndex((t) => t.id === playingTrack.id); 158 - } 149 + queue.sel = Array.from({ length: moved.length }, (_, i) => actualTarget + i); 159 150 nav.anchor = queue.sel[0]; 160 151 nav.head = queue.sel[queue.sel.length - 1]; 161 152 queue.version++; ··· 163 154 164 155 export const playNext = () => { 165 156 if (!queue.sel.length || queue.pos === -1) return; 166 - const cur = queue.tracks[queue.pos], 167 - sel = [...queue.sel].sort((a, b) => a - b), 168 - songs = sel.map((i) => queue.tracks[i]); 169 - for (let i = sel.length - 1; i >= 0; i--) queue.tracks.splice(sel[i], 1); 170 - const idx = queue.tracks.indexOf(cur); 171 - queue.tracks.splice(idx + 1, 0, ...songs); 172 - queue.pos = idx; 173 - queue.sel = songs.map((_, i) => idx + 1 + i); 157 + 158 + const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); 159 + const selIndices = [...queue.sel].sort((a, b) => a - b); 160 + const moved = selIndices.map((i) => items[i]); 161 + const remaining = items.filter((_, i) => !queue.sel.includes(i)); 162 + 163 + const newPos = remaining.findIndex((x) => x.p); 164 + remaining.splice(newPos + 1, 0, ...moved); 165 + 166 + queue.tracks = remaining.map((x) => x.t); 167 + queue.pos = remaining.findIndex((x) => x.p); 168 + queue.sel = moved.map((_, i) => newPos + 1 + i); 174 169 queue.version++; 175 170 }; 176 171 ··· 178 173 export const moveDown = () => reorder(1); 179 174 180 175 export const sortQueue = (field: string, asc = true) => { 181 - const cur = queue.tracks[queue.pos], 182 - isAll = !queue.sel.length; 183 - const targets = isAll 184 - ? [...queue.tracks] 185 - : queue.sel.map((i) => queue.tracks[i]); 186 - const indices = isAll ? [] : [...queue.sel].sort((a, b) => a - b); 176 + const isAll = !queue.sel.length; 177 + const indices = isAll 178 + ? queue.tracks.map((_, i) => i) 179 + : [...queue.sel].sort((a, b) => a - b); 180 + 181 + const targets = indices.map((idx) => ({ 182 + t: queue.tracks[idx], 183 + p: idx === queue.pos, 184 + })); 187 185 188 - const cmp = (a: any, b: any, numeric = true) => 186 + const cmp = (a: any, b: any) => 189 187 (a || "").toString().localeCompare((b || "").toString(), undefined, { 190 - numeric, 188 + numeric: true, 191 189 }); 192 190 193 - const compareDiscAndTrack = (a: Song, b: Song) => { 194 - const disc = (a.discNumber || 0) - (b.discNumber || 0); 195 - if (disc !== 0) return disc; 196 - return (a.track || 0) - (b.track || 0); 197 - }; 191 + const trackCmp = (a: Song, b: Song) => 192 + a.discNumber !== b.discNumber 193 + ? (a.discNumber || 0) - (b.discNumber || 0) 194 + : (a.track || 0) - (b.track || 0); 198 195 199 196 targets.sort((a, b) => { 197 + const s1 = a.t, 198 + s2 = b.t; 199 + let res = 0; 200 200 if (field === "artist") { 201 - const res = cmp(a.artist, b.artist); 202 - if (res !== 0) return asc ? res : -res; 203 - const alb = cmp(a.album, b.album); 204 - if (alb !== 0) return alb; 205 - return compareDiscAndTrack(a, b); 206 - } 207 - 208 - if (field === "album") { 209 - const res = cmp(a.album, b.album); 210 - if (res !== 0) return asc ? res : -res; 211 - return compareDiscAndTrack(a, b); 201 + res = 202 + cmp(s1.artist, s2.artist) || 203 + cmp(s1.album, s2.album) || 204 + trackCmp(s1, s2); 205 + } else if (field === "album") { 206 + res = cmp(s1.album, s2.album) || trackCmp(s1, s2); 207 + } else { 208 + const val = (s: Song) => { 209 + if (field === "stars") return !!s.starred ? 1 : 0; 210 + if (field === "rating") return s.userRating || 0; 211 + if (field === "duration") return s.duration || 0; 212 + return (s as any)[field]; 213 + }; 214 + const av = val(s1), 215 + bv = val(s2); 216 + res = typeof av === "number" ? av - bv : cmp(av, bv); 212 217 } 213 - 214 - const val = (s: Song) => { 215 - if (field === "stars") return !!s.starred ? 1 : 0; 216 - if (field === "rating") return s.userRating || 0; 217 - if (field === "duration") return s.duration || 0; 218 - return s[field as keyof Song]; 219 - }; 220 - 221 - const av = val(a), 222 - bv = val(b); 223 - const res = 224 - typeof av === "number" && typeof bv === "number" ? av - bv : cmp(av, bv); 225 218 return asc ? res : -res; 226 219 }); 227 220 228 - if (isAll) queue.tracks = targets; 229 - else indices.forEach((idx, i) => (queue.tracks[idx] = targets[i])); 230 - if (cur) queue.pos = queue.tracks.indexOf(cur); 221 + indices.forEach((idx, i) => { 222 + queue.tracks[idx] = targets[i].t; 223 + if (targets[i].p) queue.pos = idx; 224 + }); 225 + 231 226 queue.version++; 232 227 }; 233 228 ··· 258 253 const indices = isAll 259 254 ? queue.tracks.map((_, i) => i) 260 255 : [...queue.sel].sort((a, b) => a - b); 261 - const items = indices.map((i) => queue.tracks[i]); 256 + 257 + const items = indices.map((idx) => ({ 258 + t: queue.tracks[idx], 259 + p: idx === queue.pos, 260 + })); 262 261 263 262 for (let i = items.length - 1; i > 0; i--) { 264 263 const j = Math.floor(Math.random() * (i + 1)); 265 264 [items[i], items[j]] = [items[j], items[i]]; 266 265 } 267 266 268 - indices.forEach((idx, i) => (queue.tracks[idx] = items[i])); 269 - 270 - if (player.track) 271 - queue.pos = queue.tracks.findIndex( 272 - (track) => track.id === player.track?.id, 273 - ); 267 + indices.forEach((idx, i) => { 268 + queue.tracks[idx] = items[i].t; 269 + if (items[i].p) queue.pos = idx; 270 + }); 274 271 275 272 queue.version++; 276 273 }; ··· 334 331 }; 335 332 336 333 $effect.root(() => { 337 - let saveTimeout: any; 334 + let timer: any; 338 335 $effect(() => { 339 336 queue.version; 340 337 if (ui.busy || !auth.ok) return; 341 - clearTimeout(saveTimeout); 342 - saveTimeout = setTimeout(() => { 343 - requestIdleCallback(() => { 344 - const ids = queue.tracks.map((t) => t.id); 345 - const current = player.track?.id; 346 - const position = Math.floor(player.time * 1000); 347 - api.savePlayQueue(ids, current, position); 348 - }); 338 + clearTimeout(timer); 339 + timer = setTimeout(() => { 340 + if (!queue.tracks.length) return api.savePlayQueue([]); 341 + api.savePlayQueue( 342 + queue.tracks.map((t) => t.id), 343 + player.track?.id || queue.tracks[0].id, 344 + Math.floor(player.time * 1000), 345 + ); 349 346 }, 1000); 350 347 }); 351 348 });
+6
src/lib/settings.svelte.ts
··· 15 15 enableQueueNum: false, 16 16 enableDisc: false, 17 17 enableTrackNum: false, 18 + enableQuality: false, 18 19 dynamicColors: false, 20 + roundedArt: false, 21 + transparentBackgrounds: false, 22 + transparentOutlines: false, 23 + showQueueStats: false, 24 + sidebarWidth: 20, 19 25 scrobbling: true, 20 26 replayGainMode: "off" as "off" | "track" | "album", 21 27 lang: "en" as "en" | "es",
+39 -24
src/lib/theme.svelte.ts
··· 3 3 import { settings } from "./settings.svelte.js"; 4 4 import { getSwatches, getColor } from "colorthief"; 5 5 6 - const getLuminance = ([r, g, b]: number[]) => { 7 - const [rs, gs, bs] = [r, g, b].map((v) => { 6 + const getLuminance = (rgb: number[]) => { 7 + const [rs, gs, bs] = rgb.map((v) => { 8 8 const val = v / 255; 9 9 return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); 10 10 }); ··· 17 17 return l1 > l2 ? l1 / l2 : l2 / l1; 18 18 }; 19 19 20 + const cache = new Map<string, { bg: string; text: string; dark: boolean }>(); 21 + 22 + const apply = (bg: string, text: string, dark: boolean) => { 23 + const style = document.documentElement.style; 24 + style.setProperty("--bg", bg); 25 + style.setProperty("--text", text); 26 + document.documentElement.style.colorScheme = dark ? "dark" : "light"; 27 + }; 28 + 29 + const clear = () => { 30 + const style = document.documentElement.style; 31 + style.removeProperty("--bg"); 32 + style.removeProperty("--text"); 33 + document.documentElement.style.colorScheme = ""; 34 + }; 35 + 20 36 export const initTheme = () => { 21 37 $effect.root(() => { 22 38 $effect(() => { 23 39 const track = player.track; 24 - if (!settings.dynamicColors || !track) { 25 - document.documentElement.style.removeProperty("--bg"); 26 - document.documentElement.style.removeProperty("--text"); 27 - document.documentElement.style.colorScheme = ""; 28 - return; 29 - } 40 + if (!settings.dynamicColors || !track) return clear(); 41 + 42 + const id = track.albumId || track.coverArt || track.id; 43 + const cached = cache.get(id); 44 + if (cached) return apply(cached.bg, cached.text, cached.dark); 30 45 31 46 const img = new Image(); 32 47 img.crossOrigin = "Anonymous"; 33 - img.src = api.art(track.albumId || track.coverArt || track.id, 512); 48 + img.src = api.art(id, 512); 34 49 img.onload = async () => { 35 50 if (!settings.dynamicColors || player.track?.id !== track.id) return; 36 51 try { ··· 39 54 if (!dominant) return; 40 55 41 56 const bg = dominant.array(); 57 + let text: number[] | null = null; 42 58 43 - const roles: (keyof typeof swatches)[] = [ 59 + for (const role of [ 44 60 "Vibrant", 45 61 "Muted", 46 62 "DarkVibrant", 47 63 "DarkMuted", 48 64 "LightVibrant", 49 65 "LightMuted", 50 - ]; 51 - 52 - let text: number[] | null = null; 53 - for (const role of roles) { 54 - const s = swatches[role]; 55 - if (s && getContrast(bg, s.color.array()) >= 4.5) { 56 - text = s.color.array(); 66 + ] as const) { 67 + const swatch = swatches[role]; 68 + if (swatch && getContrast(bg, swatch.color.array()) >= 4.5) { 69 + text = swatch.color.array(); 57 70 break; 58 71 } 59 72 } 60 73 61 74 if (!text) text = dominant.contrast.foreground.array(); 62 75 63 - const toRgb = (c: number[]) => `rgb(${c[0]},${c[1]},${c[2]})`; 64 - document.documentElement.style.setProperty("--bg", toRgb(bg)); 65 - document.documentElement.style.setProperty("--text", toRgb(text)); 66 - document.documentElement.style.colorScheme = dominant.isDark 67 - ? "dark" 68 - : "light"; 76 + const theme = { 77 + bg: `rgb(${bg[0]},${bg[1]},${bg[2]})`, 78 + text: `rgb(${text[0]},${text[1]},${text[2]})`, 79 + dark: dominant.isDark, 80 + }; 81 + 82 + cache.set(id, theme); 83 + apply(theme.bg, theme.text, theme.dark); 69 84 } catch (e) { 70 - console.error("Color extraction failed", e); 85 + console.error("Theme fail", e); 71 86 } 72 87 }; 73 88 });
+13 -1
src/lib/ui.svelte.ts
··· 1 1 export const nav = $state({ anchor: -1, head: -1 }); 2 2 export const ui = $state({ 3 3 busy: true, 4 - modalsOpen: 0, 5 4 activeContext: "none" as "queue" | "library" | "none", 6 5 showSettings: false, 7 6 showKeys: false, 8 7 exportPlaylist: false, 9 8 confirmUpdatePlaylistId: null as string | null, 10 9 confirmUpdatePlaylistName: "", 10 + renamePlaylistId: null as string | null, 11 + renamePlaylistName: "", 11 12 confirmDeletePlaylistId: null as string | null, 12 13 confirmDeletePlaylistName: "", 13 14 lastUpdatedPlaylistId: null as string | null, 15 + 16 + get modalsOpen() { 17 + return ( 18 + (this.showSettings ? 1 : 0) + 19 + (this.showKeys ? 1 : 0) + 20 + (this.exportPlaylist ? 1 : 0) + 21 + (this.confirmUpdatePlaylistId ? 1 : 0) + 22 + (this.renamePlaylistId ? 1 : 0) + 23 + (this.confirmDeletePlaylistId ? 1 : 0) 24 + ); 25 + }, 14 26 });