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

Use VirtualListBlock for TrackList

Kasper (Sep 12, 2024, 2:08 AM +0200) 4ade0346 88f9f950

+107 -226
+3 -3
src/components/Queue.svelte
··· 228 228 <VirtualListBlock 229 229 bind:this={history_list} 230 230 items={$queue.past} 231 - get_key={(i) => i.qId} 231 + get_key={(item) => item.qId} 232 232 item_height={54} 233 233 scroll_container={queue_element} 234 234 let:item ··· 288 288 <VirtualListBlock 289 289 bind:this={up_next_list} 290 290 items={$queue.userQueue} 291 - get_key={(i) => i.qId} 291 + get_key={(item) => item.qId} 292 292 item_height={54} 293 293 scroll_container={queue_element} 294 294 let:item ··· 327 327 <VirtualListBlock 328 328 bind:this={autoplay_list} 329 329 items={$queue.autoQueue} 330 - get_key={(i) => i.qId} 330 + get_key={(item) => item.qId} 331 331 item_height={54} 332 332 scroll_container={queue_element} 333 333 let:item
+3 -3
src/components/Sidebar.svelte
··· 6 6 import { writable } from 'svelte/store' 7 7 import { setContext, tick } from 'svelte' 8 8 import { dragged } from '../lib/drag-drop' 9 - import { main_area } from '@/lib/page' 9 + import { tracklist_actions } from '@/lib/page' 10 10 11 11 const special = { 12 12 children: ['root'], ··· 87 87 }} 88 88 on:keydown={(e) => { 89 89 if (e.key === 'Escape') { 90 - main_area.focus() 90 + tracklist_actions.focus() 91 91 } 92 92 }} 93 93 /> ··· 98 98 on:keydown={(e) => { 99 99 if (e.key === 'Escape') { 100 100 e.preventDefault() 101 - main_area.focus() 101 + tracklist_actions.focus() 102 102 } else if (e.key == 'Home' || e.key == 'End' || e.key == 'PageUp' || e.key == 'PageDown') { 103 103 e.preventDefault() 104 104 } else {
+83 -70
src/components/TrackList.svelte
··· 7 7 paths, 8 8 isMac, 9 9 } from '../lib/data' 10 - import VirtualList from './VirtualList.svelte' 11 10 import { newPlaybackInstance, playingId } from '../lib/player' 12 11 import { 13 12 getDuration, ··· 17 16 assertUnreachable, 18 17 } from '../lib/helpers' 19 18 import { appendToUserQueue, prependToUserQueue, queueVisible } from '../lib/queue' 20 - import { selection, scrollToIndex, main_area } from '../lib/page' 19 + import { selection, tracklist_actions } from '../lib/page' 21 20 import { ipcListen, ipcRenderer } from '../lib/window' 22 - import { onDestroy } from 'svelte' 21 + import { onDestroy, onMount } from 'svelte' 23 22 import { dragged } from '../lib/drag-drop' 24 23 import * as dragGhost from './DragGhost.svelte' 25 24 import type { TrackID } from 'ferrum-addon/addon' 26 25 import { modalCount } from './Modal.svelte' 26 + import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' 27 27 28 28 export let onTrackInfo: (allIds: TrackID[], index: number) => void 29 29 ··· 61 61 ipcRenderer.on('Group Album Tracks', (_, checked) => { 62 62 page.set_group_album_tracks(checked) 63 63 }) 64 - 65 - $: if ($scrollToIndex !== null) { 66 - virtualList.scrollToItem($scrollToIndex) 67 - } 68 64 69 65 function doubleClick(e: MouseEvent, index: number) { 70 66 if (e.button === 0 && checkMouseShortcut(e)) { ··· 194 190 } 195 191 } 196 192 197 - let virtualList: VirtualList<ReturnType<typeof getItem>> 193 + let virtual_list: VirtualListBlock<number> 198 194 199 - $: if (virtualList) { 200 - main_area.focus = virtualList.focus 195 + $: if ($page && virtual_list) { 196 + virtual_list.refresh() 201 197 } 202 198 203 - $: if ($page && virtualList) { 204 - virtualList.refresh() 205 - } 199 + let scroll_container: HTMLElement 200 + onMount(() => { 201 + tracklist_actions.scroll_to_index = (index) => { 202 + virtual_list.scroll_to_index(index) 203 + } 204 + tracklist_actions.focus = () => { 205 + scroll_container.focus() 206 + } 207 + }) 206 208 </script> 207 209 208 210 <div ··· 328 330 <span>Year</span> 329 331 </div> 330 332 </div> 331 - <VirtualList 332 - {getItem} 333 - itemHeight={24} 334 - itemCount={$page.length} 335 - bind:this={virtualList} 336 - isMain={-1} 333 + <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 334 + <!-- svelte-ignore a11y-no-static-element-interactions --> 335 + <!-- svelte-ignore a11y-autofocus --> 336 + <div 337 + bind:this={scroll_container} 338 + class="relative h-full overflow-y-auto outline-none" 337 339 on:keydown={keydown} 338 - on:mousedown-self={selection.clear} 339 - let:item={track} 340 - let:index 340 + on:mousedown|self={selection.clear} 341 + tabindex="0" 342 + autofocus 343 + on:keydown={scroll_container_keydown} 341 344 > 342 - {#if track !== null} 343 - <!-- svelte-ignore a11y-click-events-have-key-events --> 344 - <!-- svelte-ignore a11y-interactive-supports-focus --> 345 - <div 346 - class="row" 347 - role="row" 348 - on:dblclick={(e) => doubleClick(e, index)} 349 - on:mousedown={(e) => selection.handleMouseDown(e, index)} 350 - on:contextmenu={(e) => selection.handleContextMenu(e, index)} 351 - on:click={(e) => selection.handleClick(e, index)} 352 - draggable="true" 353 - on:dragstart={onDragStart} 354 - on:dragover={(e) => onDragOver(e, index)} 355 - on:drop={dropHandler} 356 - on:dragend={dragEndHandler} 357 - class:odd={index % 2 === 0} 358 - class:selected={$selection.list[index] === true} 359 - class:playing={track.id === $playingId} 360 - > 361 - <div class="c index"> 362 - {#if track.id === $playingId} 363 - <svg 364 - class="playing-icon inline" 365 - xmlns="http://www.w3.org/2000/svg" 366 - height="24" 367 - viewBox="0 0 24 24" 368 - width="24" 369 - > 370 - <path d="M0 0h24v24H0z" fill="none" /> 371 - <path 372 - 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" 373 - /> 374 - </svg> 375 - {:else} 376 - {index + 1} 377 - {/if} 345 + <!-- Using `let:item={i}` instead of `let:i` fixes drag-and-drop --> 346 + <VirtualListBlock 347 + bind:this={virtual_list} 348 + items={Array.from({ length: $page.length }).map((_, i) => i)} 349 + item_height={24} 350 + {scroll_container} 351 + let:item={i} 352 + > 353 + {@const track = getItem(i)} 354 + {#if track !== null} 355 + <!-- svelte-ignore a11y-click-events-have-key-events --> 356 + <!-- svelte-ignore a11y-interactive-supports-focus --> 357 + <div 358 + class="row" 359 + role="row" 360 + on:dblclick={(e) => doubleClick(e, i)} 361 + on:mousedown={(e) => selection.handleMouseDown(e, i)} 362 + on:contextmenu={(e) => selection.handleContextMenu(e, i)} 363 + on:click={(e) => selection.handleClick(e, i)} 364 + draggable="true" 365 + on:dragstart={onDragStart} 366 + on:dragover={(e) => onDragOver(e, i)} 367 + on:drop={dropHandler} 368 + on:dragend={dragEndHandler} 369 + class:odd={i % 2 === 0} 370 + class:selected={$selection.list[i] === true} 371 + class:playing={track.id === $playingId} 372 + > 373 + <div class="c index"> 374 + {#if track.id === $playingId} 375 + <svg 376 + class="playing-icon inline" 377 + xmlns="http://www.w3.org/2000/svg" 378 + height="24" 379 + viewBox="0 0 24 24" 380 + width="24" 381 + > 382 + <path d="M0 0h24v24H0z" fill="none" /> 383 + <path 384 + 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" 385 + /> 386 + </svg> 387 + {:else} 388 + {i + 1} 389 + {/if} 390 + </div> 391 + <div class="c name">{track.name}</div> 392 + <div class="c playCount">{track.playCount || ''}</div> 393 + <div class="c duration">{track.duration ? getDuration(track.duration) : ''}</div> 394 + <div class="c artist">{track.artist}</div> 395 + <div class="c albumName">{track.albumName || ''}</div> 396 + <div class="c comments">{track.comments || ''}</div> 397 + <div class="c genre">{track.genre || ''}</div> 398 + <div class="c dateAdded">{formatDate(track.dateAdded)}</div> 399 + <div class="c year">{track.year || ''}</div> 378 400 </div> 379 - <div class="c name">{track.name}</div> 380 - <div class="c playCount">{track.playCount || ''}</div> 381 - <div class="c duration">{track.duration ? getDuration(track.duration) : ''}</div> 382 - <div class="c artist">{track.artist}</div> 383 - <div class="c albumName">{track.albumName || ''}</div> 384 - <div class="c comments">{track.comments || ''}</div> 385 - <div class="c genre">{track.genre || ''}</div> 386 - <div class="c dateAdded">{formatDate(track.dateAdded)}</div> 387 - <div class="c year">{track.year || ''}</div> 388 - </div> 389 - {/if} 390 - </VirtualList> 401 + {/if} 402 + </VirtualListBlock> 403 + </div> 391 404 <div class="drag-line" class:show={dragToIndex !== null} bind:this={dragLine} /> 392 405 </div> 393 406
-136
src/components/VirtualList.svelte
··· 1 - <script lang="ts" generics="T"> 2 - import { createEventDispatcher, onMount, tick } from 'svelte' 3 - 4 - const dispatch = createEventDispatcher<{ 'mousedown-self': null }>() 5 - 6 - // eslint-disable-next-line no-undef 7 - type X = T 8 - 9 - export let getItem: (index: number) => X 10 - export let isMain = 0 11 - export let itemCount: number 12 - export let itemHeight: number 13 - let startIndex = -1 14 - let endIndex = -1 15 - let height = 0 16 - let scrollTop = 0 17 - let visibleItems: X[] = [] 18 - 19 - export function scrollToItem(index: number) { 20 - const top = index * itemHeight 21 - if (viewport.scrollTop > top) { 22 - viewport.scrollTop = top 23 - } else if (viewport.scrollTop + viewport.clientHeight < top + itemHeight) { 24 - viewport.scrollTop = top + itemHeight - viewport.clientHeight 25 - } 26 - } 27 - 28 - export function focus() { 29 - viewport.focus() 30 - } 31 - 32 - let viewport: HTMLDivElement 33 - function handleScroll(e: Event) { 34 - const target = e.target as HTMLTextAreaElement 35 - scrollTop = target.scrollTop 36 - } 37 - 38 - let mounted = false 39 - onMount(() => { 40 - scrollTop = viewport.scrollTop 41 - mounted = true 42 - }) 43 - 44 - function keydown(e: KeyboardEvent) { 45 - let prevent = true 46 - if (e.key === 'Home') viewport.scrollTop = 0 47 - else if (e.key === 'End') viewport.scrollTop = viewport.scrollHeight 48 - else if (e.key === 'PageUp') viewport.scrollTop -= viewport.clientHeight 49 - else if (e.key === 'PageDown') viewport.scrollTop += viewport.clientHeight 50 - else prevent = false 51 - if (prevent) e.preventDefault() 52 - } 53 - 54 - const buffer = 5 55 - function getStartIndex(scrollTop: number, itemHeight: number) { 56 - let topPixel = scrollTop 57 - let index = Math.floor(topPixel / itemHeight) - buffer 58 - return Math.max(0, index) 59 - } 60 - function getEndIndex(scrollTop: number, height: number, itemHeight: number, itemCount: number) { 61 - let bottomPixel = scrollTop + height 62 - let index = Math.ceil(bottomPixel / itemHeight) + buffer 63 - return Math.min(itemCount - 1, index) 64 - } 65 - 66 - $: if (mounted) updateView(scrollTop, height, itemHeight, itemCount) 67 - function updateView(scrollTop: number, height: number, itemHeight: number, itemCount: number) { 68 - const newStartIndex = getStartIndex(scrollTop, itemHeight) 69 - const newEndIndex = getEndIndex(scrollTop, height, itemHeight, itemCount) 70 - 71 - let newVisibleItems = [] 72 - for (let i = newStartIndex; i <= newEndIndex; i++) { 73 - if (i >= startIndex && i <= endIndex) { 74 - newVisibleItems.push(visibleItems[i - startIndex]) 75 - } else { 76 - newVisibleItems.push(getItem(i)) 77 - } 78 - } 79 - visibleItems = newVisibleItems 80 - startIndex = newStartIndex 81 - endIndex = newEndIndex 82 - } 83 - 84 - export async function refresh() { 85 - if (mounted) { 86 - startIndex = -1 87 - endIndex = -1 88 - visibleItems = [] 89 - await tick() 90 - // we need to wait a tick so properties can finish updating 91 - updateView(scrollTop, height, itemHeight, itemCount) 92 - } 93 - } 94 - </script> 95 - 96 - <svelte:body 97 - on:keydown={(e) => { 98 - if (isMain) { 99 - keydown(e) 100 - } 101 - }} 102 - /> 103 - 104 - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 105 - <div 106 - class="viewport" 107 - role="none" 108 - bind:this={viewport} 109 - bind:clientHeight={height} 110 - on:scroll={handleScroll} 111 - on:dragleave 112 - on:keydown 113 - on:mousedown|self={() => dispatch('mousedown-self')} 114 - tabindex="0" 115 - on:keydown={keydown} 116 - > 117 - <div 118 - class="content" 119 - style:height={itemCount * itemHeight + 'px'} 120 - style:padding-top={startIndex * itemHeight + 'px'} 121 - > 122 - {#each visibleItems as item, i (startIndex + i)} 123 - <slot {item} index={startIndex + i} /> 124 - {/each} 125 - </div> 126 - </div> 127 - 128 - <style lang="sass"> 129 - .viewport 130 - height: 100% 131 - overflow-y: auto 132 - outline: none 133 - background-color: inherit 134 - .content 135 - box-sizing: border-box 136 - </style>
+6 -2
src/components/VirtualListBlock.svelte
··· 45 45 } 46 46 } 47 47 48 - function refresh() { 48 + export function refresh() { 49 + if (!main_element || !scroll_container) { 50 + return 51 + } 52 + 49 53 let element_top = main_element.offsetTop 50 54 let offset_parent = main_element.offsetParent 51 55 while (offset_parent !== scroll_container && offset_parent instanceof HTMLElement) { ··· 81 85 scroll_event_element?.removeEventListener('scroll', refresh) 82 86 }) 83 87 84 - export function scroll_to_index(index: number, offset: number) { 88 + export function scroll_to_index(index: number, offset = 0) { 85 89 const dummy = document.createElement('div') 86 90 dummy.style.height = item_height + 'px' 87 91 dummy.style.position = 'absolute'
+12 -12
src/lib/page.ts
··· 1 - import { get, writable } from 'svelte/store' 1 + import { get } from 'svelte/store' 2 2 import { page } from './data' 3 3 import { showTrackMenu } from './menus' 4 4 import { newSelection } from './selection' 5 5 6 - export const scrollToIndex = writable(null as number | null) 7 - 8 - let main_area_el: HTMLElement | undefined 9 - export const main_area = { 10 - focus() { 11 - main_area_el?.focus() 12 - }, 6 + export const tracklist_actions = { 7 + scroll_to_index(_index: number) {}, 8 + focus() {}, 13 9 } 14 - 15 10 export const selection = newSelection({ 16 - getItemCount: () => get(page).length, 17 - scrollToItem: (i) => scrollToIndex.set(i), 18 - onContextMenu: async () => { 11 + getItemCount() { 12 + return get(page).length 13 + }, 14 + // scrollToItem: scroll_to_index, 15 + scrollToItem(i) { 16 + tracklist_actions.scroll_to_index?.(i) 17 + }, 18 + async onContextMenu() { 19 19 const indexes = selection.getSelectedIndexes() 20 20 const ids = page.getTrackIds() 21 21 await showTrackMenu(ids, indexes, { editable: get(page).tracklist.type === 'playlist' })