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

Add images

Kasper (May 28, 2025, 6:01 AM +0200) 31b072bc 3923f92c

+93 -121
-111
src/components/Cover.svelte
··· 1 - <script lang="ts"> 2 - import { paths } from '@/lib/data' 3 - import type { Track } from '../../ferrum-addon' 4 - import { ipc_renderer, join_paths } from '@/lib/window' 5 - 6 - export let track: Track 7 - $: src = 8 - 'app://trackimg?path=' + 9 - encodeURIComponent(join_paths(paths.tracksDir, track.file)) + 10 - '&cache_db_path=' + 11 - encodeURIComponent(paths.cacheDb) + 12 - '&date_modified=' + 13 - encodeURIComponent(track.dateModified) 14 - 15 - let error: { src: string; message: 404 | string | null } | false | null = null 16 - </script> 17 - 18 - {#if error} 19 - {#if error.message === null || error.message === 404} 20 - <svg 21 - class="cover" 22 - xmlns="http://www.w3.org/2000/svg" 23 - width="24" 24 - height="24" 25 - viewBox="0 0 24 24" 26 - > 27 - <path 28 - d="M23 0l-15.996 3.585v13.04c-2.979-.589-6.004 1.671-6.004 4.154 0 2.137 1.671 3.221 3.485 3.221 2.155 0 4.512-1.528 4.515-4.638v-10.9l12-2.459v8.624c-2.975-.587-6 1.664-6 4.141 0 2.143 1.715 3.232 3.521 3.232 2.14 0 4.476-1.526 4.479-4.636v-17.364z" 29 - /> 30 - </svg> 31 - {:else} 32 - {@const error_msg = error.message} 33 - <!-- svelte-ignore a11y-click-events-have-key-events --> 34 - <!-- svelte-ignore a11y-no-static-element-interactions --> 35 - <div 36 - class="h-full cursor-pointer" 37 - title={error.message} 38 - on:mousedown|stopPropagation 39 - on:click={() => { 40 - ipc_renderer.invoke('showMessageBox', false, { 41 - type: 'error', 42 - message: 'Failed to load cover', 43 - detail: error_msg, 44 - }) 45 - }} 46 - > 47 - <svg 48 - class="cover error" 49 - xmlns="http://www.w3.org/2000/svg" 50 - height="24px" 51 - width="24px" 52 - viewBox="0 0 24 24" 53 - fill="#e8eaed" 54 - ><path d="M0 0h24v24H0z" fill="none" /><path 55 - d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" 56 - /></svg 57 - > 58 - </div> 59 - {/if} 60 - {:else} 61 - <img 62 - class="cover" 63 - class:invisible={error === null} 64 - {src} 65 - alt="" 66 - on:load={() => { 67 - error = false 68 - }} 69 - on:error={async () => { 70 - // Yes this is dumb, but there's no way to get an error code from <img src="" /> 71 - error = { message: null, src } 72 - try { 73 - const response = await fetch(src) 74 - if (src !== error.src) { 75 - return 76 - } 77 - if (response.status === 404) { 78 - error.message = 404 79 - } else { 80 - error.message = await response.text() 81 - console.log(`Failed to load cover (${response.status}): ${error.message}`) 82 - } 83 - } catch (_) { 84 - error.message = 'network' 85 - } 86 - }} 87 - /> 88 - {/if} 89 - 90 - <style lang="sass"> 91 - .cover 92 - width: 18px 93 - min-width: 18px 94 - height: 18px 95 - min-height: 18px 96 - margin: 3px 0px 97 - pointer-events: none 98 - img 99 - object-fit: contain 100 - svg 101 - padding: 5px 102 - box-sizing: border-box 103 - background: var(--empty-cover-bg-color) 104 - fill: var(--empty-cover-color) 105 - border-radius: 2px 106 - .error 107 - fill: #ef4444 108 - background: transparent 109 - padding: 3px 110 - display: inline-block 111 - </style>
+93 -10
src/components/TrackList.svelte
··· 31 31 save_view_options, 32 32 get_track_by_item_id, 33 33 view_options, 34 + paths, 34 35 } from '@/lib/data' 35 36 import { new_playback_instance, playing_id } from '../lib/player' 36 37 import { get_duration, format_date, check_mouse_shortcut, check_shortcut } from '../lib/helpers' 37 38 import { tracklist_actions } from '../lib/page' 38 - import { ipc_listen, ipc_renderer } from '../lib/window' 39 + import { ipc_listen, ipc_renderer, join_paths } from '../lib/window' 39 40 import { onDestroy, onMount } from 'svelte' 40 41 import { dragged } from '../lib/drag-drop' 41 42 import * as dragGhost from './DragGhost.svelte' 42 43 import type { ItemId, Track, TracksPage } from 'ferrum-addon/addon' 43 - import Cover from './Cover.svelte' 44 44 import Header from './Header.svelte' 45 45 import { writable } from 'svelte/store' 46 46 import { SvelteSelection } from '@/lib/selection' 47 47 import { get_flattened_tracklists, handle_selected_tracks_action } from '@/lib/menus' 48 48 import type { SelectedTracksAction } from '@/electron/typed_ipc' 49 49 import { defineCustomElement } from '@revolist/revogrid/standalone' 50 - import type { ColumnRegular } from '@revolist/revogrid' 50 + import type { 51 + BeforeRowRenderEvent, 52 + ColumnCollection, 53 + ColumnRegular, 54 + RevoGridCustomEvent, 55 + } from '@revolist/revogrid' 51 56 52 57 defineCustomElement() 53 58 ··· 298 303 { name: 'Time', prop: 'duration', width_px: 50 }, 299 304 { name: 'Genre', prop: 'genre', width_pct: 0.65 }, 300 305 { name: 'Grouping', prop: 'grouping', width_pct: 0.65 }, 301 - { name: 'Image', prop: 'image', width_px: 28 }, 306 + { 307 + name: 'Image', 308 + prop: 'image', 309 + width_px: 28, 310 + 311 + cellTemplate(create_element, props) { 312 + const src = props.value 313 + return create_element('img', { 314 + src: src, 315 + class: 'invisible', 316 + onload(e: Event & { currentTarget: EventTarget & HTMLImageElement }) { 317 + if (src !== e.currentTarget.src) return 318 + const img = e.currentTarget 319 + img.classList.remove('invisible', 'error', 'missing') 320 + img.removeAttribute('title') 321 + }, 322 + async onerror(e: Event & { currentTarget: EventTarget & HTMLImageElement }) { 323 + // Yes this is dumb, but there's no way to get an error code from <img src="" /> 324 + const img = e.currentTarget 325 + if (src !== img.src) return 326 + img.classList.remove('invisible') 327 + img.removeAttribute('title') 328 + // 404 is an common expected result, so we start with that 329 + img.classList.add('error', 'missing') 330 + const new_error = await fetch(src) 331 + .then(async (response) => { 332 + if (response.status === 404) { 333 + return '404' 334 + } 335 + const response_text = await response.text() 336 + console.log(`Failed to load cover (${response.status}): ${response_text}`) 337 + return response_text 338 + }) 339 + .catch(() => { 340 + return 'network' 341 + }) 342 + if (src !== img.src) return 343 + img.classList.toggle('missing', new_error === '404') 344 + img.title = new_error 345 + }, 346 + onmousedown(e: MouseEvent) { 347 + e.stopPropagation() 348 + }, 349 + onclick(e: MouseEvent & { currentTarget: EventTarget & HTMLImageElement }) { 350 + const error = e.currentTarget.title 351 + if (e.currentTarget.classList.contains('error')) { 352 + ipc_renderer.invoke('showMessageBox', false, { 353 + type: 'error', 354 + message: 'Failed to load cover', 355 + detail: error, 356 + }) 357 + } 358 + }, 359 + }) 360 + }, 361 + }, 302 362 // { name: 'ImportedFrom', prop: 'importedFrom' }, 303 363 // { name: 'Liked', prop: 'liked' }, 304 364 { name: 'Name', prop: 'name', width_pct: 1.7 }, ··· 444 504 duration: track.duration ? get_duration(track.duration) : '', 445 505 dateAdded: format_date(track.dateAdded), 446 506 index: i + 1, 507 + image: 508 + 'app://trackimg/?path=' + 509 + encodeURIComponent(join_paths(paths.tracksDir, track.file)) + 510 + '&cache_db_path=' + 511 + encodeURIComponent(paths.cacheDb) + 512 + '&date_modified=' + 513 + encodeURIComponent(track.dateModified), 447 514 row_class: row_class.trim(), 448 515 } 449 516 }) ··· 688 755 on:drop={drop_handler} 689 756 on:dragend={drag_end_handler} 690 757 > 691 - {#each columns as column} 692 - <div class="c {column.key}" style:width={column.width}> 693 - {:else if column.key === 'image'} 694 - <Cover {track} /> 695 - {/if} 696 - </div> 697 758 {/each} 698 759 </div> 699 760 {/if} ··· 724 785 padding-left: 0px 725 786 padding-right: 10px 726 787 text-align: right 788 + .image 789 + display: flex 790 + align-items: center 791 + img 792 + display: block 793 + object-fit: contain 794 + height: 24px 795 + width: 18px 796 + padding: 3px 0 797 + .error::before 798 + content: '' 799 + cursor: pointer 800 + display: block 801 + width: 100% 802 + height: 100% 803 + background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="%23ef4444"><path d="M9,4c-2.76,0 -5,2.24 -5,5c0,2.76 2.24,5 5,5c2.76,0 5,-2.24 5,-5c0,-2.76 -2.24,-5 -5,-5Zm0.5,7.5l-1,0l0,-1l1,0l0,1Zm0,-2l-1,0l0,-3l1,0l0,3Z"/></svg>') 804 + .error.missing::before 805 + cursor: default 806 + background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><rect x="0" y="0" width="18" height="18" fill="%232b2c31" rx="2" ry="2"/><path fill="%2345464a" d="M12.667,5l-5.332,1.195l-0,4.347c-0.993,-0.197 -2.002,0.557 -2.002,1.384c0,0.713 0.557,1.074 1.162,1.074c0.718,-0 1.504,-0.509 1.505,-1.546l0,-3.633l4,-0.82l0,2.875c-0.992,-0.196 -2,0.554 -2,1.38c0,0.714 0.572,1.077 1.174,1.077c0.713,0 1.492,-0.508 1.493,-1.545l-0,-5.788Z"/></svg>'); 807 + .loading 808 + visibility: hidden 727 809 .rgRow 728 810 line-height: 24px 729 811 font-size: 12px ··· 742 824 background-position-y: center 743 825 background-position-x: calc(100% - 10px) 744 826 color: transparent 827 + // We use background-image because setting it with innerHTML breaks row recycling updates 745 828 // #00ffff 746 829 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2300ffff'%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3Cpath 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'/%3E%3C/svg%3E") 747 830 .playing.selected > .index