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

Runes attempt fail

Kasper (May 27, 2025, 12:25 AM +0200) 3275a005 3b3d769b

+696 -521
+12 -12
package-lock.json
··· 12 12 "@sveltejs/vite-plugin-svelte": "^5.0.3", 13 13 "@tailwindcss/vite": "^4.1.7", 14 14 "@tsconfig/svelte": "^5.0.4", 15 - "electron": "^28.3.3", 15 + "electron": "^36.3.1", 16 16 "electron-builder": "^26.0.12", 17 17 "eslint": "^9.27.0", 18 18 "eslint-config-prettier": "^10.1.5", ··· 2762 2762 "license": "MIT" 2763 2763 }, 2764 2764 "node_modules/@types/node": { 2765 - "version": "18.19.103", 2766 - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.103.tgz", 2767 - "integrity": "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw==", 2765 + "version": "22.15.21", 2766 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", 2767 + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", 2768 2768 "dev": true, 2769 2769 "license": "MIT", 2770 2770 "dependencies": { 2771 - "undici-types": "~5.26.4" 2771 + "undici-types": "~6.21.0" 2772 2772 } 2773 2773 }, 2774 2774 "node_modules/@types/plist": { ··· 4414 4414 } 4415 4415 }, 4416 4416 "node_modules/electron": { 4417 - "version": "28.3.3", 4418 - "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", 4419 - "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", 4417 + "version": "36.3.1", 4418 + "resolved": "https://registry.npmjs.org/electron/-/electron-36.3.1.tgz", 4419 + "integrity": "sha512-LeOZ+tVahmctHaAssLCGRRUa2SAO09GXua3pKdG+WzkbSDMh+3iOPONNVPTqGp8HlWnzGj4r6mhsIbM2RgH+eQ==", 4420 4420 "dev": true, 4421 4421 "hasInstallScript": true, 4422 4422 "license": "MIT", 4423 4423 "dependencies": { 4424 4424 "@electron/get": "^2.0.0", 4425 - "@types/node": "^18.11.18", 4425 + "@types/node": "^22.7.7", 4426 4426 "extract-zip": "^2.0.1" 4427 4427 }, 4428 4428 "bin": { ··· 8827 8827 } 8828 8828 }, 8829 8829 "node_modules/undici-types": { 8830 - "version": "5.26.5", 8831 - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 8832 - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 8830 + "version": "6.21.0", 8831 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 8832 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 8833 8833 "dev": true, 8834 8834 "license": "MIT" 8835 8835 },
+1 -1
package.json
··· 71 71 "@sveltejs/vite-plugin-svelte": "^5.0.3", 72 72 "@tailwindcss/vite": "^4.1.7", 73 73 "@tsconfig/svelte": "^5.0.4", 74 - "electron": "^28.3.3", 74 + "electron": "^36.3.1", 75 75 "electron-builder": "^26.0.12", 76 76 "eslint": "^9.27.0", 77 77 "eslint-config-prettier": "^10.1.5",
+16 -12
src/App.svelte
··· 1 1 <script lang="ts"> 2 + import { run } from 'svelte/legacy'; 3 + 2 4 import { onDestroy, onMount } from 'svelte' 3 5 import { fade } from 'svelte/transition' 4 6 import TrackList from './components/TrackList.svelte' ··· 52 54 function toggle_queue() { 53 55 $queue_visible = !$queue_visible 54 56 } 55 - $: ipc_renderer.invoke('update:Show Queue', $queue_visible) 57 + run(() => { 58 + ipc_renderer.invoke('update:Show Queue', $queue_visible) 59 + }); 56 60 ipc_renderer.on('Show Queue', toggle_queue) 57 61 onDestroy(() => { 58 62 ipc_renderer.removeListener('Show Queue', toggle_queue) 59 63 }) 60 64 61 - let droppable = false 65 + let droppable = $state(false) 62 66 const allowed_mimes = ['audio/mpeg', 'audio/x-m4a', 'audio/ogg'] // mp3, m4a 63 67 function get_file_paths(e: DragEvent): string[] { 64 68 if (!e.dataTransfer) return [] ··· 111 115 } 112 116 } 113 117 114 - let show_settings = false 118 + let show_settings = $state(false) 115 119 onDestroy( 116 120 ipc_listen('show_settings', () => { 117 121 if ($modal_count === 0) { ··· 120 124 }), 121 125 ) 122 126 123 - let show_itunes_import = false 127 + let show_itunes_import = $state(false) 124 128 onDestroy( 125 129 ipc_listen('itunesImport', () => { 126 130 if ($modal_count === 0) { ··· 129 133 }), 130 134 ) 131 135 132 - let playlist_info: PlaylistInfo | null = null 136 + let playlist_info: PlaylistInfo | null = $state(null) 133 137 onDestroy( 134 138 ipc_listen('context.playlist.edit', (_, id) => { 135 139 const list = get_track_list(id) ··· 179 183 }) 180 184 </script> 181 185 182 - <svelte:window on:keydown={keydown} /> 186 + <svelte:window onkeydown={keydown} /> 183 187 <svelte:head> 184 188 <title>Ferrum</title> 185 189 </svelte:head> 186 190 187 - <!-- svelte-ignore a11y-no-noninteractive-element-interactions --> 191 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 188 192 <main 189 - on:dragenter|capture={drag_enter_or_over} 190 - on:keydown={(e) => { 193 + ondragentercapture={drag_enter_or_over} 194 + onkeydown={(e) => { 191 195 if (e.target) { 192 196 if (check_shortcut(e, 'ArrowUp', { cmd_or_ctrl: true })) { 193 197 e.preventDefault() ··· 218 222 <!-- svelte-ignore a11y_interactive_supports_focus --> 219 223 <div 220 224 class="dropzone" 221 - on:dragleave={drag_leave} 222 - on:drop={drop} 223 - on:dragover={drag_enter_or_over} 225 + ondragleave={drag_leave} 226 + ondrop={drop} 227 + ondragover={drag_enter_or_over} 224 228 role="dialog" 225 229 aria-label="Drop files to import" 226 230 aria-dropeffect="copy"
+1 -1
src/components/ArtistList.svelte
··· 4 4 import Header from './Header.svelte' 5 5 6 6 let all_artists = get_artists() 7 - $: artists = fuzzysort.go($filter, all_artists, { all: true }) 7 + let artists = $derived(fuzzysort.go($filter, all_artists, { all: true })) 8 8 </script> 9 9 10 10 <Header title="Artists" subtitle="{all_artists.length} artists" description={undefined} />
+24 -11
src/components/Button.svelte
··· 1 1 <script lang="ts"> 2 + import { run, createBubbler } from 'svelte/legacy'; 3 + 4 + const bubble = createBubbler(); 2 5 import type { HTMLBaseAttributes } from 'svelte/elements' 3 6 4 7 // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 - interface $$Props extends HTMLBaseAttributes { 6 - secondary?: boolean 7 - danger?: boolean 8 - type?: 'button' | 'submit' | 'reset' 8 + 9 + 10 + interface Props { 11 + secondary?: boolean; 12 + danger?: boolean; 13 + type?: 'button' | 'submit' | 'reset'; 14 + children?: import('svelte').Snippet; 15 + [key: string]: any 9 16 } 10 17 11 - export let secondary = false 12 - export let danger = false 13 - export let type: 'button' | 'submit' | 'reset' = 'button' 14 - let normal = !danger && !secondary 15 - $: normal = !danger && !secondary 18 + let { 19 + secondary = false, 20 + danger = false, 21 + type = 'button', 22 + children, 23 + ...rest 24 + }: Props = $props(); 25 + let normal = $state(!danger && !secondary) 26 + run(() => { 27 + normal = !danger && !secondary 28 + }); 16 29 </script> 17 30 18 - <button on:click on:mousedown class:normal class:secondary class:danger {type} {...$$restProps}> 19 - <slot /> 31 + <button onclick={bubble('click')} onmousedown={bubble('mousedown')} class:normal class:secondary class:danger {type} {...rest}> 32 + {@render children?.()} 20 33 </button> 21 34 22 35 <style lang="sass">
+16 -14
src/components/CheckForUpdates.svelte
··· 5 5 import { is_dev, save_view_options, view_options } from '@/lib/data' 6 6 import { check_shortcut } from '@/lib/helpers' 7 7 8 - let latest_update: Awaited<ReturnType<typeof check>> | null = null 8 + let latest_update: Awaited<ReturnType<typeof check>> | null = $state(null) 9 9 10 10 check() 11 11 async function check() { ··· 48 48 } 49 49 }} 50 50 > 51 - <!-- svelte-ignore a11y-autofocus --> 51 + <!-- svelte-ignore a11y_autofocus --> 52 52 <div class="w-md max-w-xl text-sm outline-none" autofocus tabindex="-1"> 53 53 <p class="pb-3"> 54 54 Ferrum {latest_update.channel.version} is available. You are currently on {latest_update.app_version} ··· 58 58 {latest_update.body} 59 59 </p> 60 60 </div> 61 - <svelte:fragment slot="buttons"> 62 - <Button 63 - secondary 64 - on:click={() => { 65 - skip_update(channel.version) 66 - latest_update = null 67 - }}>Skip This Version</Button 68 - > 69 - <div class="grow"></div> 70 - <Button secondary on:click={() => (latest_update = null)}>Later</Button> 71 - <Button type="submit">Update</Button> 72 - </svelte:fragment> 61 + {#snippet buttons()} 62 + 63 + <Button 64 + secondary 65 + on:click={() => { 66 + skip_update(channel.version) 67 + latest_update = null 68 + }}>Skip This Version</Button 69 + > 70 + <div class="grow"></div> 71 + <Button secondary on:click={() => (latest_update = null)}>Later</Button> 72 + <Button type="submit">Update</Button> 73 + 74 + {/snippet} 73 75 </Modal> 74 76 {/if}
+18 -11
src/components/Cover.svelte
··· 1 1 <script lang="ts"> 2 + import { createBubbler, stopPropagation } from 'svelte/legacy'; 3 + 4 + const bubble = createBubbler(); 2 5 import { paths } from '@/lib/data' 3 6 import type { Track } from '../../ferrum-addon' 4 7 import { ipc_renderer, join_paths } from '@/lib/window' 5 8 6 - export let track: Track 7 - $: src = 8 - 'app://trackimg?path=' + 9 + interface Props { 10 + track: Track; 11 + } 12 + 13 + let { track }: Props = $props(); 14 + let src = 15 + $derived('app://trackimg?path=' + 9 16 encodeURIComponent(join_paths(paths.tracksDir, track.file)) + 10 17 '&cache_db_path=' + 11 18 encodeURIComponent(paths.cacheDb) + 12 19 '&date_modified=' + 13 - encodeURIComponent(track.dateModified) 20 + encodeURIComponent(track.dateModified)) 14 21 15 - let error: { src: string; message: 404 | string | null } | false | null = null 22 + let error: { src: string; message: 404 | string | null } | false | null = $state(null) 16 23 </script> 17 24 18 25 {#if error} ··· 30 37 </svg> 31 38 {:else} 32 39 {@const error_msg = error.message} 33 - <!-- svelte-ignore a11y-click-events-have-key-events --> 34 - <!-- svelte-ignore a11y-no-static-element-interactions --> 40 + <!-- svelte-ignore a11y_click_events_have_key_events --> 41 + <!-- svelte-ignore a11y_no_static_element_interactions --> 35 42 <div 36 43 class="h-full cursor-pointer" 37 44 title={error.message} 38 - on:mousedown|stopPropagation 39 - on:click={() => { 45 + onmousedown={stopPropagation(bubble('mousedown'))} 46 + onclick={() => { 40 47 ipc_renderer.invoke('showMessageBox', false, { 41 48 type: 'error', 42 49 message: 'Failed to load cover', ··· 63 70 class:invisible={error === null} 64 71 {src} 65 72 alt="" 66 - on:load={() => { 73 + onload={() => { 67 74 error = false 68 75 }} 69 - on:error={async () => { 76 + onerror={async () => { 70 77 // Yes this is dumb, but there's no way to get an error code from <img src="" /> 71 78 error = { message: null, src } 72 79 try {
+1 -1
src/components/DragGhost.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 export let drag_el: HTMLElement 3 3 let text = '' 4 4 export function set_inner_text(new_text: string) {
+6 -3
src/components/Filter.svelte
··· 1 1 <script lang="ts"> 2 + import { createBubbler } from 'svelte/legacy'; 3 + 4 + const bubble = createBubbler(); 2 5 import { onDestroy } from 'svelte' 3 6 import { filter } from '@/lib/data' 4 7 import { ipc_listen } from '../lib/window' 5 8 6 - let filter_input: HTMLInputElement 9 + let filter_input: HTMLInputElement = $state() 7 10 onDestroy( 8 11 ipc_listen('filter', () => { 9 12 filter_input.select() ··· 12 15 </script> 13 16 14 17 <input 15 - on:focus 16 - on:keydown 18 + onfocus={bubble('focus')} 19 + onkeydown={bubble('keydown')} 17 20 bind:this={filter_input} 18 21 type="text" 19 22 class="search rounded-[5px] text-[13px] leading-none"
+7 -3
src/components/Header.svelte
··· 3 3 import { modal_count } from './Modal.svelte' 4 4 import { queue_visible } from '@/lib/queue' 5 5 6 - export let title: string 7 - export let subtitle: string 8 - export let description: string | undefined 6 + interface Props { 7 + title: string; 8 + subtitle: string; 9 + description: string | undefined; 10 + } 11 + 12 + let { title, subtitle, description }: Props = $props(); 9 13 </script> 10 14 11 15 <div class="relative px-5 pt-4 pb-5">
+8 -3
src/components/ItunesImport.svelte
··· 5 5 import Button from './Button.svelte' 6 6 import Modal from './Modal.svelte' 7 7 import { call } from '@/lib/error' 8 - // import { selection as pageSelection } from '@/lib/page' 8 + 9 + 10 + interface Props { 11 + // import { selection as pageSelection } from '@/lib/page' 12 + cancel: () => void; 13 + } 9 14 10 - export let cancel: () => void 15 + let { cancel }: Props = $props(); 11 16 let itunes_import = ItunesImport.new() 12 17 13 18 type Stage = 'select' | 'fileSelect' | 'scanning' | ImportStatus 14 - let stage: Stage = 'select' 19 + let stage: Stage = $state('select') 15 20 16 21 function cancel_handler() { 17 22 if (stage === 'fileSelect' || stage === 'scanning') {
+38 -23
src/components/Modal.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 import { writable } from 'svelte/store' 3 3 4 4 export const modal_count = writable(0) 5 5 </script> 6 6 7 7 <script lang="ts"> 8 + import { self, createBubbler, handlers, preventDefault } from 'svelte/legacy'; 9 + 10 + const bubble = createBubbler(); 8 11 import { onDestroy, onMount } from 'svelte' 9 12 import { check_shortcut } from '../lib/helpers' 10 13 11 - export let on_cancel: () => void 12 - export let cancel_on_escape = false 13 - export let form: (() => void) | undefined = undefined 14 - export let plain = false 15 - $: tag = form === undefined ? 'div' : 'form' 16 - export let title: string | null = null 17 - let dialog_el: HTMLDialogElement 14 + interface Props { 15 + on_cancel: () => void; 16 + cancel_on_escape?: boolean; 17 + form?: (() => void) | undefined; 18 + plain?: boolean; 19 + title?: string | null; 20 + children?: import('svelte').Snippet; 21 + buttons?: import('svelte').Snippet; 22 + } 23 + 24 + let { 25 + on_cancel, 26 + cancel_on_escape = false, 27 + form = undefined, 28 + plain = false, 29 + title = null, 30 + children, 31 + buttons 32 + }: Props = $props(); 33 + let dialog_el: HTMLDialogElement = $state() 18 34 19 35 let last_focused_el: Element | undefined | null 20 36 ··· 35 51 36 52 // Prevent clicks where the mousedown or mouseup happened on a child element. This could've 37 53 // been solved with a non-parent backdrop element, but that interferes with text selection. 38 - let clickable = true 54 + let clickable = $state(true) 55 + let tag = $derived(form === undefined ? 'div' : 'form') 39 56 </script> 40 57 41 58 <svelte:body 42 - on:click={() => { 59 + onclick={() => { 43 60 clickable = true 44 61 }} 45 62 /> ··· 50 67 class="modal m-auto" 51 68 bind:this={dialog_el} 52 69 tabindex="-1" 53 - on:click|self={() => { 70 + onclick={self(() => { 54 71 if (clickable) { 55 72 on_cancel() 56 73 } 57 - }} 58 - on:keydown 59 - on:keydown={(e) => { 74 + })} 75 + onkeydown={handlers(bubble('keydown'), (e) => { 60 76 if (check_shortcut(e, 'Escape')) { 61 77 e.preventDefault() 62 78 if (cancel_on_escape) { 63 79 on_cancel() 64 80 } 65 81 } 66 - }} 67 - on:keydown|self={(e) => { 82 + }, self((e) => { 68 83 if (form && check_shortcut(e, 'Enter')) { 69 84 form() 70 85 e.preventDefault() 71 86 } 72 - }} 87 + }))} 73 88 > 74 89 <svelte:element 75 90 this={tag} 76 91 class="box" 77 92 class:padded={!plain} 78 - on:submit|preventDefault={form} 79 - on:mousedown={() => { 93 + onsubmit={preventDefault(form)} 94 + onmousedown={() => { 80 95 clickable = false 81 96 }} 82 - on:mouseup={() => { 97 + onmouseup={() => { 83 98 clickable = false 84 99 }} 85 100 role="none" ··· 87 102 {#if title !== null} 88 103 <h3 class="text-lg">{title}</h3> 89 104 {/if} 90 - <slot /> 91 - {#if $$slots.buttons} 105 + {@render children?.()} 106 + {#if buttons} 92 107 <div class="buttons"> 93 - <slot name="buttons" /> 108 + {@render buttons?.()} 94 109 </div> 95 110 {/if} 96 111 </svelte:element>
+20 -17
src/components/Player.svelte
··· 1 1 <script lang="ts"> 2 + import { createBubbler, preventDefault } from 'svelte/legacy'; 3 + 4 + const bubble = createBubbler(); 2 5 import { 3 6 stopped, 4 7 play_pause, ··· 60 63 <div 61 64 class="cover" 62 65 role="none" 63 - on:contextmenu={playing_context_menu} 64 - on:dragstart={drag_start} 66 + oncontextmenu={playing_context_menu} 67 + ondragstart={drag_start} 65 68 draggable="true" 66 69 > 67 70 {#if $cover_src} ··· 75 78 {/if} 76 79 </div> 77 80 <div 78 - on:contextmenu={playing_context_menu} 79 - on:dragstart={drag_start} 81 + oncontextmenu={playing_context_menu} 82 + ondragstart={drag_start} 80 83 draggable="true" 81 84 role="none" 82 85 > ··· 97 100 class="side-controls shuffle" 98 101 class:on={$shuffle} 99 102 tabindex="-1" 100 - on:mousedown|preventDefault 101 - on:click={() => ($shuffle = !$shuffle)} 103 + onmousedown={preventDefault(bubble('mousedown'))} 104 + onclick={() => ($shuffle = !$shuffle)} 102 105 > 103 106 <div class="parent-active-zoom"> 104 107 <svg ··· 117 120 type="button" 118 121 aria-label="Previous" 119 122 class="previous" 120 - on:click={previous} 123 + onclick={previous} 121 124 tabindex="-1" 122 - on:mousedown|preventDefault 125 + onmousedown={preventDefault(bubble('mousedown'))} 123 126 > 124 127 <svg 125 128 xmlns="http://www.w3.org/2000/svg" ··· 137 140 <button 138 141 type="button" 139 142 class="play-pause" 140 - on:click={play_pause} 143 + onclick={play_pause} 141 144 class:cannot-play={$tracks_page_item_ids.length === 0} 142 145 tabindex="-1" 143 - on:mousedown|preventDefault 146 + onmousedown={preventDefault(bubble('mousedown'))} 144 147 > 145 148 {#if $time_record.paused} 146 149 <svg ··· 173 176 type="button" 174 177 aria-label="Next" 175 178 class="next" 176 - on:click={skip_to_next} 179 + onclick={skip_to_next} 177 180 tabindex="-1" 178 - on:mousedown|preventDefault 181 + onmousedown={preventDefault(bubble('mousedown'))} 179 182 > 180 183 <svg 181 184 xmlns="http://www.w3.org/2000/svg" ··· 196 199 class="side-controls repeat" 197 200 class:on={$repeat} 198 201 tabindex="-1" 199 - on:mousedown|preventDefault 200 - on:click={() => ($repeat = !$repeat)} 202 + onmousedown={preventDefault(bubble('mousedown'))} 203 + onclick={() => ($repeat = !$repeat)} 201 204 > 202 205 <div class="parent-active-zoom"> 203 206 <svg ··· 229 232 </div> 230 233 </div> 231 234 <div class="right"> 232 - <button type="button" class="volume-icon" tabindex="-1" on:click={volume.toggle}> 235 + <button type="button" class="volume-icon" tabindex="-1" onclick={volume.toggle}> 233 236 {#if $volume > 0.5} 234 237 <svg 235 238 class="high parent-active-zoom" ··· 276 279 type="button" 277 280 aria-label="Toggle queue" 278 281 tabindex="-1" 279 - on:mousedown|preventDefault 280 - on:click={toggle_queue_visibility} 282 + onmousedown={preventDefault(bubble('mousedown'))} 283 + onclick={toggle_queue_visibility} 281 284 class:on={$queue_visible} 282 285 > 283 286 <svg
+12 -6
src/components/PlaylistInfo.svelte
··· 4 4 import Modal from './Modal.svelte' 5 5 import Button from './Button.svelte' 6 6 7 - export let info: PlaylistInfo 8 - export let cancel: () => void 7 + interface Props { 8 + info: PlaylistInfo; 9 + cancel: () => void; 10 + } 11 + 12 + let { info = $bindable(), cancel }: Props = $props(); 9 13 10 14 function rows(value: string) { 11 15 const matches = value.match(/\n/g) || [] ··· 40 44 <textarea rows={rows(info.description)} bind:value={info.description} placeholder="Description" 41 45 ></textarea> 42 46 </main> 43 - <svelte:fragment slot="buttons"> 44 - <Button secondary on:click={cancel}>Cancel</Button> 45 - <Button on:click={save}>Save</Button> 46 - </svelte:fragment> 47 + {#snippet buttons()} 48 + 49 + <Button secondary on:click={cancel}>Cancel</Button> 50 + <Button on:click={save}>Save</Button> 51 + 52 + {/snippet} 47 53 </Modal> 48 54 49 55 <style lang="sass">
+120 -108
src/components/Queue.svelte
··· 1 1 <script lang="ts"> 2 + import { run, self, handlers, createBubbler, preventDefault } from 'svelte/legacy'; 3 + 4 + const bubble = createBubbler(); 2 5 import { 3 6 clear_user_queue, 4 7 get_by_queue_index, ··· 28 31 } 29 32 }) 30 33 31 - let show_history = false 32 - $: current_index = $queue.past.length 33 - $: up_next_index = current_index + Number(!!$queue.current) 34 - $: first_visible_index = show_history ? 0 : up_next_index 35 - $: autoplay_index = up_next_index + $queue.user_queue.length 34 + let show_history = $state(false) 35 + let current_index = $derived($queue.past.length) 36 + let up_next_index = $derived(current_index + Number(!!$queue.current)) 37 + let first_visible_index = $derived(show_history ? 0 : up_next_index) 38 + let autoplay_index = $derived(up_next_index + $queue.user_queue.length) 36 39 37 - let history_list: VirtualListBlock<QueueItem> | null 38 - let up_next_list: VirtualListBlock<QueueItem> | null 39 - let autoplay_list: VirtualListBlock<QueueItem> 40 + let history_list: VirtualListBlock<QueueItem> | null = $state() 41 + let up_next_list: VirtualListBlock<QueueItem> | null = $state() 42 + let autoplay_list: VirtualListBlock<QueueItem> = $state() 40 43 41 - let visible_qids = [ 44 + let visible_qids = $state([ 42 45 ...(show_history ? $queue.past : []), 43 46 ...(show_history && $queue.current ? [$queue.current.item] : []), 44 47 ...$queue.user_queue, 45 48 ...$queue.auto_queue, 46 - ].map((item) => item.qId) 47 - $: visible_qids = [ 48 - ...(show_history ? $queue.past : []), 49 - ...(show_history && $queue.current ? [$queue.current.item] : []), 50 - ...$queue.user_queue, 51 - ...$queue.auto_queue, 52 - ].map((item) => item.qId) 49 + ].map((item) => item.qId)) 50 + run(() => { 51 + visible_qids = [ 52 + ...(show_history ? $queue.past : []), 53 + ...(show_history && $queue.current ? [$queue.current.item] : []), 54 + ...$queue.user_queue, 55 + ...$queue.auto_queue, 56 + ].map((item) => item.qId) 57 + }); 53 58 const selection = new SvelteSelection(visible_qids, { 54 59 scroll_to: ({ index }) => { 55 60 if (show_history) { ··· 79 84 } 80 85 }, 81 86 }) 82 - $: selection.update_all_items(visible_qids) 87 + run(() => { 88 + selection.update_all_items(visible_qids) 89 + }); 83 90 84 91 function remove_from_queue() { 85 92 if (selection.items.size >= 1) { ··· 89 96 } 90 97 onDestroy(ipc_listen('context.Remove from Queue', remove_from_queue)) 91 98 92 - let queue_element: HTMLElement 99 + let queue_element: HTMLElement = $state() 93 100 94 101 function handle_action(action: SelectedTracksAction) { 95 102 const first_index = selection.find_first_index() ··· 116 123 }) 117 124 onDestroy(track_action_unlisten) 118 125 119 - let drag_line: HTMLElement 126 + let drag_line: HTMLElement = $state() 120 127 let dragged_indexes: number[] = [] 121 128 function on_drag_start(e: DragEvent) { 122 129 if (e.dataTransfer) { ··· 136 143 e.dataTransfer.setData('ferrum.tracks', '') 137 144 } 138 145 } 139 - let drag_to_index: null | number = null 146 + let drag_to_index: null | number = $state(null) 140 147 let drag_top_of_item = false 141 148 function on_drag_over(e: DragEvent, index: number) { 142 149 if (e.currentTarget && e.dataTransfer?.types[0] === 'ferrum.tracks' && index >= up_next_index) { ··· 182 189 183 190 <aside transition:fly={{ x: '100%', duration: 150, opacity: 1 }}> 184 191 <div class="shadow"></div> 185 - <!-- svelte-ignore a11y-no-static-element-interactions --> 192 + <!-- svelte-ignore a11y_no_static_element_interactions --> 186 193 <div 187 194 bind:this={queue_element} 188 195 class="content relative -mt-px border-l outline-none" 189 196 tabindex="-1" 190 - on:mousedown|self={() => selection.clear()} 191 - on:keydown={scroll_container_keydown} 192 - on:keydown={(e) => { 197 + onmousedown={self(() => selection.clear())} 198 + onkeydown={handlers(scroll_container_keydown, (e) => { 193 199 if (check_shortcut(e, 'Backspace') && selection.items.size >= 1) { 194 200 e.preventDefault() 195 201 remove_from_queue() 196 202 } else { 197 203 selection.handle_keydown(e) 198 204 } 199 - }} 205 + })} 200 206 > 201 207 {#if $queue.past.length || $queue.current} 202 208 <div class="relative"> 203 209 <div class="sticky top-0 z-1 flex flex h-[40px] items-center bg-black/50 backdrop-blur-md"> 204 210 <button 205 211 type="button" 206 - on:click={() => { 212 + onclick={() => { 207 213 show_history = !show_history 208 214 tick().then(() => { 209 215 up_next_list?.refresh() ··· 212 218 }} 213 219 class="group ml-1.5 flex h-full items-center pl-1 font-semibold" 214 220 tabindex="-1" 215 - on:mousedown|preventDefault 221 + onmousedown={preventDefault(bubble('mousedown'))} 216 222 > 217 223 <svg 218 224 xmlns="http://www.w3.org/2000/svg" ··· 239 245 get_key={(item) => item.qId} 240 246 item_height={54} 241 247 scroll_container={queue_element} 242 - let:item 243 - let:i={qi} 248 + 249 + 244 250 > 245 - <!-- svelte-ignore a11y-click-events-have-key-events --> 246 - <!-- svelte-ignore a11y-interactive-supports-focus --> 247 - <div 248 - class="row" 249 - role="row" 250 - class:selected={$selection.has(item.qId)} 251 - on:mousedown={(e) => selection.handle_mousedown(e, qi)} 252 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi)} 253 - on:click={(e) => selection.handle_click(e, qi)} 254 - draggable="true" 255 - on:dragstart={on_drag_start} 256 - on:dragover={(e) => on_drag_over(e, qi)} 257 - on:drop={drop_handler} 258 - on:dragleave={drag_end_handler} 259 - on:dragend={drag_end_handler} 260 - > 261 - <QueueItemComponent id={item.id} /> 262 - </div> 263 - </VirtualListBlock> 251 + {#snippet children({ item, i: qi })} 252 + <!-- svelte-ignore a11y_click_events_have_key_events --> 253 + <!-- svelte-ignore a11y_interactive_supports_focus --> 254 + <div 255 + class="row" 256 + role="row" 257 + class:selected={$selection.has(item.qId)} 258 + onmousedown={(e) => selection.handle_mousedown(e, qi)} 259 + oncontextmenu={(e) => selection.handle_contextmenu(e, qi)} 260 + onclick={(e) => selection.handle_click(e, qi)} 261 + draggable="true" 262 + ondragstart={on_drag_start} 263 + ondragover={(e) => on_drag_over(e, qi)} 264 + ondrop={drop_handler} 265 + ondragleave={drag_end_handler} 266 + ondragend={drag_end_handler} 267 + > 268 + <QueueItemComponent id={item.id} /> 269 + </div> 270 + {/snippet} 271 + </VirtualListBlock> 264 272 {#if $queue.current} 265 273 {@const qi = current_index} 266 - <!-- svelte-ignore a11y-click-events-have-key-events --> 267 - <!-- svelte-ignore a11y-interactive-supports-focus --> 274 + <!-- svelte-ignore a11y_click_events_have_key_events --> 275 + <!-- svelte-ignore a11y_interactive_supports_focus --> 268 276 <div 269 277 class="row" 270 278 role="row" 271 279 class:selected={$selection.has($queue.current.item.qId)} 272 - on:mousedown={(e) => selection.handle_mousedown(e, qi)} 273 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi)} 274 - on:click={(e) => selection.handle_click(e, qi)} 280 + onmousedown={(e) => selection.handle_mousedown(e, qi)} 281 + oncontextmenu={(e) => selection.handle_contextmenu(e, qi)} 282 + onclick={(e) => selection.handle_click(e, qi)} 275 283 draggable="true" 276 - on:dragstart={on_drag_start} 277 - on:dragover={(e) => on_drag_over(e, qi)} 278 - on:drop={drop_handler} 279 - on:dragleave={drag_end_handler} 280 - on:dragend={drag_end_handler} 284 + ondragstart={on_drag_start} 285 + ondragover={(e) => on_drag_over(e, qi)} 286 + ondrop={drop_handler} 287 + ondragleave={drag_end_handler} 288 + ondragend={drag_end_handler} 281 289 > 282 290 <QueueItemComponent id={$queue.current.item.id} /> 283 291 </div> ··· 288 296 289 297 {#if $queue.user_queue.length || queue.getQueueLength() === 0} 290 298 <div class="relative"> 291 - <!-- svelte-ignore a11y-no-noninteractive-element-interactions --> 299 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 292 300 <h4 293 301 class="sticky top-0 z-1 flex h-[40px] items-center justify-between bg-black/50 px-7 font-semibold backdrop-blur-md" 294 - on:mousedown|self={() => selection.clear()} 302 + onmousedown={self(() => selection.clear())} 295 303 > 296 304 Up Next 297 305 {#if $queue.user_queue.length > 0} ··· 299 307 type="button" 300 308 aria-label="Clear 'Up Next'" 301 309 tabindex="-1" 302 - on:mousedown|preventDefault 303 - on:click={clear_user_queue} 310 + onmousedown={preventDefault(bubble('mousedown'))} 311 + onclick={clear_user_queue} 304 312 > 305 313 <svg 306 314 xmlns="http://www.w3.org/2000/svg" ··· 322 330 get_key={(item) => item.qId} 323 331 item_height={54} 324 332 scroll_container={queue_element} 325 - let:item 326 - let:i 333 + 334 + 327 335 > 328 - {@const qi = i + up_next_index} 329 - <!-- svelte-ignore a11y-click-events-have-key-events --> 330 - <!-- svelte-ignore a11y-interactive-supports-focus --> 331 - <div 332 - class="row" 333 - role="row" 334 - class:selected={$selection.has(item.qId)} 335 - on:mousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 336 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 337 - on:click={(e) => selection.handle_click(e, qi - first_visible_index)} 338 - draggable="true" 339 - on:dragstart={on_drag_start} 340 - on:dragover={(e) => on_drag_over(e, qi)} 341 - on:drop={drop_handler} 342 - on:dragleave={drag_end_handler} 343 - on:dragend={drag_end_handler} 344 - > 345 - <QueueItemComponent id={item.id} /> 346 - </div> 347 - </VirtualListBlock> 336 + {#snippet children({ item, i })} 337 + {@const qi = i + up_next_index} 338 + <!-- svelte-ignore a11y_click_events_have_key_events --> 339 + <!-- svelte-ignore a11y_interactive_supports_focus --> 340 + <div 341 + class="row" 342 + role="row" 343 + class:selected={$selection.has(item.qId)} 344 + onmousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 345 + oncontextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 346 + onclick={(e) => selection.handle_click(e, qi - first_visible_index)} 347 + draggable="true" 348 + ondragstart={on_drag_start} 349 + ondragover={(e) => on_drag_over(e, qi)} 350 + ondrop={drop_handler} 351 + ondragleave={drag_end_handler} 352 + ondragend={drag_end_handler} 353 + > 354 + <QueueItemComponent id={item.id} /> 355 + </div> 356 + {/snippet} 357 + </VirtualListBlock> 348 358 </div> 349 359 {/if} 350 360 351 361 {#if $queue.auto_queue.length} 352 362 <div class="relative"> 353 - <!-- svelte-ignore a11y-no-noninteractive-element-interactions --> 363 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 354 364 <h4 355 365 class="sticky top-0 z-1 flex h-[40px] items-center bg-black/50 px-7 font-semibold backdrop-blur-md" 356 - on:mousedown={() => selection.clear()} 366 + onmousedown={() => selection.clear()} 357 367 > 358 368 Autoplay 359 369 </h4> ··· 363 373 get_key={(item) => item.qId} 364 374 item_height={54} 365 375 scroll_container={queue_element} 366 - let:item 367 - let:i 376 + 377 + 368 378 > 369 - {@const qi = i + autoplay_index} 370 - <!-- svelte-ignore a11y-click-events-have-key-events --> 371 - <!-- svelte-ignore a11y-interactive-supports-focus --> 372 - <div 373 - class="row" 374 - role="row" 375 - class:selected={$selection.has(item.qId)} 376 - on:mousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 377 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 378 - on:click={(e) => selection.handle_click(e, qi - first_visible_index)} 379 - draggable="true" 380 - on:dragstart={on_drag_start} 381 - on:dragover={(e) => on_drag_over(e, qi)} 382 - on:drop={drop_handler} 383 - on:dragleave={drag_end_handler} 384 - on:dragend={drag_end_handler} 385 - > 386 - <QueueItemComponent id={item.id} /> 387 - </div> 388 - </VirtualListBlock> 379 + {#snippet children({ item, i })} 380 + {@const qi = i + autoplay_index} 381 + <!-- svelte-ignore a11y_click_events_have_key_events --> 382 + <!-- svelte-ignore a11y_interactive_supports_focus --> 383 + <div 384 + class="row" 385 + role="row" 386 + class:selected={$selection.has(item.qId)} 387 + onmousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 388 + oncontextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 389 + onclick={(e) => selection.handle_click(e, qi - first_visible_index)} 390 + draggable="true" 391 + ondragstart={on_drag_start} 392 + ondragover={(e) => on_drag_over(e, qi)} 393 + ondrop={drop_handler} 394 + ondragleave={drag_end_handler} 395 + ondragend={drag_end_handler} 396 + > 397 + <QueueItemComponent id={item.id} /> 398 + </div> 399 + {/snippet} 400 + </VirtualListBlock> 389 401 </div> 390 402 {/if} 391 403 <div
+18 -10
src/components/QueueItem.svelte
··· 1 1 <script lang="ts"> 2 + import { run } from 'svelte/legacy'; 3 + 2 4 import { get_track, paths, tracks_updated } from '@/lib/data' 3 5 import type { Track } from '../../ferrum-addon' 4 6 import { join_paths } from '@/lib/window' 5 7 6 - export let id: string 8 + interface Props { 9 + id: string; 10 + } 7 11 8 - let track: Track 9 - $: $tracks_updated, (track = get_track(id)) 12 + let { id }: Props = $props(); 10 13 11 - $: src = 12 - 'app://trackimg?path=' + 14 + let track: Track = $state() 15 + run(() => { 16 + $tracks_updated, (track = get_track(id)) 17 + }); 18 + 19 + let src = 20 + $derived('app://trackimg?path=' + 13 21 encodeURIComponent(join_paths(paths.tracksDir, track.file)) + 14 22 '&cache_db_path=' + 15 23 encodeURIComponent(paths.cacheDb) + 16 24 '&date_modified=' + 17 - encodeURIComponent(track.dateModified) 25 + encodeURIComponent(track.dateModified)) 18 26 19 - let failed_src: string | null = null 20 - let loaded = false 27 + let failed_src: string | null = $state(null) 28 + let loaded = $state(false) 21 29 </script> 22 30 23 31 <div class="box"> ··· 39 47 class:invisible={!loaded} 40 48 {src} 41 49 alt="" 42 - on:load={() => { 50 + onload={() => { 43 51 loaded = true 44 52 failed_src = null 45 53 }} 46 - on:error={() => { 54 + onerror={() => { 47 55 failed_src = src 48 56 }} 49 57 />
+22 -16
src/components/QuickNav.svelte
··· 1 1 <script lang="ts"> 2 + import { run } from 'svelte/legacy'; 3 + 2 4 import { onDestroy } from 'svelte' 3 5 import { check_shortcut } from '../lib/helpers' 4 6 import { ipc_listen } from '@/lib/window' ··· 11 13 12 14 type Result = TrackListDetails & { path?: string } 13 15 14 - let value = '' 15 - let playlists: Result[] = [] 16 - let show = false 17 - $: if (show) { 18 - playlists = get_playlists() 19 - } 16 + let value = $state('') 17 + let playlists: Result[] = $state([]) 18 + let show = $state(false) 20 19 function get_playlists() { 21 20 const playlists: Result[] = [...special_playlists_nav] 22 21 for (const playlist of Object.values($track_lists_details_map)) { ··· 27 26 return playlists 28 27 } 29 28 30 - let filtered_items = fuzzysort.go(value, playlists, { key: 'name', all: true }) 31 - $: { 32 - filtered_items = fuzzysort.go(value, playlists, { key: 'name', all: true }) 33 - clamp_index() 34 - } 29 + let filtered_items = $state(fuzzysort.go(value, playlists, { key: 'name', all: true })) 35 30 36 31 function select_input(el: HTMLInputElement) { 37 32 el.select() 38 33 } 39 34 40 - let selected_index = 0 35 + let selected_index = $state(0) 41 36 42 37 function clamp_index() { 43 38 selected_index = Math.max(0, Math.min(filtered_items.length - 1, selected_index)) 44 39 } 45 - $: list_items, clamp_index() 46 - let list_items: HTMLElement[] = [] 40 + let list_items: HTMLElement[] = $state([]) 47 41 48 42 function handle_keydown(e: KeyboardEvent) { 49 43 if (e.key === 'Tab') { ··· 76 70 show = !show 77 71 }), 78 72 ) 73 + run(() => { 74 + if (show) { 75 + playlists = get_playlists() 76 + } 77 + }); 78 + run(() => { 79 + filtered_items = fuzzysort.go(value, playlists, { key: 'name', all: true }) 80 + clamp_index() 81 + }); 82 + run(() => { 83 + list_items, clamp_index() 84 + }); 79 85 </script> 80 86 81 87 {#if show} ··· 88 94 <input 89 95 type="text" 90 96 bind:value 91 - on:keydown={handle_keydown} 97 + onkeydown={handle_keydown} 92 98 placeholder="Search for a playlist..." 93 99 use:select_input 94 100 /> ··· 97 103 <button 98 104 bind:this={list_items[i]} 99 105 type="button" 100 - on:click={() => { 106 + onclick={() => { 101 107 navigate(item.obj.path ?? '/playlist/' + item.obj.id) 102 108 show = false 103 109 }}
+12 -6
src/components/Settings.svelte
··· 3 3 import Button from './Button.svelte' 4 4 import { save_view_options, view_options } from '@/lib/data' 5 5 6 - export let on_close: () => void 6 + interface Props { 7 + on_close: () => void; 8 + } 7 9 8 - let auto_update = !view_options.noAutoUpdate 10 + let { on_close }: Props = $props(); 11 + 12 + let auto_update = $state(!view_options.noAutoUpdate) 9 13 10 14 function save() { 11 15 view_options.noAutoUpdate = !auto_update ··· 25 29 Automatically check for updates on startup 26 30 </label> 27 31 </div> 28 - <svelte:fragment slot="buttons"> 29 - <Button secondary on:click={on_close}>Cancel</Button> 30 - <Button type="submit" on:click={save}>Save</Button> 31 - </svelte:fragment> 32 + {#snippet buttons()} 33 + 34 + <Button secondary on:click={on_close}>Cancel</Button> 35 + <Button type="submit" on:click={save}>Save</Button> 36 + 37 + {/snippet} 32 38 </Modal>
+23 -18
src/components/Sidebar.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 export const special_playlists_nav = [ 3 3 { id: 'root', name: 'Songs', kind: 'special', path: '/playlist/root' }, 4 4 // { id: 'root', name: 'Artists', kind: 'special', path: '/artists' }, ··· 6 6 </script> 7 7 8 8 <script lang="ts"> 9 + import { run, createBubbler, preventDefault, self } from 'svelte/legacy'; 10 + 11 + const bubble = createBubbler(); 9 12 import SidebarItems, { type SidebarItemHandle } from './SidebarItems.svelte' 10 13 import Filter from './Filter.svelte' 11 14 import { is_mac, track_lists_details_map, move_playlist } from '@/lib/data' ··· 17 20 import { navigate } from '@/lib/router' 18 21 import { current_playlist_id } from './TrackList.svelte' 19 22 20 - let viewport: HTMLElement 23 + let viewport: HTMLElement = $state() 21 24 const item_handle = setContext('itemHandle', writable(null as SidebarItemHandle | null)) 22 25 23 26 onDestroy( ··· 39 42 }) 40 43 } 41 44 42 - let root_droppable = false 45 + let root_droppable = $state(false) 43 46 function dragover(e: DragEvent) { 44 47 if (e.currentTarget && e.dataTransfer?.types[0] === 'ferrum.playlist') { 45 48 root_droppable = true ··· 60 63 } 61 64 } 62 65 63 - let content_element: HTMLDivElement 66 + let content_element: HTMLDivElement = $state() 64 67 65 - $: $current_playlist_id, scroll_to_active() 66 68 async function scroll_to_active() { 67 69 await tick() 68 70 const active = content_element?.querySelector('.active') ··· 86 88 content_element.scrollTop = scroll_top 87 89 scroll_to_active() 88 90 } 91 + run(() => { 92 + $current_playlist_id, scroll_to_active() 93 + }); 89 94 </script> 90 95 91 96 <!-- NOTE: aside is used as css selector in SidebarItems --> 92 - <aside on:mousedown|self|preventDefault role="none"> 97 + <aside onmousedown={self(preventDefault(bubble('mousedown')))} role="none"> 93 98 {#if is_mac} 94 - <div class="titlebar" on:mousedown|self|preventDefault role="none"></div> 99 + <div class="titlebar" onmousedown={self(preventDefault(bubble('mousedown')))} role="none"></div> 95 100 {/if} 96 101 <div class="content" bind:this={content_element}> 97 102 <Filter ··· 104 109 } 105 110 }} 106 111 /> 107 - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 108 - <!-- svelte-ignore a11y-no-noninteractive-element-interactions --> 112 + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 113 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 109 114 <nav 110 115 class="items" 111 116 tabindex="-1" 112 - on:mousedown|preventDefault={() => { 117 + onmousedown={preventDefault(() => { 113 118 if (document.activeElement === document.body) { 114 119 tracklist_actions.focus() 115 120 } 116 - }} 117 - on:keydown={(e) => { 121 + })} 122 + onkeydown={(e) => { 118 123 if (e.key === 'Escape') { 119 124 e.preventDefault() 120 125 tracklist_actions.focus() ··· 131 136 }} 132 137 bind:this={viewport} 133 138 class:droppable={root_droppable} 134 - on:contextmenu|self={on_context_menu} 135 - on:dragover|self={dragover} 136 - on:dragleave|self={dragleave} 137 - on:drop|self={drop} 139 + oncontextmenu={self(on_context_menu)} 140 + ondragover={self(dragover)} 141 + ondragleave={self(dragleave)} 142 + ondrop={self(drop)} 138 143 > 139 - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 140 - <div class="focuser" tabindex="0" on:focus={focuser}></div> 144 + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 145 + <div class="focuser" tabindex="0" onfocus={focuser}></div> 141 146 <div class="spacer"></div> 142 147 <SidebarItems 143 148 parent_path={null}
+51 -35
src/components/SidebarItems.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 import { 3 3 track_lists_details_map, 4 4 add_tracks_to_playlist, ··· 29 29 </script> 30 30 31 31 <script lang="ts"> 32 + import { run, createBubbler, stopPropagation, self } from 'svelte/legacy'; 33 + 34 + const bubble = createBubbler(); 32 35 import type { TrackListDetails } from '../../ferrum-addon' 33 36 import { type Writable, writable } from 'svelte/store' 34 37 import { getContext } from 'svelte' ··· 39 42 import { navigate, url_pathname } from '@/lib/router' 40 43 import { current_playlist_id } from './TrackList.svelte' 41 44 42 - export let show = true 43 - export let parent_path: string | null 44 - export let prevent_drop = false 45 45 type Child = TrackListDetails & { path: string } 46 - export let children: Child[] 47 46 48 - export let level = 0 49 47 async function tracklist_context_menu(id: string, is_folder: boolean) { 50 48 await ipc_renderer.invoke('showTracklistMenu', { id, isFolder: is_folder, isRoot: false }) 51 49 } ··· 55 53 return list.children && list.children.length > 0 && $shown_folders.includes(id) 56 54 } 57 55 58 - export let on_select_down = () => {} 56 + interface Props { 57 + show?: boolean; 58 + parent_path: string | null; 59 + prevent_drop?: boolean; 60 + children: Child[]; 61 + level?: number; 62 + on_select_down?: any; 63 + } 64 + 65 + let { 66 + show = true, 67 + parent_path, 68 + prevent_drop = false, 69 + children, 70 + level = 0, 71 + on_select_down = () => {} 72 + }: Props = $props(); 59 73 function select_first(item: Child) { 60 74 const child_id = item.children?.[0] 61 75 if (child_id) { ··· 120 134 } 121 135 e.preventDefault() 122 136 } 123 - $: if (children.find((child) => child.path === $url_pathname)) { 124 - const item_handle = getContext<Writable<SidebarItemHandle | null>>('itemHandle') 125 - item_handle.set({ handleKey: handle_key }) 126 - } 137 + run(() => { 138 + if (children.find((child) => child.path === $url_pathname)) { 139 + const item_handle = getContext<Writable<SidebarItemHandle | null>>('itemHandle') 140 + item_handle.set({ handleKey: handle_key }) 141 + } 142 + }); 127 143 128 - let drag_track_onto_index = null as number | null 129 - let drop_above = false 130 - let drag_playlist_onto_index = null as number | null 144 + let drag_track_onto_index = $state(null as number | null) 145 + let drop_above = $state(false) 146 + let drag_playlist_onto_index = $state(null as number | null) 131 147 132 148 function on_drag_start(e: DragEvent, tracklist: TrackListDetails) { 133 149 if (e.dataTransfer && tracklist.kind !== 'special' && parent_path) { ··· 154 170 style:padding-left={14 * level + 'px'} 155 171 class:active={child_list.path === $url_pathname} 156 172 draggable="true" 157 - on:dragstart={(e) => on_drag_start(e, child_list)} 173 + ondragstart={(e) => on_drag_start(e, child_list)} 158 174 class:show={$shown_folders.includes(child_list.id)} 159 175 class:droppable={drag_playlist_onto_index === i} 160 - on:drop={(e) => { 176 + ondrop={(e) => { 161 177 if ( 162 178 e.currentTarget && 163 179 e.dataTransfer?.types[0] === 'ferrum.playlist' && ··· 175 191 drag_playlist_onto_index = null 176 192 } 177 193 }} 178 - on:mousedown={() => navigate(child_list.path)} 179 - on:contextmenu={() => tracklist_context_menu(child_list.id, true)} 194 + onmousedown={() => navigate(child_list.path)} 195 + oncontextmenu={() => tracklist_context_menu(child_list.id, true)} 180 196 > 181 - <!-- svelte-ignore a11y-click-events-have-key-events --> 182 - <!-- svelte-ignore a11y-interactive-supports-focus --> 197 + <!-- svelte-ignore a11y_click_events_have_key_events --> 198 + <!-- svelte-ignore a11y_interactive_supports_focus --> 183 199 <svg 184 200 class="arrow" 185 201 role="button" 186 202 aria-label="Arrow button" 187 - on:mousedown|stopPropagation 188 - on:click={() => { 203 + onmousedown={stopPropagation(bubble('mousedown'))} 204 + onclick={() => { 189 205 if ($shown_folders.includes(child_list.id)) { 190 206 hide_folder(child_list.id) 191 207 } else { ··· 199 215 > 200 216 <path d="M21 12l-18 12v-24z" /> 201 217 </svg> 202 - <!-- svelte-ignore a11y-interactive-supports-focus --> 218 + <!-- svelte-ignore a11y_interactive_supports_focus --> 203 219 <div 204 220 class="text" 205 221 role="link" 206 - on:dragover={(e) => { 222 + ondragover={(e) => { 207 223 if ( 208 224 e.currentTarget && 209 225 e.dataTransfer?.types[0] === 'ferrum.playlist' && ··· 215 231 e.preventDefault() 216 232 } 217 233 }} 218 - on:dragleave|self={() => { 234 + ondragleave={self(() => { 219 235 drag_playlist_onto_index = null 220 - }} 236 + })} 221 237 > 222 238 {child_list.name} 223 239 </div> ··· 240 256 }} 241 257 /> 242 258 {:else if child_list.kind === 'playlist'} 243 - <!-- svelte-ignore a11y-interactive-supports-focus --> 259 + <!-- svelte-ignore a11y_interactive_supports_focus --> 244 260 <a 245 261 href="/playlist/{child_list.id}" 246 262 tabindex="-1" ··· 248 264 aria-label="playlist" 249 265 style:padding-left={14 * level + 'px'} 250 266 draggable="true" 251 - on:dragstart={(e) => on_drag_start(e, child_list)} 267 + ondragstart={(e) => on_drag_start(e, child_list)} 252 268 class:active={child_list.path === $url_pathname} 253 - on:mousedown={() => navigate(child_list.path)} 269 + onmousedown={() => navigate(child_list.path)} 254 270 class:droppable={drag_track_onto_index === i} 255 271 class:droppable-above={drag_playlist_onto_index === i && drop_above} 256 272 class:droppable-below={drag_playlist_onto_index === i && !drop_above} 257 - on:drop={(e) => { 273 + ondrop={(e) => { 258 274 if (e.currentTarget && e.dataTransfer?.types[0] === 'ferrum.tracks' && dragged.tracks) { 259 275 add_tracks_to_playlist(child_list.id, dragged.tracks.ids) 260 276 drag_track_onto_index = null ··· 276 292 drag_playlist_onto_index = null 277 293 } 278 294 }} 279 - on:contextmenu={() => tracklist_context_menu(child_list.id, false)} 295 + oncontextmenu={() => tracklist_context_menu(child_list.id, false)} 280 296 > 281 297 <div class="arrow"></div> 282 298 <div 283 299 class="text" 284 300 role="link" 285 - on:dragover={(e) => { 301 + ondragover={(e) => { 286 302 if (e.currentTarget && e.dataTransfer?.types[0] === 'ferrum.tracks' && dragged.tracks) { 287 303 drag_track_onto_index = i 288 304 e.preventDefault() ··· 298 314 drop_above = e.pageY < rect.bottom - rect.height / 2 299 315 } 300 316 }} 301 - on:dragleave|self={() => { 317 + ondragleave={self(() => { 302 318 drag_track_onto_index = null 303 319 drag_playlist_onto_index = null 304 - }} 320 + })} 305 321 > 306 322 {child_list.name} 307 323 </div> ··· 312 328 tabindex="-1" 313 329 class="item rounded-r-[5px]" 314 330 style:padding-left={14 * level + 'px'} 315 - on:mousedown={() => navigate(child_list.path)} 331 + onmousedown={() => navigate(child_list.path)} 316 332 class:active={child_list.path === $url_pathname} 317 333 > 318 334 <div class="arrow"></div>
+46 -33
src/components/Slider.svelte
··· 1 1 <script lang="ts"> 2 + import { run } from 'svelte/legacy'; 3 + 2 4 import { onDestroy } from 'svelte' 3 5 import type { HTMLBaseAttributes } from 'svelte/elements' 4 - export let value: number 5 - /** Growth rate per second. This is for providing a smooth visual with low CPU usage. */ 6 - export let growth_rate: number = 0 7 - export let max = 100 8 - export let update_on_drag = true 9 - export let on_user_change: (value: number) => void = () => {} 10 - export let klass = '' 11 - export { klass as class } 6 + 7 + interface Props { 8 + value: number; 9 + /** Growth rate per second. This is for providing a smooth visual with low CPU usage. */ 10 + growth_rate?: number; 11 + max?: number; 12 + update_on_drag?: boolean; 13 + on_user_change?: (value: number) => void; 14 + class?: string; 15 + step?: number; 16 + [key: string]: any 17 + } 18 + 19 + let { 20 + value = $bindable(), 21 + growth_rate = 0, 22 + max = 100, 23 + update_on_drag = true, 24 + on_user_change = () => {}, 25 + class: klass = '', 26 + ...rest 27 + }: Props = $props(); 28 + 12 29 13 30 // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 - interface $$Props extends HTMLBaseAttributes { 15 - value: number 16 - growth_rate?: number 17 - max?: number 18 - step?: number 19 - class?: string 20 - update_on_drag?: boolean 21 - on_user_change?: (value: number) => void 22 - } 31 + 23 32 24 - let bar: HTMLDivElement 25 - let dragging = false 33 + let bar: HTMLDivElement = $state() 34 + let dragging = $state(false) 26 35 27 - let internal_value = value 28 - let updated_at = Date.now() 29 - $: if (update_on_drag || !dragging) { 30 - internal_value = value 31 - updated_at = Date.now() 32 - } 36 + let internal_value = $state(value) 37 + let updated_at = $state(Date.now()) 38 + run(() => { 39 + if (update_on_drag || !dragging) { 40 + internal_value = value 41 + updated_at = Date.now() 42 + } 43 + }); 33 44 34 45 function apply(e: MouseEvent) { 35 46 const delta = e.clientX - bar.getBoundingClientRect().left ··· 53 64 const secs_per_pixel = max / (bar.clientWidth * devicePixelRatio * 2 * growth_rate) 54 65 interval = setInterval(update_value, secs_per_pixel * 1000) 55 66 } 56 - $: if (growth_rate && bar) { 57 - start_growing() 58 - } 67 + run(() => { 68 + if (growth_rate && bar) { 69 + start_growing() 70 + } 71 + }); 59 72 60 73 onDestroy(() => { 61 74 if (interval) clearInterval(interval) ··· 63 76 </script> 64 77 65 78 <svelte:window 66 - on:mousemove={(e) => { 79 + onmousemove={(e) => { 67 80 if (dragging) { 68 81 apply(e) 69 82 } 70 83 }} 71 - on:mouseup={(e) => { 84 + onmouseup={(e) => { 72 85 if (dragging) { 73 86 dragging = false 74 87 apply(e) ··· 78 91 /> 79 92 <!-- If we had an <input>, it would cause a reflow every time the value updates. Instead of that, we CSS and mouse events. --> 80 93 <!-- We also don't use the Web Animation API, because somehow that had way higher CPU usage for me --> 81 - <div class="slider{` ${klass}`.trimEnd()}" {...$$restProps}> 82 - <!-- svelte-ignore a11y-no-static-element-interactions --> 94 + <div class="slider{` ${klass}`.trimEnd()}" {...rest}> 95 + <!-- svelte-ignore a11y_no_static_element_interactions --> 83 96 <!-- Make sure it has enough padding for the thumb to not overflow --> 84 97 <div 85 98 class="group flex h-5 w-full items-center justify-center overflow-hidden p-2" 86 - on:mousedown={(e) => { 99 + onmousedown={(e) => { 87 100 apply(e) 88 101 dragging = true 89 102 }}
+53 -43
src/components/TrackInfo.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 export type TrackInfoList = { 3 3 ids: TrackID[] 4 4 index: number ··· 18 18 </script> 19 19 20 20 <script lang="ts"> 21 + import { run, self } from 'svelte/legacy'; 22 + 21 23 import { check_shortcut } from '@/lib/helpers' 22 24 import Button from './Button.svelte' 23 25 import type { Track, TrackID } from '../../ferrum-addon' ··· 40 42 current_list.set(null) 41 43 } 42 44 let id: TrackID 43 - let track: Track 45 + let track: Track = $state() 44 46 type ImageStuff = { 45 47 index: number 46 48 totalImages: number ··· 48 50 objectUrl: string 49 51 } 50 52 /** Undefined when loading, null when no image exists */ 51 - let image: ImageStuff | null | undefined 53 + let image: ImageStuff | null | undefined = $state() 52 54 53 - $: if ($current_list) open_index($current_list) 54 55 function open_index(list: TrackInfoList) { 55 56 id = list.ids[list.index] 56 57 track = get_track(list.ids[list.index]) ··· 101 102 } 102 103 103 104 let image_edited = false 104 - let name = '' 105 - let artist = '' 106 - let album_name = '' 107 - let album_artist = '' 108 - let composer = '' 109 - let grouping = '' 110 - let genre = '' 111 - let year = '' 112 - $: year = uint_filter(year) 113 - let track_num = '' 114 - let track_count = '' 115 - let disc_num = '' 116 - let disc_count = '' 117 - let bpm = '' 118 - let compilation = false 119 - let rating = 0 120 - let liked = false 121 - let play_count = 0 122 - let comments = '' 105 + let name = $state('') 106 + let artist = $state('') 107 + let album_name = $state('') 108 + let album_artist = $state('') 109 + let composer = $state('') 110 + let grouping = $state('') 111 + let genre = $state('') 112 + let year = $state('') 113 + let track_num = $state('') 114 + let track_count = $state('') 115 + let disc_num = $state('') 116 + let disc_count = $state('') 117 + let bpm = $state('') 118 + let compilation = $state(false) 119 + let rating = $state(0) 120 + let liked = $state(false) 121 + let play_count = $state(0) 122 + let comments = $state('') 123 123 function set_info(track: Track) { 124 124 image_edited = false 125 125 name = track.name ··· 141 141 play_count = track.playCount || 0 142 142 comments = to_string(track.comments || '') 143 143 } 144 - $: if (track) set_info(track) 145 144 146 145 function is_edited() { 147 146 const is_unedited = ··· 227 226 } 228 227 } 229 228 230 - let droppable = false 229 + let droppable = $state(false) 231 230 const allowed_mimes = ['image/jpeg', 'image/png'] 232 231 function get_file_path(e: DragEvent): string | null { 233 232 if (e.dataTransfer && has_file(e)) { ··· 323 322 } 324 323 } 325 324 } 325 + run(() => { 326 + if ($current_list) open_index($current_list) 327 + }); 328 + run(() => { 329 + year = uint_filter(year) 330 + }); 331 + run(() => { 332 + if (track) set_info(track) 333 + }); 326 334 </script> 327 335 328 - <svelte:window on:keydown={keydown} /> 329 - <svelte:body on:keydown|self={keydown_none_selected} on:paste={cover_paste} /> 336 + <svelte:window onkeydown={keydown} /> 337 + <svelte:body onkeydown={self(keydown_none_selected)} onpaste={cover_paste} /> 330 338 <Modal on_cancel={cancel} cancel_on_escape form={save}> 331 339 <main class="modal"> 332 340 <div class="header" class:has-subtitle={image && image.totalImages >= 2}> ··· 334 342 class="cover-area" 335 343 class:droppable 336 344 tabindex="0" 337 - on:keydown={cover_keydown} 345 + onkeydown={cover_keydown} 338 346 role="button" 339 347 aria-label="Cover artwork" 340 348 > 341 - <!-- svelte-ignore a11y-no-static-element-interactions --> 349 + <!-- svelte-ignore a11y_no_static_element_interactions --> 342 350 <div 343 351 class="cover" 344 - on:dragenter={drag_enter_or_over} 345 - on:dragover={drag_enter_or_over} 346 - on:dragleave={drag_leave} 347 - on:drop={drop} 348 - on:dblclick={pick_cover} 352 + ondragenter={drag_enter_or_over} 353 + ondragover={drag_enter_or_over} 354 + ondragleave={drag_leave} 355 + ondrop={drop} 356 + ondblclick={pick_cover} 349 357 > 350 358 {#if image} 351 359 <img class="outline-element" alt="" src={image.objectUrl} /> ··· 369 377 {@const image_index = image.index} 370 378 <div class="cover-subtitle"> 371 379 <div class="arrow" class:unclickable={image_index <= 0}> 372 - <!-- svelte-ignore a11y-click-events-have-key-events --> 380 + <!-- svelte-ignore a11y_click_events_have_key_events --> 373 381 <svg 374 - on:click={prev_image} 382 + onclick={prev_image} 375 383 tabindex="-1" 376 384 role="button" 377 385 aria-label="Previous image" ··· 393 401 {image.index + 1} / {image.totalImages} 394 402 </div> 395 403 <div class="arrow" class:unclickable={image_index >= image.totalImages - 1}> 396 - <!-- svelte-ignore a11y-click-events-have-key-events --> 404 + <!-- svelte-ignore a11y_click_events_have_key_events --> 397 405 <svg 398 - on:click={next_image} 406 + onclick={next_image} 399 407 tabindex="-1" 400 408 role="button" 401 409 aria-label="Next image" ··· 424 432 <div class="spacer"></div> 425 433 <div class="row"> 426 434 <div class="label">Title</div> 427 - <!-- svelte-ignore a11y-autofocus --> 435 + <!-- svelte-ignore a11y_autofocus --> 428 436 <input type="text" bind:value={name} autofocus /> 429 437 </div> 430 438 <div class="row"> ··· 489 497 </div> 490 498 <div class="spacer"></div> 491 499 </main> 492 - <svelte:fragment slot="buttons"> 493 - <Button secondary on:click={cancel}>Cancel</Button> 494 - <Button type="submit" on:click={() => save()}>Save</Button> 495 - </svelte:fragment> 500 + {#snippet buttons()} 501 + 502 + <Button secondary on:click={cancel}>Cancel</Button> 503 + <Button type="submit" on:click={() => save()}>Save</Button> 504 + 505 + {/snippet} 496 506 </Modal> 497 507 498 508 <style lang="sass">
+129 -103
src/components/TrackList.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 export const sort_key = writable('index') 3 3 export const sort_desc = writable(true) 4 4 export let current_playlist_id = writable('') ··· 18 18 </script> 19 19 20 20 <script lang="ts"> 21 + import { self } from 'svelte/legacy' 22 + 21 23 import { 22 24 filter, 23 25 move_tracks, ··· 48 50 import { get_flattened_tracklists, handle_selected_tracks_action } from '@/lib/menus' 49 51 import type { SelectedTracksAction } from '@/electron/typed_ipc' 50 52 51 - let tracklist_element: HTMLDivElement 53 + let tracklist_element: HTMLDivElement = $state() 52 54 53 - export let params: { playlist_id: string } 54 - $: $current_playlist_id = params.playlist_id 55 + interface Props { 56 + params: { playlist_id: string } 57 + } 55 58 56 - let tracks_page = get_tracks_page({ 57 - playlistId: params.playlist_id, 58 - filterQuery: $filter, 59 - sortKey: $sort_key, 60 - sortDesc: $sort_desc, 61 - groupAlbumTracks: $group_album_tracks, 59 + let { params }: Props = $props() 60 + $effect(() => { 61 + $current_playlist_id = params.playlist_id 62 62 }) 63 - // eslint-disable-next-line no-constant-condition 64 - $: if ($tracklist_updated || $tracks_updated || true) { 65 - tracks_page = get_tracks_page({ 63 + 64 + let tracks_page = $state( 65 + get_tracks_page({ 66 66 playlistId: params.playlist_id, 67 67 filterQuery: $filter, 68 68 sortKey: $sort_key, 69 69 sortDesc: $sort_desc, 70 70 groupAlbumTracks: $group_album_tracks, 71 - }) 72 - } 73 - $: $tracks_page_item_ids = tracks_page.itemIds 71 + }), 72 + ) 73 + $effect(() => { 74 + if ($tracklist_updated || $tracks_updated || true) { 75 + tracks_page = get_tracks_page({ 76 + playlistId: params.playlist_id, 77 + filterQuery: $filter, 78 + sortKey: $sort_key, 79 + sortDesc: $sort_desc, 80 + groupAlbumTracks: $group_album_tracks, 81 + }) 82 + } 83 + }) 84 + $effect(() => { 85 + $tracks_page_item_ids = tracks_page.itemIds 86 + }) 74 87 75 88 function handle_action(action: SelectedTracksAction) { 76 89 if (selection.items.size === 0) { ··· 105 118 } 106 119 }, 107 120 }) 108 - $: selection.update_all_items(tracks_page.itemIds) 121 + $effect.pre(() => { 122 + selection.update_all_items(tracks_page.itemIds) 123 + }) 109 124 110 125 const track_action_unlisten = ipc_listen('selected_tracks_action', (_, action) => { 111 126 if (tracklist_element.contains(document.activeElement)) { ··· 175 190 new_playback_instance(all_track_ids, index) 176 191 } 177 192 178 - let drag_line: HTMLElement 193 + let drag_line: HTMLElement = $state() 179 194 let drag_item_ids: ItemId[] = [] 180 195 function on_drag_start(e: DragEvent) { 181 196 if (e.dataTransfer) { ··· 195 210 e.dataTransfer.setData('ferrum.tracks', '') 196 211 } 197 212 } 198 - let drag_to_index: null | number = null 213 + let drag_to_index: null | number = $state(null) 199 214 function on_drag_over(e: DragEvent, index: number) { 200 215 if ( 201 216 !$sort_desc || ··· 244 259 } 245 260 } 246 261 247 - let virtual_list: VirtualListBlock<ItemId> 262 + let virtual_list: VirtualListBlock<ItemId> = $state() 248 263 249 - $: if (virtual_list) { 250 - virtual_list.refresh() 251 - } 264 + $effect(() => { 265 + if (virtual_list) { 266 + virtual_list.refresh() 267 + } 268 + }) 252 269 253 - let scroll_container: HTMLElement 270 + let scroll_container: HTMLElement = $state() 254 271 onMount(() => { 255 272 tracklist_actions.scroll_to_index = virtual_list.scroll_to_index 256 273 }) ··· 315 332 'dateAdded', 316 333 'year', 317 334 ] 318 - let columns: Column[] = load_columns() 335 + let columns: Column[] = $state(load_columns()) 319 336 onMount(() => { 320 337 columns = load_columns() 321 338 }) ··· 378 395 }), 379 396 ) 380 397 381 - let col_container: HTMLElement 382 - let col_drag_line: HTMLElement 398 + let col_container: HTMLElement = $state() 399 + let col_drag_line: HTMLElement = $state() 383 400 let col_drag_index: number | null = null 384 401 function on_col_drag_start(e: DragEvent, index: number) { 385 402 if (e.dataTransfer) { 386 403 col_drag_index = index 387 404 } 388 405 } 389 - let col_drag_to_index: null | number = null 406 + let col_drag_to_index: null | number = $state(null) 390 407 function on_col_drag_over(e: DragEvent, index: number) { 391 408 if (col_drag_index !== null && e.currentTarget instanceof HTMLElement) { 392 409 e.preventDefault() ··· 428 445 bind:this={tracklist_element} 429 446 class="tracklist h-full" 430 447 role="table" 431 - on:dragleave={() => (drag_to_index = null)} 448 + ondragleave={() => (drag_to_index = null)} 432 449 > 433 - <!-- svelte-ignore a11y-interactive-supports-focus --> 450 + <!-- svelte-ignore a11y_interactive_supports_focus --> 434 451 <div 435 452 class="row table-header border-b border-b-slate-500/30" 436 453 class:desc={$sort_desc} 437 454 role="row" 438 - on:contextmenu={on_column_context_menu} 439 - on:dragleave={() => (col_drag_to_index = null)} 455 + oncontextmenu={on_column_context_menu} 456 + ondragleave={() => (col_drag_to_index = null)} 440 457 bind:this={col_container} 441 458 > 442 459 {#each columns as column, i} 443 - <!-- svelte-ignore a11y-interactive-supports-focus --> 444 - <!-- svelte-ignore a11y-click-events-have-key-events --> 460 + <!-- svelte-ignore a11y_interactive_supports_focus --> 461 + <!-- svelte-ignore a11y_click_events_have_key_events --> 445 462 <div 446 463 class="c {column.key}" 447 464 class:sort={$sort_key === column.key} 448 465 style:width="{column.width}px" 449 466 style:translate="{column.offset}px 0" 450 467 role="button" 451 - on:click={() => { 468 + onclick={() => { 452 469 if (tracks_page.playlistKind === 'special' && column.key === 'index') { 453 470 return 454 471 } else if (column.key === 'image') { ··· 462 479 } 463 480 }} 464 481 draggable="true" 465 - on:dragstart={(e) => on_col_drag_start(e, i)} 466 - on:dragend={col_drag_end_handler} 467 - on:dragover={(e) => on_col_drag_over(e, i)} 468 - on:drop={col_drop_handler} 482 + ondragstart={(e) => on_col_drag_start(e, i)} 483 + ondragend={col_drag_end_handler} 484 + ondragover={(e) => on_col_drag_over(e, i)} 485 + ondrop={col_drop_handler} 469 486 > 470 487 <span>{column.key === 'image' ? '' : column.name}</span> 471 488 </div> ··· 476 493 bind:this={col_drag_line} 477 494 ></div> 478 495 </div> 479 - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 480 - <!-- svelte-ignore a11y-no-static-element-interactions --> 496 + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 497 + <!-- svelte-ignore a11y_no_static_element_interactions --> 481 498 <div 482 499 bind:this={scroll_container} 483 500 class="main-focus-element relative h-full overflow-y-auto outline-none" 484 501 tabindex="0" 485 - on:mousedown|self={() => selection.clear()} 486 - on:keydown={scroll_container_keydown} 487 - on:keydown={keydown} 502 + onmousedown={(e) => { 503 + if (e.target === e.currentTarget) { 504 + selection.clear() 505 + } 506 + }} 507 + onkeydown={(e) => { 508 + scroll_container_keydown(e) 509 + keydown(e) 510 + }} 488 511 > 489 512 <VirtualListBlock 490 513 bind:this={virtual_list} 491 514 items={tracks_page.itemIds} 492 515 item_height={24} 493 516 {scroll_container} 494 - let:visible_indexes 495 517 buffer={5} 496 518 > 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> 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 552 + class="playing-icon inline" 553 + xmlns="http://www.w3.org/2000/svg" 554 + height="24" 555 + viewBox="0 0 24 24" 556 + width="24" 557 + > 558 + <path d="M0 0h24v24H0z" fill="none" /> 559 + <path 560 + 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 + /> 562 + </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} /> 540 574 {:else} 541 - {i + 1} 575 + {track[column.key] || ''} 542 576 {/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} /> 549 - {:else} 550 - {track[column.key] || ''} 551 - {/if} 552 - </div> 553 - {/each} 554 - </div> 555 - {/if} 556 - {/each} 577 + </div> 578 + {/each} 579 + </div> 580 + {/if} 581 + {/each} 582 + {/snippet} 557 583 </VirtualListBlock> 558 584 <div class="drag-line" class:hidden={drag_to_index === null} bind:this={drag_line}></div> 559 585 </div>
+31 -25
src/components/VirtualListBlock.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 export function scroll_container_keydown(e: KeyboardEvent & { currentTarget: HTMLElement }) { 3 3 let prevent = true 4 4 if (e.key === 'Home') e.currentTarget.scrollTop = 0 ··· 13 13 <script lang="ts" generics="T"> 14 14 import { onDestroy } from 'svelte' 15 15 16 - export let items: T[] 17 - export let item_height: number 18 - /** Must be a positioned element, like `position: relative` */ 19 - export let scroll_container: HTMLElement 20 - export let buffer = 3 16 + interface Props { 17 + items: T[] 18 + item_height: number 19 + /** Must be a positioned element, like `position: relative` */ 20 + scroll_container: HTMLElement 21 + buffer?: number 22 + children?: import('svelte').Snippet<[any]> 23 + } 21 24 22 - $: height = items.length * item_height 23 - $: buffer_height = buffer * item_height 25 + let { items, item_height, scroll_container, buffer = 3, children }: Props = $props() 24 26 25 - let main_element: HTMLDivElement 27 + let main_element: HTMLDivElement = $state() 26 28 let start_pixel = 0 27 29 let start_index = 0 28 30 let visible_count = 0 29 - let visible_indexes: number[] = [] 30 - 31 - $: { 32 - items, item_height, buffer 33 - if (scroll_container && main_element) refresh() 34 - } 31 + let visible_indexes: number[] = $state([]) 35 32 36 33 const resize_observer = new ResizeObserver(refresh) 37 - $: observe(scroll_container) 38 34 function observe(scroll_container: HTMLElement | undefined) { 39 35 resize_observer.disconnect() 40 36 if (scroll_container) { ··· 42 38 } 43 39 } 44 40 45 - let ticking = false 41 + let frame: number | null = null 46 42 export function refresh() { 47 - if (ticking || !main_element || !scroll_container) { 43 + if (frame !== null || !main_element || !scroll_container) { 48 44 return 49 45 } 50 - ticking = true 51 - requestAnimationFrame(() => { 46 + frame = requestIdleCallback(() => { 47 + frame = null 52 48 let element_top = main_element.offsetTop 53 49 let offset_parent = main_element.offsetParent 54 50 while (offset_parent !== scroll_container && offset_parent instanceof HTMLElement) { ··· 100 96 // add new visible indexes 101 97 visible_indexes.push(...new_visible_indexes) 102 98 visible_indexes = visible_indexes 103 - 104 - ticking = false 105 99 }) 106 100 } 107 - 108 - $: apply_scroll_event_handler(scroll_container) 109 101 110 102 let scroll_event_element: HTMLElement | undefined = scroll_container 111 103 function apply_scroll_event_handler(container: HTMLElement | undefined) { ··· 129 121 dummy.scrollIntoView({ behavior: 'instant', block: 'nearest' }) 130 122 dummy.remove() 131 123 } 124 + let height = $derived(items.length * item_height) 125 + let buffer_height = $derived(buffer * item_height) 126 + $effect(() => { 127 + items 128 + item_height 129 + buffer 130 + if (scroll_container && main_element) refresh() 131 + }) 132 + $effect(() => { 133 + observe(scroll_container) 134 + }) 135 + $effect(() => { 136 + apply_scroll_event_handler(scroll_container) 137 + }) 132 138 </script> 133 139 134 140 <div bind:this={main_element} style:height={items.length * item_height + 'px'}> 135 - <slot {visible_indexes} /> 141 + {@render children?.({ visible_indexes })} 136 142 </div>
+10 -5
src/lib/Route.svelte
··· 2 2 import type { SvelteComponent } from 'svelte' 3 3 import { url_pathname } from './router' 4 4 5 - export let route: string 6 - $: route_segments = route.split('/') 7 - $: params = parse($url_pathname) 8 5 9 - export let component: typeof SvelteComponent<Record<string, unknown>, Record<string, unknown>> 6 + interface Props { 7 + route: string; 8 + component: typeof SvelteComponent<Record<string, unknown>, Record<string, unknown>>; 9 + } 10 + 11 + let { route, component }: Props = $props(); 10 12 11 13 function parse(pathname: string) { 12 14 const params: Record<string, string> = {} ··· 25 27 } 26 28 return params 27 29 } 30 + let route_segments = $derived(route.split('/')) 31 + let params = $derived(parse($url_pathname)) 28 32 </script> 29 33 30 34 {#if params} 31 - <svelte:component this={component} {params} /> 35 + {@const SvelteComponent_1 = component} 36 + <SvelteComponent_1 {params} /> 32 37 {/if}
+1 -1
src/lib/helpers.ts
··· 10 10 return mins + ':' + secs_text 11 11 } 12 12 13 - export function format_date(timestamp: number) { 13 + export async function format_date(timestamp: number) { 14 14 const date = new Date(timestamp) 15 15 const month = date.getMonth() + 1 16 16 const month_text = (month < 10 ? '0' : '') + month