A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

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

feat: initial work for blur browser

+840
+1
deno.jsonc
··· 36 36 "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2", 37 37 "@std/html": "jsr:@std/html@^1.0.5", 38 38 "@vicary/debounce-microtask": "jsr:@vicary/debounce-microtask@^0.1.8", 39 + "@tanstack/virtual-core": "npm:@tanstack/virtual-core@^3.13.0", 39 40 "alien-signals": "npm:alien-signals@^3.1.2", 40 41 "bs58check": "npm:bs58check@^4.0.0", 41 42 "codemirror": "npm:codemirror@^6.0.2",
+7
src/_data/facets.json
··· 22 22 "desc": "Audio playback controller with an artwork display. Play audio from the queue, add tracks to your favourites, control the queue and volume." 23 23 }, 24 24 { 25 + "url": "themes/blur/browser/facet/index.html", 26 + "title": "Blur / Browser", 27 + "category": "Browsing", 28 + "featured": true, 29 + "desc": "Collection browser and search with favourite toggling, date grouping, and virtual scrolling." 30 + }, 31 + { 25 32 "url": "facets/data/cache-tracks/index.html", 26 33 "title": "Cache Tracks", 27 34 "kind": "prelude",
+67
src/components/orchestrator/scoped-tracks/element.js
··· 74 74 #tracksSearch = signal(/** @type {Track[]} */ ([])); 75 75 #tracksFinal = signal(/** @type {Track[]} */ ([])); 76 76 77 + #tracksGrouped = computed(() => { 78 + const tracks = this.#tracksFinal.value; 79 + const groupBy = this.#scope.value?.groupBy(); 80 + if (!groupBy) return undefined; 81 + return buildGroups(tracks, groupBy); 82 + }); 83 + 77 84 // STATE 78 85 79 86 supplyFingerprint = this.#supplyFingerprint.get; 80 87 tracks = this.#tracksFinal.get; 88 + groups = this.#tracksGrouped.get; 81 89 82 90 // LIFECYCLE 83 91 ··· 274 282 } 275 283 276 284 export default ScopedTracksOrchestrator; 285 + 286 + //////////////////////////////////////////// 287 + // HELPERS 288 + //////////////////////////////////////////// 289 + 290 + const MONTHS = [ 291 + "January", "February", "March", "April", "May", "June", 292 + "July", "August", "September", "October", "November", "December", 293 + ]; 294 + 295 + /** 296 + * @param {Track[]} tracks 297 + * @param {string} groupBy dot-path field, e.g. "createdAt" or "tags.artist" 298 + * @returns {{ label: string; tracks: Track[] }[]} 299 + */ 300 + function buildGroups(tracks, groupBy) { 301 + /** @type {{ label: string; tracks: Track[] }[]} */ 302 + const groups = []; 303 + let lastKey = /** @type {string | undefined} */ (undefined); 304 + let current = /** @type {{ label: string; tracks: Track[] } | undefined} */ (undefined); 305 + 306 + for (const track of tracks) { 307 + const { key, label } = groupKeyLabel(track, groupBy); 308 + 309 + if (key !== lastKey) { 310 + current = { label, tracks: [] }; 311 + groups.push(current); 312 + lastKey = key; 313 + } 314 + 315 + current?.tracks.push(track); 316 + } 317 + 318 + return groups; 319 + } 320 + 321 + /** 322 + * @param {Track} track 323 + * @param {string} fieldPath 324 + * @returns {{ key: string; label: string }} 325 + */ 326 + function groupKeyLabel(track, fieldPath) { 327 + if (fieldPath === "createdAt") { 328 + const iso = track.createdAt; 329 + if (!iso) return { key: "unknown", label: "Unknown" }; 330 + const year = iso.slice(0, 4); 331 + const month = iso.slice(5, 7); 332 + return { 333 + key: `${year}-${month}`, 334 + label: `${MONTHS[parseInt(month, 10) - 1]} ${year}`, 335 + }; 336 + } 337 + 338 + // Generic dot-path extraction 339 + let val = /** @type {any} */ (track); 340 + for (const key of fieldPath.split(".")) val = val?.[key]; 341 + const str = val != null ? String(val) : "Unknown"; 342 + return { key: str, label: str }; 343 + } 277 344 278 345 //////////////////////////////////////////// 279 346 // REGISTER
+245
src/themes/blur/browser/element.css
··· 1 + :host { 2 + display: flex; 3 + flex-direction: column; 4 + font-size: var(--fs-sm); 5 + height: 100%; 6 + overflow: hidden; 7 + } 8 + 9 + /*********************************** 10 + * Toolbar 11 + ***********************************/ 12 + 13 + .toolbar { 14 + align-items: center; 15 + border-bottom: 1px solid color-mix(in oklch, currentColor 12%, transparent); 16 + display: flex; 17 + gap: var(--space-sm); 18 + padding: var(--space-xs); 19 + } 20 + 21 + .search-field { 22 + align-items: center; 23 + color: color-mix(in oklch, currentColor 45%, transparent); 24 + display: flex; 25 + flex: 1; 26 + gap: var(--space-2xs); 27 + } 28 + 29 + .search-field i { 30 + flex-shrink: 0; 31 + font-size: var(--fs-sm); 32 + } 33 + 34 + .search-field input { 35 + background: transparent; 36 + border: none; 37 + color: inherit; 38 + flex: 1; 39 + font-size: var(--fs-sm); 40 + min-width: 0; 41 + outline: none; 42 + } 43 + 44 + .search-field input::placeholder { 45 + color: color-mix(in oklch, currentColor 60%, transparent); 46 + } 47 + 48 + .search-field input:focus { 49 + color: currentColor; 50 + } 51 + 52 + .toolbar-actions { 53 + align-items: center; 54 + display: flex; 55 + gap: var(--space-2xs); 56 + } 57 + 58 + .toolbar-actions select { 59 + background: transparent; 60 + border: 1px solid color-mix(in oklch, currentColor 20%, transparent); 61 + border-radius: var(--radius-md); 62 + color: inherit; 63 + font-size: var(--fs-xs); 64 + max-width: 12rem; 65 + padding: var(--space-3xs) var(--space-xs); 66 + } 67 + 68 + /*********************************** 69 + * Table header 70 + ***********************************/ 71 + 72 + .table-header { 73 + border-bottom: 1px solid color-mix(in oklch, currentColor 12%, transparent); 74 + color: color-mix(in oklch, currentColor 50%, transparent); 75 + display: flex; 76 + font-size: var(--fs-xs); 77 + font-weight: 500; 78 + letter-spacing: var(--tracking-wide); 79 + padding: 0 var(--space-xs); 80 + text-transform: uppercase; 81 + user-select: none; 82 + } 83 + 84 + .table-header > div { 85 + align-items: center; 86 + cursor: pointer; 87 + display: flex; 88 + gap: var(--space-3xs); 89 + padding: var(--space-2xs) var(--space-2xs) var(--space-2xs) 0; 90 + } 91 + 92 + .table-header > div:hover { 93 + color: currentColor; 94 + } 95 + 96 + .table-header .col--sorted { 97 + color: color-mix(in oklch, currentColor 80%, transparent); 98 + } 99 + 100 + /*********************************** 101 + * Column widths 102 + ***********************************/ 103 + 104 + .col-fav { 105 + flex-shrink: 0; 106 + width: 3rem; 107 + } 108 + 109 + .col-title { 110 + flex: 2; 111 + min-width: 0; 112 + } 113 + 114 + .col-artist { 115 + flex: 1.5; 116 + min-width: 0; 117 + } 118 + 119 + .col-album { 120 + flex: 1.5; 121 + min-width: 0; 122 + } 123 + 124 + /*********************************** 125 + * Scroll panel 126 + ***********************************/ 127 + 128 + .scroll-panel { 129 + flex: 1; 130 + overflow-y: auto; 131 + min-height: 0; 132 + } 133 + 134 + .virtual-scroll { 135 + position: relative; 136 + } 137 + 138 + /*********************************** 139 + * Group header 140 + ***********************************/ 141 + 142 + .group-header { 143 + align-items: center; 144 + color: color-mix(in oklch, currentColor 45%, transparent); 145 + height: 36px; 146 + left: 0; 147 + position: absolute; 148 + right: 0; 149 + display: flex; 150 + font-size: var(--fs-xs); 151 + font-weight: 500; 152 + gap: var(--space-2xs); 153 + letter-spacing: var(--tracking-wide); 154 + padding: 0 var(--space-xs); 155 + text-transform: uppercase; 156 + } 157 + 158 + .group-header i { 159 + font-size: var(--fs-sm); 160 + } 161 + 162 + /*********************************** 163 + * Track row 164 + ***********************************/ 165 + 166 + .track-row { 167 + align-items: center; 168 + border-bottom: 1px solid color-mix(in oklch, currentColor 6%, transparent); 169 + cursor: pointer; 170 + display: flex; 171 + height: 44px; 172 + left: 0; 173 + position: absolute; 174 + right: 0; 175 + padding: 0 var(--space-xs); 176 + transition: background-color 80ms; 177 + } 178 + 179 + .track-row:hover { 180 + background-color: color-mix(in oklch, currentColor 4%, transparent); 181 + } 182 + 183 + .track-row > div { 184 + overflow: hidden; 185 + } 186 + 187 + .track-row .col-fav { 188 + align-items: center; 189 + display: flex; 190 + flex-shrink: 0; 191 + } 192 + 193 + .track-row .col-title, 194 + .track-row .col-artist, 195 + .track-row .col-album { 196 + padding-right: var(--space-sm); 197 + } 198 + 199 + .track-row span { 200 + display: block; 201 + overflow: hidden; 202 + text-overflow: ellipsis; 203 + white-space: nowrap; 204 + } 205 + 206 + .track-row--playing .track-title { 207 + font-weight: 600; 208 + } 209 + 210 + /*********************************** 211 + * Favourite button 212 + ***********************************/ 213 + 214 + .fav-btn { 215 + background: none; 216 + border: none; 217 + color: color-mix(in oklch, currentColor 20%, transparent); 218 + cursor: pointer; 219 + font-size: 90%; 220 + line-height: 0; 221 + padding: var(--space-2xs); 222 + padding-left: calc(var(--space-xs) - var(--space-2xs)); 223 + } 224 + 225 + .fav-btn:hover { 226 + color: color-mix(in oklch, currentColor 60%, transparent); 227 + } 228 + 229 + .fav-btn--active { 230 + color: oklch(0.55 0.2 25); 231 + } 232 + 233 + .fav-btn--active:hover { 234 + color: oklch(0.45 0.2 25); 235 + } 236 + 237 + /*********************************** 238 + * Loading 239 + ***********************************/ 240 + 241 + .loading { 242 + color: color-mix(in oklch, currentColor 40%, transparent); 243 + font-size: var(--fs-sm); 244 + padding: var(--space-lg) var(--space-md); 245 + }
+477
src/themes/blur/browser/element.js
··· 1 + import { 2 + defineElement, 3 + DiffuseElement, 4 + query, 5 + whenElementsDefined, 6 + } from "~/common/element.js"; 7 + import { computed, signal, untracked } from "~/common/signal.js"; 8 + import * as Playlist from "~/common/playlist.js"; 9 + import { 10 + elementScroll, 11 + observeElementOffset, 12 + observeElementRect, 13 + Virtualizer, 14 + } from "@tanstack/virtual-core"; 15 + 16 + /** 17 + * @import {RenderArg} from "~/common/element.d.ts" 18 + * @import {SignalReader} from "~/common/signal.d.ts"; 19 + * @import {Track} from "~/definitions/types.d.ts" 20 + * @import {OutputElement} from "~/components/output/types.d.ts" 21 + */ 22 + 23 + const TRACK_ROW_HEIGHT = 44; 24 + const GROUP_HEADER_HEIGHT = 36; 25 + const OVERSCAN = 10; 26 + 27 + /** @type {Record<string, string[]>} */ 28 + const COLUMN_SORT = { 29 + title: ["tags.title"], 30 + artist: ["tags.artist", "tags.album", "tags.disc.no", "tags.track.no"], 31 + album: ["tags.album", "tags.disc.no", "tags.track.no"], 32 + }; 33 + 34 + const DEFAULT_SORT = ["createdAt"]; 35 + 36 + /** 37 + * @typedef {{ type: "group"; label: string }} GroupItem 38 + * @typedef {{ type: "track"; track: Track }} TrackItem 39 + * @typedef {GroupItem | TrackItem} VirtualItem 40 + */ 41 + 42 + class Browser extends DiffuseElement { 43 + constructor() { 44 + super(); 45 + this.attachShadow({ mode: "open" }); 46 + } 47 + 48 + // SIGNALS 49 + 50 + $output = signal( 51 + /** @type {OutputElement | undefined} */ (undefined), 52 + ); 53 + 54 + $provider = signal( 55 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> } | undefined} */ (undefined), 56 + ); 57 + 58 + $queue = signal( 59 + /** @type {import("~/components/engine/queue/element.js").CLASS | undefined} */ (undefined), 60 + ); 61 + 62 + $scope = signal( 63 + /** @type {import("~/components/engine/scope/element.js").CLASS | undefined} */ (undefined), 64 + ); 65 + 66 + $favourites = signal( 67 + /** @type {import("~/components/orchestrator/favourites/element.js").CLASS | undefined} */ (undefined), 68 + ); 69 + 70 + $groupedPlaylists = computed(() => { 71 + const col = this.$output.value?.playlistItems.collection(); 72 + if (!col || col.state !== "loaded" || !col.data.length) return []; 73 + const items = col.data; 74 + 75 + /** @type {Map<string, { name: string, unordered: boolean }>} */ 76 + const playlistMap = Playlist.gather(items); 77 + 78 + const all = [...playlistMap.values()].sort((a, b) => 79 + a.name.localeCompare(b.name) 80 + ); 81 + 82 + const ordered = all.filter((p) => !p.unordered); 83 + const unordered = all.filter((p) => p.unordered); 84 + 85 + return [ 86 + { label: "Ordered", playlists: ordered }, 87 + { label: "Unordered", playlists: unordered }, 88 + ].filter((g) => g.playlists.length > 0); 89 + }); 90 + 91 + // STATE 92 + 93 + /** @type {VirtualItem[]} */ 94 + #flatItems = []; 95 + 96 + /** @type {Track[] | undefined} */ 97 + #lastTracks = undefined; 98 + 99 + /** @type {{ label: string; tracks: Track[] }[] | undefined} */ 100 + #lastGroups = undefined; 101 + 102 + /** @type {Virtualizer<Element, Element> | undefined} */ 103 + #virtualizer; 104 + 105 + /** @type {(() => void) | undefined} */ 106 + #virtualizerCleanup; 107 + 108 + // LIFECYCLE 109 + 110 + /** 111 + * @override 112 + */ 113 + connectedCallback() { 114 + super.connectedCallback(); 115 + 116 + /** @type {OutputElement} */ 117 + const output = query(this, "output-selector"); 118 + 119 + /** @type {DiffuseElement & { tracks: SignalReader<Track[]> }} */ 120 + const provider = query(this, "tracks-selector"); 121 + 122 + /** @type {import("~/components/engine/queue/element.js").CLASS} */ 123 + const queue = query(this, "queue-engine-selector"); 124 + 125 + /** @type {import("~/components/engine/scope/element.js").CLASS} */ 126 + const scope = query(this, "scope-engine-selector"); 127 + 128 + /** @type {import("~/components/orchestrator/favourites/element.js").CLASS} */ 129 + const favourites = query(this, "favourites-orchestrator-selector"); 130 + 131 + whenElementsDefined({ output, provider, queue, scope, favourites }).then(() => { 132 + this.$output.value = output; 133 + this.$provider.value = provider; 134 + this.$queue.value = queue; 135 + this.$scope.value = scope; 136 + this.$favourites.value = favourites; 137 + }); 138 + 139 + // Reset scroll when track list changes 140 + this.effect(() => { 141 + const _results = this.$provider.value?.tracks(); 142 + 143 + untracked(() => { 144 + const panel = this.root().querySelector(".scroll-panel"); 145 + if (panel) panel.scrollTo(0, 0); 146 + }); 147 + }); 148 + 149 + // Sync playlist select 150 + this.effect(() => { 151 + const playlist = this.$scope.value?.playlist(); 152 + const select = this.root().querySelector("#playlist-select"); 153 + if (select) { 154 + /** @type {HTMLSelectElement} */ (select).value = playlist ?? ""; 155 + } 156 + }); 157 + 158 + // Set up the virtualizer after the first render, when .scroll-panel exists in the DOM. 159 + // This mirrors the winamp browser's #setupScrollTracking pattern. 160 + requestAnimationFrame(() => { 161 + const panel = this.root().querySelector(".scroll-panel"); 162 + if (!panel) return; 163 + 164 + this.#virtualizer = new Virtualizer({ 165 + count: 0, 166 + getScrollElement: () => panel, 167 + estimateSize: (i) => 168 + this.#flatItems[i]?.type === "group" 169 + ? GROUP_HEADER_HEIGHT 170 + : TRACK_ROW_HEIGHT, 171 + overscan: OVERSCAN, 172 + observeElementRect, 173 + observeElementOffset, 174 + scrollToFn: elementScroll, 175 + onChange: () => { 176 + requestAnimationFrame(() => this.forceRender()); 177 + }, 178 + }); 179 + 180 + this.#virtualizerCleanup = this.#virtualizer._didMount(); 181 + this.#virtualizer._willUpdate(); 182 + 183 + // Render now that the virtualizer is wired up 184 + this.forceRender(); 185 + }); 186 + } 187 + 188 + /** 189 + * @override 190 + */ 191 + disconnectedCallback() { 192 + super.disconnectedCallback(); 193 + this.#virtualizerCleanup?.(); 194 + this.#virtualizerCleanup = undefined; 195 + this.#virtualizer = undefined; 196 + } 197 + 198 + // EVENTS 199 + 200 + /** 201 + * @param {Track} track 202 + */ 203 + playTrack(track) { 204 + this.$queue.value?.add({ inFront: true, trackIds: [track.id] }); 205 + this.$queue.value?.shift(); 206 + } 207 + 208 + setSearchTerm = () => { 209 + /** @type {HTMLInputElement | null} */ 210 + const input = this.root().querySelector("#search-input"); 211 + const term = input?.value?.trim(); 212 + this.$scope.value?.setSearchTerm(term); 213 + }; 214 + 215 + /** 216 + * @param {Event} event 217 + */ 218 + setSelectedPlaylist = (event) => { 219 + const value = /** @type {HTMLSelectElement} */ (event.currentTarget).value; 220 + this.$scope.value?.setPlaylist(value === "" ? undefined : value); 221 + }; 222 + 223 + /** 224 + * @param {string} column 225 + */ 226 + sortByColumn = (column) => { 227 + const scope = this.$scope.value; 228 + if (!scope) return; 229 + 230 + const isActive = JSON.stringify(COLUMN_SORT[column]) === 231 + JSON.stringify(scope.sortBy()); 232 + 233 + if (isActive) { 234 + if (scope.sortDirection() === "desc") { 235 + scope.revertToDefaultSort(); 236 + } else { 237 + scope.setSortDirection("desc"); 238 + } 239 + } else { 240 + scope.setSortBy(COLUMN_SORT[column] ?? []); 241 + scope.setSortDirection(undefined); 242 + } 243 + }; 244 + 245 + /** 246 + * @param {Track} track 247 + */ 248 + toggleFavourite = (track) => { 249 + this.$favourites.value?.toggle(track); 250 + }; 251 + 252 + // RENDER 253 + 254 + /** 255 + * @param {RenderArg} _ 256 + */ 257 + render({ html }) { 258 + const isLoading = 259 + this.$output.value?.tracks?.collection().state !== "loaded"; 260 + 261 + const tracks = this.$provider.value?.tracks() ?? []; 262 + const playlist = this.$scope.value?.playlist(); 263 + const searchTerm = this.$scope.value?.searchTerm() ?? ""; 264 + const sortBy = this.$scope.value?.sortBy() ?? DEFAULT_SORT; 265 + const sortDirection = this.$scope.value?.sortDirection(); 266 + 267 + const sortedColumn = Object.entries(COLUMN_SORT).find( 268 + ([, v]) => JSON.stringify(v) === JSON.stringify(sortBy), 269 + )?.[0]; 270 + 271 + const ariaSort = /** @param {string} col */ (col) => 272 + sortedColumn === col 273 + ? (sortDirection === "desc" ? "descending" : "ascending") 274 + : "none"; 275 + 276 + const groups = /** @type {any} */ (this.$provider.value)?.groups?.(); 277 + 278 + // Rebuild flat items only when data reference changes 279 + if (groups !== this.#lastGroups || tracks !== this.#lastTracks) { 280 + this.#flatItems = groups ? buildFlatList(groups) : []; 281 + this.#lastGroups = groups; 282 + this.#lastTracks = tracks; 283 + } 284 + 285 + const count = groups ? this.#flatItems.length : tracks.length; 286 + 287 + // Update virtualizer count whenever data changes. 288 + // The virtualizer is set up in connectedCallback after first render; 289 + // until then virtualItems is empty and totalSize is 0. 290 + if (this.#virtualizer) { 291 + this.#virtualizer.setOptions({ 292 + ...this.#virtualizer.options, 293 + count, 294 + }); 295 + this.#virtualizer._willUpdate(); 296 + } 297 + 298 + const virtualItems = this.#virtualizer?.getVirtualItems() ?? []; 299 + const totalSize = this.#virtualizer?.getTotalSize() ?? 0; 300 + 301 + // Build O(1) favourite lookup — one subscription regardless of visible row count 302 + const favItems = this.$favourites.value?.playlistItems() ?? []; 303 + const favSet = new Set( 304 + favItems.map((item) => { 305 + const a = item.criteria.find((c) => c.field === "tags.artist"); 306 + const t = item.criteria.find((c) => c.field === "tags.title"); 307 + return `${String(a?.value ?? "").toLowerCase()}|${String(t?.value ?? "").toLowerCase()}`; 308 + }), 309 + ); 310 + 311 + /** 312 + * @param {Track} track 313 + * @param {number} top 314 + */ 315 + const renderTrackRow = (track, top) => { 316 + const key = `${String(track.tags?.artist ?? "").toLowerCase()}|${String(track.tags?.title ?? "").toLowerCase()}`; 317 + const isFav = favSet.has(key); 318 + return html` 319 + <div 320 + class="track-row" 321 + style="transform: translateY(${top}px);" 322 + @dblclick="${() => this.playTrack(track)}" 323 + > 324 + <div class="col-fav"> 325 + <button 326 + class="fav-btn ${isFav ? `fav-btn--active` : ``}" 327 + @click="${(/** @type {Event} */ e) => { e.stopPropagation(); this.toggleFavourite(track); }}" 328 + title="${isFav ? `Remove from favourites` : `Add to favourites`}" 329 + > 330 + <i class="ph-${isFav ? `fill ph-heart` : `bold ph-heart`}"></i> 331 + </button> 332 + </div> 333 + <div class="col-title"> 334 + <span class="track-title">${track.tags?.title}</span> 335 + </div> 336 + <div class="col-artist"> 337 + <span>${track.tags?.artist}</span> 338 + </div> 339 + <div class="col-album"> 340 + <span>${track.tags?.album}</span> 341 + </div> 342 + </div> 343 + `; 344 + }; 345 + 346 + return html` 347 + <link rel="stylesheet" href="styles/base.css" /> 348 + <link rel="stylesheet" href="vendor/@phosphor-icons/web/bold/style.css" /> 349 + <link rel="stylesheet" href="vendor/@phosphor-icons/web/fill/style.css" /> 350 + <link rel="stylesheet" href="themes/blur/browser/element.css" /> 351 + 352 + <div class="toolbar"> 353 + <label class="search-field"> 354 + <i class="ph-bold ph-magnifying-glass"></i> 355 + <input 356 + id="search-input" 357 + type="search" 358 + placeholder="Search" 359 + @change="${this.setSearchTerm}" 360 + .value="${searchTerm}" 361 + /> 362 + </label> 363 + 364 + <div class="toolbar-actions"> 365 + <select id="playlist-select" @change="${this.setSelectedPlaylist}"> 366 + <option value="" ?selected="${!playlist || playlist === ``}">All tracks</option> 367 + ${this.$groupedPlaylists().map((group) => 368 + html` 369 + <optgroup label="${group.label}"> 370 + ${group.playlists.map((p) => 371 + html` 372 + <option value="${p.name}" ?selected="${p.name === playlist}"> 373 + ${p.name} 374 + </option> 375 + ` 376 + )} 377 + </optgroup> 378 + ` 379 + )} 380 + </select> 381 + </div> 382 + </div> 383 + 384 + <div class="table-header"> 385 + <div class="col-fav"></div> 386 + <div 387 + class="col-title ${sortedColumn === `title` ? `col--sorted` : ``}" 388 + @click="${() => this.sortByColumn(`title`)}" 389 + aria-sort="${ariaSort(`title`)}" 390 + > 391 + Title 392 + ${sortedColumn === `title` 393 + ? html`<i class="ph-bold ${sortDirection === `desc` ? `ph-caret-down` : `ph-caret-up`}"></i>` 394 + : ``} 395 + </div> 396 + <div 397 + class="col-artist ${sortedColumn === `artist` ? `col--sorted` : ``}" 398 + @click="${() => this.sortByColumn(`artist`)}" 399 + aria-sort="${ariaSort(`artist`)}" 400 + > 401 + Artist 402 + ${sortedColumn === `artist` 403 + ? html`<i class="ph-bold ${sortDirection === `desc` ? `ph-caret-down` : `ph-caret-up`}"></i>` 404 + : ``} 405 + </div> 406 + <div 407 + class="col-album ${sortedColumn === `album` ? `col--sorted` : ``}" 408 + @click="${() => this.sortByColumn(`album`)}" 409 + aria-sort="${ariaSort(`album`)}" 410 + > 411 + Album 412 + ${sortedColumn === `album` 413 + ? html`<i class="ph-bold ${sortDirection === `desc` ? `ph-caret-down` : `ph-caret-up`}"></i>` 414 + : ``} 415 + </div> 416 + </div> 417 + 418 + <div class="scroll-panel"> 419 + <div class="virtual-scroll" style="height: ${totalSize}px;"> 420 + ${isLoading 421 + ? html`<div class="loading">Loading ...</div>` 422 + : virtualItems.map((vItem) => { 423 + const item = groups 424 + ? this.#flatItems[vItem.index] 425 + : { type: /** @type {"track"} */ ("track"), track: tracks[vItem.index] }; 426 + 427 + return item?.type === "group" 428 + ? html` 429 + <div 430 + class="group-header" 431 + style="transform: translateY(${vItem.start}px);" 432 + > 433 + <i class="ph-bold ph-calendar-blank"></i> 434 + <span>${item.label}</span> 435 + </div> 436 + ` 437 + : item?.type === "track" 438 + ? renderTrackRow(item.track, vItem.start) 439 + : ``; 440 + }) 441 + } 442 + </div> 443 + </div> 444 + `; 445 + } 446 + } 447 + 448 + export default Browser; 449 + 450 + //////////////////////////////////////////// 451 + // HELPERS 452 + //////////////////////////////////////////// 453 + 454 + /** 455 + * @param {{ label: string; tracks: Track[] }[]} groups 456 + * @returns {VirtualItem[]} 457 + */ 458 + function buildFlatList(groups) { 459 + /** @type {VirtualItem[]} */ 460 + const items = []; 461 + for (const group of groups) { 462 + items.push({ type: "group", label: group.label }); 463 + for (const track of group.tracks) { 464 + items.push({ type: "track", track }); 465 + } 466 + } 467 + return items; 468 + } 469 + 470 + //////////////////////////////////////////// 471 + // REGISTER 472 + //////////////////////////////////////////// 473 + 474 + export const CLASS = Browser; 475 + export const NAME = "db-browser"; 476 + 477 + defineElement(NAME, CLASS);
+11
src/themes/blur/browser/facet/index.html
··· 1 + <style> 2 + @import "./vendor/@phosphor-icons/web/bold/style.css"; 3 + @import "./vendor/@phosphor-icons/web/fill/style.css"; 4 + @import "./styles/base.css"; 5 + 6 + db-browser { 7 + height: 100vh; 8 + } 9 + </style> 10 + 11 + <script type="module" src="themes/blur/browser/facet/index.inline.js"></script>
+32
src/themes/blur/browser/facet/index.inline.js
··· 1 + import foundation from "~/common/foundation.js"; 2 + import BrowserElement from "~/themes/blur/browser/element.js"; 3 + 4 + // Set doc title 5 + foundation.setup({ title: "Browser | Blur | Diffuse" }); 6 + 7 + const [out, que, scp, trc, fav] = await Promise.all([ 8 + foundation.orchestrator.output(), 9 + foundation.engine.queue(), 10 + foundation.engine.scope(), 11 + foundation.orchestrator.scopedTracks(), 12 + foundation.orchestrator.favourites(), 13 + ]); 14 + 15 + // Default to grouping by date added 16 + // TODO: Remove 17 + if (!scp.groupBy()) scp.setGroupBy("createdAt"); 18 + 19 + const el = new BrowserElement(); 20 + el.setAttribute("output-selector", out.selector); 21 + el.setAttribute("queue-engine-selector", que.selector); 22 + el.setAttribute("scope-engine-selector", scp.selector); 23 + el.setAttribute("tracks-selector", trc.selector); 24 + el.setAttribute("favourites-orchestrator-selector", fav.selector); 25 + 26 + (document.querySelector("#container") ?? document.body).append(el); 27 + 28 + //////////////////////////////////////////// 29 + // 🚀 30 + //////////////////////////////////////////// 31 + 32 + foundation.ready();