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

Configure Feed

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

at main 345 lines 8.2 kB view raw
1<script lang="ts"> 2 import { lib, t, auth } from "./app.svelte.js"; 3 import { api, asArray } from "./client.svelte.js"; 4 import { ui } from "./app.svelte.js"; 5 import TreeItem from "./TreeItem.svelte"; 6 7 let query = $state(""), 8 results = $state<any>(null); 9 10 let isArtistsExpanded = $state(true); 11 let isPlaylistsExpanded = $state(true); 12 let isSharedPlaylistsExpanded = $state(true); 13 let isSharesExpanded = $state(true); 14 15 let myPlaylists = $derived( 16 lib.playlists.filter( 17 (p) => 18 !p.owner || 19 (auth.user && p.owner.toLowerCase() === auth.user.toLowerCase()), 20 ), 21 ); 22 let sharedPlaylists = $derived( 23 lib.playlists.filter( 24 (p) => 25 p.owner && 26 auth.user && 27 p.owner.toLowerCase() !== auth.user.toLowerCase(), 28 ), 29 ); 30 31 $effect(() => { 32 if (!query.trim()) { 33 results = null; 34 return; 35 } 36 const timer = setTimeout(async () => { 37 results = (await api.search(query)) || {}; 38 }, 300); 39 return () => clearTimeout(timer); 40 }); 41 42 function onKey(e: KeyboardEvent) { 43 if ((e.target as HTMLElement).tagName === "INPUT") return; 44 const elements = Array.from( 45 document.querySelectorAll("#library [data-id]"), 46 ) as HTMLElement[]; 47 const focusedIndex = elements.findIndex((el) => 48 el.classList.contains("focused"), 49 ); 50 const { key, code, altKey: alt, shiftKey: shift } = e; 51 52 if (key === "Escape") return (e.preventDefault(), (lib.focusedId = null)); 53 54 const offsets: Record<string, number> = { 55 ArrowDown: 1, 56 ArrowUp: -1, 57 PageDown: 10, 58 PageUp: -10, 59 Home: -Infinity, 60 End: Infinity, 61 }; 62 63 if (key in offsets) { 64 e.preventDefault(); 65 const targetIndex = Math.max( 66 0, 67 Math.min(elements.length - 1, focusedIndex + offsets[key]), 68 ); 69 const next = elements[targetIndex]; 70 if (next) { 71 lib.focusedId = next.dataset.id || null; 72 next.scrollIntoView({ block: "nearest" }); 73 } 74 } else if (code === "Enter") { 75 e.preventDefault(); 76 elements[focusedIndex]?.click(); 77 } else if (code === "KeyA") { 78 e.preventDefault(); 79 elements[focusedIndex]?.dispatchEvent( 80 new CustomEvent("actionadd", { detail: alt }), 81 ); 82 } else if ( 83 key === "ContextMenu" || 84 key === "`" || 85 (key === "F10" && shift) 86 ) { 87 e.preventDefault(); 88 const el = elements[focusedIndex] as HTMLElement; 89 if (el) { 90 const rect = el.getBoundingClientRect(); 91 el.dispatchEvent( 92 new MouseEvent("contextmenu", { 93 bubbles: true, 94 cancelable: true, 95 clientX: rect.left + 50, 96 clientY: rect.bottom, 97 }), 98 ); 99 } 100 } 101 } 102</script> 103 104<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 105<!-- svelte-ignore a11y_no_noninteractive_tabindex --> 106<nav 107 id="library" 108 onfocusin={() => (ui.activeContext = "library")} 109 onkeydown={onKey} 110 onclick={(e) => { 111 if (e.target === e.currentTarget) lib.focusedId = null; 112 }} 113 tabindex="0" 114> 115 <input 116 id="search" 117 type="search" 118 bind:value={query} 119 placeholder={t("search")} 120 onfocusin={(e) => e.stopPropagation()} 121 onclick={(e) => e.stopPropagation()} 122 /> 123 <ul class="tree"> 124 {#if query && results} 125 {#each ["artist", "album", "song"] as type} 126 {@const items = asArray(results[type])} 127 {#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> 138 <ul> 139 {#each items as item, itemIndex (item.id + itemIndex)} 140 <TreeItem 141 id={item.id} 142 uid={`search-${type}-${item.id}-${itemIndex}`} 143 label={item.name || item.title} 144 fetcher={type === "artist" 145 ? () => api.artist(item.id) 146 : type === "album" 147 ? () => api.album(item.id) 148 : undefined} 149 childFactory={type === "artist" 150 ? (a: any) => () => api.album(a.id) 151 : undefined} 152 isTrack={type === "song"} 153 track={type === "song" ? item : null} 154 type={type as any} 155 {item} 156 /> 157 {/each} 158 </ul> 159 </li> 160 {/if} 161 {/each} 162 {: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} 178 <ul> 179 {#each lib.artists as item, itemIndex (item.id)} 180 <TreeItem 181 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}`} 213 label={item.name} 214 fetcher={() => api.playlist(item.id)} 215 type="playlist" 216 isReadonly={item.readonly === true} 217 owner={item.owner} 218 isPublic={item.public} 219 {item} 220 /> 221 {/each} 222 </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> 258 {/if} 259 {#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> 288 {/if} 289 {/if} 290 </ul> 291</nav> 292 293<style> 294 #library { 295 flex: 1; 296 overflow-y: auto; 297 padding-block: 0.5rem; 298 padding-inline: 1rem; 299 } 300 #search { 301 inline-size: 100%; 302 } 303 .tree { 304 padding: 0; 305 margin: 0; 306 } 307 ul { 308 padding: 0; 309 margin: 0; 310 list-style: none; 311 } 312 .label { 313 display: block; 314 inline-size: 100%; 315 text-align: start; 316 padding: 0; 317 padding-block-start: 0.5rem; 318 padding-block-end: 0.25rem; 319 background: none; 320 border: none; 321 border-block-end: 1px solid var(--border-text); 322 font-weight: bold; 323 } 324 .label.focused { 325 background: Highlight; 326 color: HighlightText; 327 } 328 :global(#library:not(:focus-within)) .label.focused { 329 background: GrayText; 330 color: HighlightText; 331 } 332 button { 333 color: inherit; 334 } 335 :global(body.dynamic) #search { 336 background-color: var(--bg-secondary); 337 color: var(--text); 338 border: 1px solid var(--border); 339 border-radius: 4px; 340 } 341 :global(body.dynamic) #search::placeholder { 342 color: var(--text); 343 opacity: 0.5; 344 } 345</style>