[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

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

Test customElements

Thought this would be better since the styles are isolated, but nah seems bad

Kasper (May 27, 2025, 1:31 AM +0200) bf8700f3 3275a005

+225 -78
+4 -5
src/App.svelte
··· 1 1 <script lang="ts"> 2 - import { run } from 'svelte/legacy'; 3 - 2 + import { run } from 'svelte/legacy' 4 3 import { onDestroy, onMount } from 'svelte' 5 4 import { fade } from 'svelte/transition' 6 - import TrackList from './components/TrackList.svelte' 7 5 import Player from './components/Player.svelte' 8 6 import Sidebar from './components/Sidebar.svelte' 9 7 import Queue from './components/Queue.svelte' ··· 25 23 import './lib/router' 26 24 import CheckForUpdates from './components/CheckForUpdates.svelte' 27 25 import Settings from './components/Settings.svelte' 26 + import TrackListRoute from './components/TrackListRoute.svelte' 28 27 29 28 ipc_renderer.invoke('app_loaded').catch(() => { 30 29 ipc_renderer.invoke('showMessageBox', false, { ··· 56 55 } 57 56 run(() => { 58 57 ipc_renderer.invoke('update:Show Queue', $queue_visible) 59 - }); 58 + }) 60 59 ipc_renderer.on('Show Queue', toggle_queue) 61 60 onDestroy(() => { 62 61 ipc_renderer.removeListener('Show Queue', toggle_queue) ··· 206 205 <div class="meat"> 207 206 <Sidebar /> 208 207 <div class="flex size-full min-w-0 flex-col"> 209 - <Route route="/playlist/:playlist_id" component={TrackList} /> 208 + <Route route="/playlist/:playlist_id" component={TrackListRoute} /> 210 209 <Route route="/artists" component={ArtistList} /> 211 210 </div> 212 211 {#if $queue_visible}
+6 -5
src/components/QuickNav.svelte
··· 1 1 <script lang="ts"> 2 - import { run } from 'svelte/legacy'; 2 + import { run } from 'svelte/legacy' 3 3 4 4 import { onDestroy } from 'svelte' 5 5 import { check_shortcut } from '../lib/helpers' ··· 74 74 if (show) { 75 75 playlists = get_playlists() 76 76 } 77 - }); 77 + }) 78 78 run(() => { 79 79 filtered_items = fuzzysort.go(value, playlists, { key: 'name', all: true }) 80 80 clamp_index() 81 - }); 81 + }) 82 82 run(() => { 83 - list_items, clamp_index() 84 - }); 83 + list_items 84 + clamp_index() 85 + }) 85 86 </script> 86 87 87 88 {#if show}
+206 -68
src/components/TrackList.svelte
··· 1 + <svelte:options customElement="track-list" /> 2 + 1 3 <script lang="ts" module> 2 4 export const sort_key = writable('index') 3 5 export const sort_desc = writable(true) ··· 15 17 }) 16 18 export const group_album_tracks = writable(true) 17 19 export const tracks_page_item_ids = writable<TracksPage['itemIds']>([]) 20 + 21 + export function scroll_container_keydown(e: KeyboardEvent & { currentTarget: HTMLElement }) { 22 + let prevent = true 23 + if (e.key === 'Home') e.currentTarget.scrollTop = 0 24 + else if (e.key === 'End') e.currentTarget.scrollTop = e.currentTarget.scrollHeight 25 + else if (e.key === 'PageUp') e.currentTarget.scrollTop -= e.currentTarget.clientHeight 26 + else if (e.key === 'PageDown') e.currentTarget.scrollTop += e.currentTarget.clientHeight 27 + else prevent = false 28 + if (prevent) e.preventDefault() 29 + } 18 30 </script> 19 31 20 32 <script lang="ts"> 21 - import { self } from 'svelte/legacy' 22 - 23 33 import { 24 34 filter, 25 35 move_tracks, ··· 41 51 import { onDestroy, onMount } from 'svelte' 42 52 import { dragged } from '../lib/drag-drop' 43 53 import * as dragGhost from './DragGhost.svelte' 44 - import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' 45 54 import type { ItemId, Track, TracksPage } from 'ferrum-addon/addon' 46 55 import Cover from './Cover.svelte' 47 56 import Header from './Header.svelte' ··· 259 268 } 260 269 } 261 270 262 - let virtual_list: VirtualListBlock<ItemId> = $state() 271 + let scroll_container: HTMLElement | null = $state(null) 272 + 273 + // Virtual list variables and functions (integrated from VirtualListBlock) 274 + let main_element: HTMLDivElement | null = $state(null) 275 + let start_pixel = 0 276 + let start_index = 0 277 + let visible_count = 0 278 + let visible_indexes: number[] = $state([]) 279 + let item_height = 24 280 + let buffer = 5 281 + 282 + const resize_observer = new ResizeObserver(refresh) 283 + function observe(container: HTMLElement | null) { 284 + resize_observer.disconnect() 285 + if (container) { 286 + resize_observer.observe(container) 287 + } 288 + } 289 + 290 + let frame: number | null = null 291 + function refresh() { 292 + console.log('refresh') 293 + if (frame !== null || !main_element || !scroll_container) { 294 + return 295 + } 296 + frame = requestAnimationFrame(() => { 297 + frame = null 298 + if (!main_element || !scroll_container) return 299 + let element_top = main_element.offsetTop 300 + let offset_parent = main_element.offsetParent 301 + while (offset_parent !== scroll_container && offset_parent instanceof HTMLElement) { 302 + element_top += offset_parent.offsetTop 303 + offset_parent = offset_parent.offsetParent 304 + } 305 + 306 + const element_bottom = element_top + height 307 + 308 + // The currently visible area of the container 309 + const scroll_top = scroll_container.scrollTop - buffer_height 310 + const scroll_bottom = 311 + scroll_container.scrollTop + scroll_container.clientHeight + buffer_height 312 + 313 + // The first visible pixel 314 + start_pixel = Math.min(element_bottom, Math.max(element_top, scroll_top)) - element_top 315 + 316 + // The last visible pixel 317 + const end_pixel = Math.max(element_top, Math.min(element_bottom, scroll_bottom)) - element_top 318 + 319 + const total_pixels = end_pixel - start_pixel 320 + 321 + start_index = Math.floor(start_pixel / item_height) 322 + visible_count = Math.ceil(total_pixels / item_height) 323 + const end_index = start_index + visible_count 324 + 325 + // first, figure out which new indexes are now visible 326 + const new_visible_indexes = [] 327 + for (let i = start_index; i < end_index; i++) { 328 + if (!visible_indexes.includes(i)) { 329 + new_visible_indexes.push(i) 330 + } 331 + } 332 + // then, update the visible indexes 333 + for (let i = 0; i < visible_indexes.length; i++) { 334 + // if the index is no longer visible 335 + if (visible_indexes[i] > end_index || visible_indexes[i] < start_index) { 336 + const new_index = new_visible_indexes.pop() 337 + // update it to a new visible index 338 + if (new_index !== undefined) { 339 + visible_indexes[i] = new_index 340 + } else { 341 + // if there are no new visible indexes left, remove it 342 + visible_indexes.splice(i, 1) 343 + i-- 344 + } 345 + } 346 + } 347 + // add new visible indexes 348 + visible_indexes.push(...new_visible_indexes) 349 + visible_indexes = visible_indexes 350 + console.log('visible_indexes', visible_indexes) 351 + }) 352 + } 353 + 354 + let scroll_event_element: HTMLElement | null = null 355 + function apply_scroll_event_handler(container: HTMLElement | null) { 356 + if (scroll_event_element) { 357 + scroll_event_element.removeEventListener('scroll', refresh) 358 + } 359 + scroll_event_element = container 360 + if (scroll_event_element) { 361 + scroll_event_element.addEventListener('scroll', refresh) 362 + } 363 + } 364 + 365 + function scroll_to_index(index: number, offset = 0) { 366 + if (!main_element) return 367 + 368 + const dummy = document.createElement('div') 369 + dummy.style.height = item_height + 'px' 370 + dummy.style.position = 'absolute' 371 + dummy.style.top = index * item_height + 'px' 372 + // For some reason we apply the offset to the bottom 373 + dummy.style.scrollMarginBottom = offset + 'px' 374 + // eslint-disable-next-line svelte/no-dom-manipulating 375 + main_element.prepend(dummy) 376 + dummy.scrollIntoView({ behavior: 'instant', block: 'nearest' }) 377 + dummy.remove() 378 + } 379 + 380 + let height = $derived(tracks_page.itemIds ? tracks_page.itemIds.length * item_height : 0) 381 + let buffer_height = $derived(buffer * item_height) 263 382 264 383 $effect(() => { 265 - if (virtual_list) { 266 - virtual_list.refresh() 384 + tracks_page.itemIds 385 + item_height 386 + buffer 387 + if (scroll_container && main_element) { 388 + refresh() 267 389 } 268 390 }) 269 391 270 - let scroll_container: HTMLElement = $state() 392 + $effect(() => { 393 + observe(scroll_container) 394 + }) 395 + 396 + $effect(() => { 397 + apply_scroll_event_handler(scroll_container) 398 + }) 399 + 400 + onDestroy(() => { 401 + if (scroll_event_element) { 402 + scroll_event_element.removeEventListener('scroll', refresh) 403 + } 404 + }) 405 + 271 406 onMount(() => { 272 - tracklist_actions.scroll_to_index = virtual_list.scroll_to_index 407 + tracklist_actions.scroll_to_index = scroll_to_index 273 408 }) 274 409 275 410 type Column = { ··· 444 579 <div 445 580 bind:this={tracklist_element} 446 581 class="tracklist h-full" 582 + style:height="100%" 447 583 role="table" 448 584 ondragleave={() => (drag_to_index = null)} 449 585 > 450 586 <!-- svelte-ignore a11y_interactive_supports_focus --> 451 587 <div 452 588 class="row table-header border-b border-b-slate-500/30" 589 + style:height={item_height + 'px'} 590 + style:position="absolute" 453 591 class:desc={$sort_desc} 454 592 role="row" 455 593 oncontextmenu={on_column_context_menu} ··· 497 635 <!-- svelte-ignore a11y_no_static_element_interactions --> 498 636 <div 499 637 bind:this={scroll_container} 500 - class="main-focus-element relative h-full overflow-y-auto outline-none" 638 + class="main-focus-element relative h-full outline-none" 639 + style:overflow-y="auto" 640 + style:position="relative" 641 + style:height="100%" 501 642 tabindex="0" 502 643 onmousedown={(e) => { 503 644 if (e.target === e.currentTarget) { ··· 509 650 keydown(e) 510 651 }} 511 652 > 512 - <VirtualListBlock 513 - bind:this={virtual_list} 514 - items={tracks_page.itemIds} 515 - item_height={24} 516 - {scroll_container} 517 - buffer={5} 518 - > 519 - {#snippet children({ visible_indexes })} 520 - {#each visible_indexes as i (tracks_page.itemIds[i])} 521 - {@const item_id = tracks_page.itemIds[i]} 522 - {@const { id: track_id, track } = get_item(item_id)} 523 - {#if track !== null} 524 - <!-- svelte-ignore a11y_click_events_have_key_events --> 525 - <!-- svelte-ignore a11y_interactive_supports_focus --> 526 - <div 527 - class="row" 528 - role="row" 529 - style:translate="0 {i * 24}px" 530 - ondblclick={(e) => double_click(e, i)} 531 - onmousedown={(e) => selection.handle_mousedown(e, i)} 532 - oncontextmenu={(e) => selection.handle_contextmenu(e, i)} 533 - onclick={(e) => selection.handle_click(e, i)} 534 - draggable="true" 535 - ondragstart={on_drag_start} 536 - ondragover={(e) => on_drag_over(e, i)} 537 - ondrop={drop_handler} 538 - ondragend={drag_end_handler} 539 - class:odd={i % 2 === 0} 540 - class:selected={$selection.has(item_id)} 541 - class:playing={track_id === $playing_id} 542 - > 543 - {#each columns as column} 544 - <div 545 - class={['c', column.key]} 546 - style:width="{column.width}px" 547 - style:translate="{column.offset}px 0" 548 - > 549 - {#if column.key === 'index'} 550 - {#if track_id === $playing_id} 551 - <!-- <svg 653 + <div bind:this={main_element} style:height={tracks_page.itemIds.length * item_height + 'px'}> 654 + {#each visible_indexes as i (tracks_page.itemIds[i])} 655 + {@const item_id = tracks_page.itemIds[i]} 656 + {@const { id: track_id, track } = get_item(item_id)} 657 + {#if track !== null} 658 + <!-- svelte-ignore a11y_click_events_have_key_events --> 659 + <!-- svelte-ignore a11y_interactive_supports_focus --> 660 + <div 661 + class="row" 662 + role="row" 663 + style:height={item_height + 'px'} 664 + style:width="100%" 665 + style:position="absolute" 666 + style:translate="0 {i * 24}px" 667 + ondblclick={(e) => double_click(e, i)} 668 + onmousedown={(e) => selection.handle_mousedown(e, i)} 669 + oncontextmenu={(e) => selection.handle_contextmenu(e, i)} 670 + onclick={(e) => selection.handle_click(e, i)} 671 + draggable="true" 672 + ondragstart={on_drag_start} 673 + ondragover={(e) => on_drag_over(e, i)} 674 + ondrop={drop_handler} 675 + ondragend={drag_end_handler} 676 + class:odd={i % 2 === 0} 677 + class:selected={$selection.has(item_id)} 678 + class:playing={track_id === $playing_id} 679 + > 680 + {#each columns as column} 681 + <div 682 + class={['c', column.key]} 683 + style:width="{column.width}px" 684 + style:translate="{column.offset}px 0" 685 + > 686 + {#if column.key === 'index'} 687 + {#if track_id === $playing_id} 688 + <!-- <svg 552 689 class="playing-icon inline" 553 690 xmlns="http://www.w3.org/2000/svg" 554 691 height="24" ··· 560 697 d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" 561 698 /> 562 699 </svg> --> 563 - {:else} 564 - {i + 1} 565 - {/if} 566 - {:else if column.key === 'duration'} 567 - {track.duration ? get_duration(track.duration) : ''} 568 - {:else if column.key === 'dateAdded'} 569 - {#await format_date(track.dateAdded) then value} 570 - {value} 571 - {/await} 572 - {:else if column.key === 'image'} 573 - <Cover {track} /> 574 700 {:else} 575 - {track[column.key] || ''} 701 + {i + 1} 576 702 {/if} 577 - </div> 578 - {/each} 579 - </div> 580 - {/if} 581 - {/each} 582 - {/snippet} 583 - </VirtualListBlock> 703 + {:else if column.key === 'duration'} 704 + {track.duration ? get_duration(track.duration) : ''} 705 + {:else if column.key === 'dateAdded'} 706 + {#await format_date(track.dateAdded) then value} 707 + {value} 708 + {/await} 709 + {:else if column.key === 'image'} 710 + <!-- <Cover {track} /> --> 711 + {:else} 712 + {track[column.key] || ''} 713 + {/if} 714 + </div> 715 + {/each} 716 + </div> 717 + {/if} 718 + {/each} 719 + </div> 584 720 <div class="drag-line" class:hidden={drag_to_index === null} bind:this={drag_line}></div> 585 721 </div> 586 722 </div> ··· 615 751 display: inline-block 616 752 &.desc .c.sort span::after 617 753 content: '▼' 754 + .main-focus-element 755 + overflow-y: auto 618 756 .row 619 757 width: 100% 620 758 $row-height: 24px
+5
src/components/TrackListRoute.svelte
··· 1 + <script lang="ts"> 2 + export let params: { playlist_id: string } 3 + </script> 4 + 5 + <track-list {params} style:height="100%" />
+4
website/svelte.config.js
··· 26 26 // See https://kit.svelte.dev/docs/adapters for more information about adapters. 27 27 adapter: adapter(), 28 28 }, 29 + 30 + compilerOptions: { 31 + customElement: true, 32 + }, 29 33 } 30 34 31 35 export default config