[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.

Implement element recycling

Instead of doing an each loop over the rows, swap out indexes from a visible_indexes array. Did not seem to improve scrolling performance :(

Kasper (May 26, 2025, 10:53 PM +0200) 3b3d769b ea118a03

+164 -112
+109 -81
src/components/TrackList.svelte
··· 258 258 type Column = { 259 259 name: string 260 260 key: 'index' | 'image' | keyof Track 261 - width?: string 261 + width: number 262 + is_pct?: true 263 + offset?: number 262 264 } 263 265 const all_columns: Column[] = [ 264 266 // sorted alphabetically 265 - { name: '#', key: 'index' }, 267 + { name: '#', key: 'index', width: 46 }, 266 268 // { name: 'Size', key: 'size' }, 267 - { name: 'Album', key: 'albumName', width: '90%' }, 268 - { name: 'Album Artist', key: 'albumArtist', width: '90%' }, 269 - { name: 'Artist', key: 'artist', width: '120%' }, 269 + { name: 'Album', key: 'albumName', width: 0.9, is_pct: true }, 270 + { name: 'Album Artist', key: 'albumArtist', width: 0.9, is_pct: true }, 271 + { name: 'Artist', key: 'artist', width: 1.2, is_pct: true }, 270 272 // { name: 'Bitrate', key: 'bitrate' }, 271 - { name: 'BPM', key: 'bpm' }, 272 - { name: 'Comments', key: 'comments', width: '65%' }, 273 + { name: 'BPM', key: 'bpm', width: 43 }, 274 + { name: 'Comments', key: 'comments', width: 0.65, is_pct: true }, 273 275 // { name: 'Compilation', key: 'compilation' }, 274 - { name: 'Composer', key: 'composer', width: '65%' }, 275 - { name: 'Date Added', key: 'dateAdded' }, 276 + { name: 'Composer', key: 'composer', width: 0.65, is_pct: true }, 277 + { name: 'Date Added', key: 'dateAdded', width: 140 }, 276 278 // { name: 'DateImported', key: 'dateImported' }, 277 279 // { name: 'DateModified', key: 'dateModified' }, 278 280 // { name: 'Disabled', key: 'disabled' }, 279 281 // { name: 'DiscCount', key: 'discCount' }, 280 282 // { name: 'DiscNum', key: 'discNum' }, 281 283 // { name: 'Disliked', key: 'disliked' }, 282 - { name: 'Time', key: 'duration' }, 283 - { name: 'Genre', key: 'genre', width: '65%' }, 284 - { name: 'Grouping', key: 'grouping', width: '65%' }, 285 - { name: 'Image', key: 'image' }, 284 + { name: 'Time', key: 'duration', width: 50 }, 285 + { name: 'Genre', key: 'genre', width: 0.65, is_pct: true }, 286 + { name: 'Grouping', key: 'grouping', width: 0.65, is_pct: true }, 287 + { name: 'Image', key: 'image', width: 28 }, 286 288 // { name: 'ImportedFrom', key: 'importedFrom' }, 287 289 // { name: 'Liked', key: 'liked' }, 288 - { name: 'Name', key: 'name', width: '170%' }, 289 - { name: 'Plays', key: 'playCount' }, 290 + { name: 'Name', key: 'name', width: 1.7, is_pct: true }, 291 + { name: 'Plays', key: 'playCount', width: 52 }, 290 292 // { name: 'Rating', key: 'rating' }, 291 293 // { name: 'SampleRate', key: 'sampleRate' }, 292 - { name: 'Skips', key: 'skipCount' }, 293 - // { name: 'Sort Album', key: 'sortAlbumName', width: '65%' }, 294 - // { name: 'Sort Album Artist', key: 'sortAlbumArtist', width: '65%' }, 295 - // { name: 'Sort Artist', key: 'sortArtist', width: '65%' }, 296 - // { name: 'Sort Composer', key: 'sortComposer', width: '65%' }, 297 - // { name: 'Sort Name', key: 'sortName', width: '65%' }, 294 + { name: 'Skips', key: 'skipCount', width: 52 }, 295 + // { name: 'Sort Album', key: 'sortAlbumName', width: 0.65, is_pct: true }, 296 + // { name: 'Sort Album Artist', key: 'sortAlbumArtist', width: 0.65, is_pct: true }, 297 + // { name: 'Sort Artist', key: 'sortArtist', width: 0.65, is_pct: true }, 298 + // { name: 'Sort Composer', key: 'sortComposer', width: 0.65, is_pct: true }, 299 + // { name: 'Sort Name', key: 'sortName', width: 0.65, is_pct: true }, 298 300 // { name: 'TrackCount', key: 'trackCount' }, 299 301 // { name: 'TrackNum', key: 'trackNum' }, 300 302 // { name: 'Volume', key: 'volume' }, 301 - { name: 'Year', key: 'year' }, 303 + { name: 'Year', key: 'year', width: 47 }, 302 304 ] 303 305 const default_columns: Column['key'][] = [ 304 306 'index', ··· 314 316 'year', 315 317 ] 316 318 let columns: Column[] = load_columns() 319 + onMount(() => { 320 + columns = load_columns() 321 + }) 317 322 function load_columns(): Column[] { 318 323 let loaded_columns = view_options.columns 319 324 if (loaded_columns.length === 0) { 320 325 loaded_columns = [...default_columns] 321 326 } 322 - return loaded_columns 327 + const columns = loaded_columns 323 328 .map((key) => all_columns.find((col) => col.key === key)) 324 329 .filter((col) => col !== undefined) 330 + const total_fixed_width = columns.reduce((sum, col) => sum + (col.is_pct ? 0 : col.width), 0) 331 + const total_percent_pct = columns.reduce((sum, col) => sum + (col.is_pct ? col.width : 0), 0) 332 + const container_width = tracklist_element?.clientWidth ?? total_fixed_width 333 + const total_percent_width = container_width - total_fixed_width 334 + let offset = 0 335 + return columns.map((col) => { 336 + col = { ...col } 337 + if (col.is_pct) { 338 + const pct = col.width / total_percent_pct 339 + col.width = pct * total_percent_width 340 + } 341 + col.offset = offset 342 + offset += col.width 343 + return col 344 + }) 325 345 } 326 346 function save_columns() { 327 347 view_options.columns = columns.map((col) => col.key) ··· 425 445 <div 426 446 class="c {column.key}" 427 447 class:sort={$sort_key === column.key} 428 - style:width={column.width} 448 + style:width="{column.width}px" 449 + style:translate="{column.offset}px 0" 429 450 role="button" 430 451 on:click={() => { 431 452 if (tracks_page.playlistKind === 'special' && column.key === 'index') { ··· 468 489 <VirtualListBlock 469 490 bind:this={virtual_list} 470 491 items={tracks_page.itemIds} 471 - get_key={(item) => item} 472 492 item_height={24} 473 493 {scroll_container} 474 - let:item={item_id} 475 - let:i 494 + let:visible_indexes 476 495 buffer={5} 477 496 > 478 - {@const { id: track_id, track } = get_item(item_id)} 479 - {#if track !== null} 480 - <!-- svelte-ignore a11y-click-events-have-key-events --> 481 - <!-- svelte-ignore a11y-interactive-supports-focus --> 482 - <div 483 - class="row" 484 - role="row" 485 - on:dblclick={(e) => double_click(e, i)} 486 - on:mousedown={(e) => selection.handle_mousedown(e, i)} 487 - on:contextmenu={(e) => selection.handle_contextmenu(e, i)} 488 - on:click={(e) => selection.handle_click(e, i)} 489 - draggable="true" 490 - on:dragstart={on_drag_start} 491 - on:dragover={(e) => on_drag_over(e, i)} 492 - on:drop={drop_handler} 493 - on:dragend={drag_end_handler} 494 - class:odd={i % 2 === 0} 495 - class:selected={$selection.has(item_id)} 496 - class:playing={track_id === $playing_id} 497 - > 498 - {#each columns as column} 499 - <div class="c {column.key}" style:width={column.width}> 500 - {#if column.key === 'index'} 501 - {#if track_id === $playing_id} 502 - <svg 503 - class="playing-icon inline" 504 - xmlns="http://www.w3.org/2000/svg" 505 - height="24" 506 - viewBox="0 0 24 24" 507 - width="24" 508 - > 509 - <path d="M0 0h24v24H0z" fill="none" /> 510 - <path 511 - 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" 512 - /> 513 - </svg> 497 + {#each visible_indexes as i (tracks_page.itemIds[i])} 498 + {@const item_id = tracks_page.itemIds[i]} 499 + {@const { id: track_id, track } = get_item(item_id)} 500 + {#if track !== null} 501 + <!-- svelte-ignore a11y-click-events-have-key-events --> 502 + <!-- svelte-ignore a11y-interactive-supports-focus --> 503 + <div 504 + class="row" 505 + role="row" 506 + style:translate="0 {i * 24}px" 507 + on:dblclick={(e) => double_click(e, i)} 508 + on:mousedown={(e) => selection.handle_mousedown(e, i)} 509 + on:contextmenu={(e) => selection.handle_contextmenu(e, i)} 510 + on:click={(e) => selection.handle_click(e, i)} 511 + draggable="true" 512 + on:dragstart={on_drag_start} 513 + on:dragover={(e) => on_drag_over(e, i)} 514 + on:drop={drop_handler} 515 + on:dragend={drag_end_handler} 516 + class:odd={i % 2 === 0} 517 + class:selected={$selection.has(item_id)} 518 + class:playing={track_id === $playing_id} 519 + > 520 + {#each columns as column} 521 + <div 522 + class="c {column.key}" 523 + style:width="{column.width}px" 524 + style:translate="{column.offset}px 0" 525 + > 526 + {#if column.key === 'index'} 527 + {#if track_id === $playing_id} 528 + <svg 529 + class="playing-icon inline" 530 + xmlns="http://www.w3.org/2000/svg" 531 + height="24" 532 + viewBox="0 0 24 24" 533 + width="24" 534 + > 535 + <path d="M0 0h24v24H0z" fill="none" /> 536 + <path 537 + 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" 538 + /> 539 + </svg> 540 + {:else} 541 + {i + 1} 542 + {/if} 543 + {:else if column.key === 'duration'} 544 + {track.duration ? get_duration(track.duration) : ''} 545 + {:else if column.key === 'dateAdded'} 546 + {format_date(track.dateAdded)} 547 + {:else if column.key === 'image'} 548 + <Cover {track} /> 514 549 {:else} 515 - {i + 1} 550 + {track[column.key] || ''} 516 551 {/if} 517 - {:else if column.key === 'duration'} 518 - {track.duration ? get_duration(track.duration) : ''} 519 - {:else if column.key === 'dateAdded'} 520 - {format_date(track.dateAdded)} 521 - {:else if column.key === 'image'} 522 - <Cover {track} /> 523 - {:else} 524 - {track[column.key] || ''} 525 - {/if} 526 - </div> 527 - {/each} 528 - </div> 529 - {/if} 552 + </div> 553 + {/each} 554 + </div> 555 + {/if} 556 + {/each} 530 557 </VirtualListBlock> 531 558 <div class="drag-line" class:hidden={drag_to_index === null} bind:this={drag_line}></div> 532 559 </div> ··· 548 575 background-color: rgba(0, 0, 0, 0.01) 549 576 overflow: hidden 550 577 .table-header 578 + position: relative 551 579 .c 552 580 overflow: visible 553 581 * ··· 562 590 &.desc .c.sort span::after 563 591 content: '▼' 564 592 .row 565 - display: flex 566 - max-width: 100% 593 + width: 100% 567 594 $row-height: 24px 568 595 height: $row-height 569 596 font-size: 12px 570 597 line-height: $row-height 571 598 box-sizing: border-box 572 - position: relative 599 + position: absolute 573 600 &.playing.selected 574 601 color: #ffffff 575 602 &.playing 576 603 color: #00ffff 577 604 .c 578 - display: inline-block 605 + display: block 606 + position: absolute 579 607 vertical-align: top 580 608 width: 100% 581 609 white-space: nowrap
+55 -31
src/components/VirtualListBlock.svelte
··· 17 17 export let item_height: number 18 18 /** Must be a positioned element, like `position: relative` */ 19 19 export let scroll_container: HTMLElement 20 - export let get_key: (item: T, i: number) => number | string 21 20 export let buffer = 3 22 21 23 22 $: height = items.length * item_height ··· 27 26 let start_pixel = 0 28 27 let start_index = 0 29 28 let visible_count = 0 30 - 31 - // Workaround for svelte not updating the indexes when the keys change 32 - let visible_count_obj = { length: visible_count } 33 - $: visible_count_obj = { length: visible_count } 29 + let visible_indexes: number[] = [] 34 30 35 31 $: { 36 32 items, item_height, buffer ··· 46 42 } 47 43 } 48 44 45 + let ticking = false 49 46 export function refresh() { 50 - if (!main_element || !scroll_container) { 47 + if (ticking || !main_element || !scroll_container) { 51 48 return 52 49 } 50 + ticking = true 51 + requestAnimationFrame(() => { 52 + let element_top = main_element.offsetTop 53 + let offset_parent = main_element.offsetParent 54 + while (offset_parent !== scroll_container && offset_parent instanceof HTMLElement) { 55 + element_top += offset_parent.offsetTop 56 + offset_parent = offset_parent.offsetParent 57 + } 53 58 54 - let element_top = main_element.offsetTop 55 - let offset_parent = main_element.offsetParent 56 - while (offset_parent !== scroll_container && offset_parent instanceof HTMLElement) { 57 - element_top += offset_parent.offsetTop 58 - offset_parent = offset_parent.offsetParent 59 - } 59 + const element_bottom = element_top + height 60 60 61 - const element_bottom = element_top + height 61 + // The currently visible area of the container 62 + const scroll_top = scroll_container.scrollTop - buffer_height 63 + const scroll_bottom = 64 + scroll_container.scrollTop + scroll_container.clientHeight + buffer_height 62 65 63 - // The currently visible area of the container 64 - const scroll_top = scroll_container.scrollTop - buffer_height 65 - const scroll_bottom = scroll_container.scrollTop + scroll_container.clientHeight + buffer_height 66 + // The first visible pixel 67 + start_pixel = Math.min(element_bottom, Math.max(element_top, scroll_top)) - element_top 66 68 67 - // The first visible pixel 68 - start_pixel = Math.min(element_bottom, Math.max(element_top, scroll_top)) - element_top 69 + // The last visible pixel 70 + const end_pixel = Math.max(element_top, Math.min(element_bottom, scroll_bottom)) - element_top 69 71 70 - // The last visible pixel 71 - const end_pixel = Math.max(element_top, Math.min(element_bottom, scroll_bottom)) - element_top 72 + const total_pixels = end_pixel - start_pixel 72 73 73 - start_index = Math.floor(start_pixel / item_height) 74 - visible_count = Math.ceil(end_pixel / item_height) - start_index 75 - visible_count_obj = { length: visible_count } 74 + start_index = Math.floor(start_pixel / item_height) 75 + visible_count = Math.ceil(total_pixels / item_height) 76 + const end_index = start_index + visible_count 77 + 78 + // first, figure out which new indexes are now visible 79 + const new_visible_indexes = [] 80 + for (let i = start_index; i < end_index; i++) { 81 + if (!visible_indexes.includes(i)) { 82 + new_visible_indexes.push(i) 83 + } 84 + } 85 + // then, update the visible indexes 86 + for (let i = 0; i < visible_indexes.length; i++) { 87 + // if the index is no longer visible 88 + if (visible_indexes[i] > end_index || visible_indexes[i] < start_index) { 89 + const new_index = new_visible_indexes.pop() 90 + // update it to a new visible index 91 + if (new_index !== undefined) { 92 + visible_indexes[i] = new_index 93 + } else { 94 + // if there are no new visible indexes left, remove it 95 + visible_indexes.splice(i, 1) 96 + i-- 97 + } 98 + } 99 + } 100 + // add new visible indexes 101 + visible_indexes.push(...new_visible_indexes) 102 + visible_indexes = visible_indexes 103 + 104 + ticking = false 105 + }) 76 106 } 77 107 78 108 $: apply_scroll_event_handler(scroll_container) ··· 101 131 } 102 132 </script> 103 133 104 - <div 105 - bind:this={main_element} 106 - style:padding-top={start_index * item_height + 'px'} 107 - style:height={items.length * item_height + 'px'} 108 - > 109 - {#each visible_count_obj as _, i (get_key(items[i + start_index], i + start_index))} 110 - <slot item={items[i + start_index]} i={i + start_index} /> 111 - {/each} 134 + <div bind:this={main_element} style:height={items.length * item_height + 'px'}> 135 + <slot {visible_indexes} /> 112 136 </div>