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

Keep selection on page update

Kasper (Sep 26, 2024, 3:20 AM +0200) 682d59f7 3f573e8b

+153 -90
+1
CHANGELOG.md
··· 13 13 - Add queue panel slide-out transition 14 14 - Add `Ctrl+Tab` and `Ctrl+Shift+Tab` to select next/previous playlist 15 15 - Add `Back` and `Forward` shortcuts 16 + - Selection no longer gets cleared when the page updates 16 17 - Make time & volume sliders look ok 17 18 - Small design updates to tracklist header & filter textbox 18 19 - Respect user's locale time
+7 -1
ferrum-addon/addon.d.ts
··· 118 118 playlistName: string 119 119 playlistDescription?: string 120 120 playlistLength: number 121 - trackIds: Array<TrackID> 121 + itemIds: Array<ItemId> 122 122 } 123 123 export declare function get_tracks_page(options: TracksPageOptions): TracksPage 124 124 export const enum ViewAs { ··· 171 171 comments: string 172 172 } 173 173 export declare function get_track(id: string): Track 174 + export interface KeyedTrack { 175 + id: TrackID 176 + track: Track 177 + } 178 + export declare function get_track_by_item_id(itemId: ItemId): KeyedTrack 179 + export declare function get_track_ids(itemIds: Array<ItemId>): Array<TrackID> 174 180 export declare function track_exists(id: string): boolean 175 181 export declare function add_play(trackId: string): void 176 182 export declare function add_skip(trackId: string): void
+13 -9
src-native/filter.rs
··· 1 - use crate::library_types::{Library, TrackID}; 1 + use crate::library_types::{ItemId, Library, TRACK_ID_MAP}; 2 2 use rayon::prelude::*; 3 3 use std::str::Chars; 4 4 use std::time::Instant; ··· 57 57 } 58 58 } 59 59 60 - fn filter_keyword(ids: Vec<TrackID>, keyword: &str, library: &Library) -> Vec<TrackID> { 61 - let tracks = library.get_tracks(); 60 + fn filter_keyword(ids: Vec<ItemId>, keyword: &str, library: &Library) -> Vec<ItemId> { 61 + let id_map = TRACK_ID_MAP.read().unwrap(); 62 62 let filtered_tracks: Vec<_> = ids 63 63 .into_par_iter() 64 64 .with_min_len(2000) 65 - .filter(|id| { 66 - let track = tracks.get(id).expect("Track ID not found"); 65 + .filter(|item_id| { 66 + let track_id = &id_map[*item_id as usize]; 67 + let track = match library.get_track(track_id) { 68 + Ok(track) => track, 69 + Err(_) => panic!("Track ID {} not found", track_id), 70 + }; 67 71 let is_match = find_match(&track.name, keyword) 68 72 || find_match(&track.artist, keyword) 69 73 || find_match_opt(&track.albumName, keyword) ··· 76 80 filtered_tracks 77 81 } 78 82 79 - pub fn filter(mut track_ids: Vec<TrackID>, query: String, library: &Library) -> Vec<TrackID> { 83 + pub fn filter(mut item_ids: Vec<ItemId>, query: String, library: &Library) -> Vec<ItemId> { 80 84 let now = Instant::now(); 81 85 if query == "" { 82 - return track_ids; 86 + return item_ids; 83 87 } 84 88 let query: String = query.nfc().collect(); 85 89 ··· 87 91 if keyword == "" { 88 92 continue; 89 93 } 90 - track_ids = filter_keyword(track_ids, keyword, &library); 94 + item_ids = filter_keyword(item_ids, keyword, &library); 91 95 } 92 96 println!("Filter: {}ms", now.elapsed().as_millis()); 93 - track_ids 97 + item_ids 94 98 } 95 99 96 100 enum Eq {
+13 -3
src-native/library_types.rs
··· 6 6 use nanoid::nanoid; 7 7 use serde::{Deserialize, Serialize}; 8 8 use std::borrow::Cow; 9 + use std::collections::HashMap; 9 10 use std::path::PathBuf; 10 11 use std::sync::RwLock; 11 12 ··· 13 14 pub struct Library { 14 15 /// DO NOT INSERT DIRECTLY - A corresponding item ID must be generated by .insert_track() 15 16 tracks: LinkedHashMap<TrackID, Track>, 17 + track_item_ids: HashMap<TrackID, ItemId>, 16 18 pub trackLists: TrackLists, 17 19 /// v1 playtime has two issues: 18 20 /// - some durations are double counted (or triple, etc.) ··· 63 65 pub fn init_libary(self) -> Library { 64 66 let mut library = Library { 65 67 tracks: LinkedHashMap::new(), 68 + track_item_ids: HashMap::new(), 66 69 trackLists: self.trackLists.into_owned(), 67 70 v1PlayTime: self.playTime.into_owned(), 68 71 playTime: Vec::new(), ··· 103 106 }; 104 107 track_lists.insert("root".to_string(), TrackList::Special(root)); 105 108 Library { 106 - v1PlayTime: Vec::new(), 107 - playTime: Vec::new(), 108 109 tracks: LinkedHashMap::new(), 110 + track_item_ids: HashMap::new(), 109 111 trackLists: track_lists, 112 + v1PlayTime: Vec::new(), 113 + playTime: Vec::new(), 110 114 } 111 115 } 112 116 #[inline] 113 117 pub fn get_tracks(&self) -> &LinkedHashMap<TrackID, Track> { 114 118 &self.tracks 115 119 } 120 + #[inline] 121 + pub fn get_track_item_ids(&self) -> &HashMap<TrackID, ItemId> { 122 + &self.track_item_ids 123 + } 116 124 pub fn insert_track(&mut self, id: TrackID, track: Track) { 117 125 match self.tracks.entry(id.clone()) { 118 126 Entry::Occupied(_) => panic!("Track ID already exists"), 119 127 Entry::Vacant(entry) => entry.insert(track), 120 128 }; 121 129 let mut track_id_map = TRACK_ID_MAP.write().unwrap(); 122 - track_id_map.push(id); 130 + let item_id: ItemId = track_id_map.len() as u32; 131 + track_id_map.push(id.clone()); 132 + self.track_item_ids.insert(id, item_id); 123 133 } 124 134 pub fn delete_track_and_file(&mut self, id: &TrackID, tracks_dir: &PathBuf) -> UniResult<()> { 125 135 let file_path = {
+14 -25
src-native/page.rs
··· 1 1 use crate::data::Data; 2 2 use crate::data_js::get_data; 3 3 use crate::filter::filter; 4 - use crate::library_types::{ 5 - ItemId, Library, SpecialTrackListName, TrackID, TrackList, TRACK_ID_MAP, 6 - }; 4 + use crate::library_types::{ItemId, Library, SpecialTrackListName, TrackList}; 7 5 use crate::sort::sort; 8 6 use crate::UniResult; 9 7 use napi::{Env, JsUnknown, Result}; ··· 25 23 pub playlist_name: String, 26 24 pub playlist_description: Option<String>, 27 25 pub playlist_length: u32, 28 - pub track_ids: Vec<TrackID>, 26 + pub item_ids: Vec<ItemId>, 29 27 } 30 28 31 29 #[napi(js_name = "get_tracks_page")] ··· 33 31 pub fn get_tracks_page(options: TracksPageOptions, env: Env) -> Result<TracksPage> { 34 32 let data: &mut Data = get_data(&env)?; 35 33 let tracklist = data.library.get_tracklist(&options.playlist_id)?; 36 - let track_ids = sort(options.clone(), &data.library)?; 37 - let tracklist_length = track_ids.len(); 38 - let track_ids = filter(track_ids, options.filter_query, &data.library); 34 + let item_ids = sort(options.clone(), &data.library)?; 35 + let tracklist_length = item_ids.len(); 36 + let item_ids = filter(item_ids, options.filter_query, &data.library); 39 37 let track_page = match tracklist { 40 38 TrackList::Playlist(playlist) => TracksPage { 41 39 playlist_kind: tracklist.kind().to_string(), 42 40 playlist_name: playlist.name.clone(), 43 41 playlist_description: playlist.description.clone(), 44 42 playlist_length: tracklist_length as u32, 45 - track_ids, 43 + item_ids, 46 44 }, 47 45 TrackList::Folder(folder) => TracksPage { 48 46 playlist_kind: tracklist.kind().to_string(), 49 47 playlist_name: folder.name.clone(), 50 48 playlist_description: folder.description.clone(), 51 49 playlist_length: tracklist_length as u32, 52 - track_ids, 50 + item_ids, 53 51 }, 54 52 TrackList::Special(special) => TracksPage { 55 53 playlist_kind: tracklist.kind().to_string(), 56 54 playlist_name: special.name.to_string(), 57 55 playlist_description: None, 58 56 playlist_length: tracklist_length as u32, 59 - track_ids, 57 + item_ids, 60 58 }, 61 59 }; 62 60 Ok(track_page) 63 61 } 64 62 65 - pub fn get_tracklist_track_ids(library: &Library, playlist_id: &str) -> UniResult<Vec<TrackID>> { 63 + pub fn get_tracklist_item_ids(library: &Library, playlist_id: &str) -> UniResult<Vec<ItemId>> { 66 64 match library.get_tracklist(playlist_id)? { 67 - TrackList::Playlist(playlist) => { 68 - let id_map = TRACK_ID_MAP.read().unwrap(); 69 - let ids = playlist 70 - .tracks 71 - .iter() 72 - .map(|item_id| id_map[*item_id as usize].clone()) 73 - .collect(); 74 - Ok(ids) 75 - } 65 + TrackList::Playlist(playlist) => Ok(playlist.tracks.clone()), 76 66 TrackList::Folder(folder) => { 77 - let mut ids: HashSet<TrackID> = HashSet::new(); 67 + let mut ids: HashSet<ItemId> = HashSet::new(); 78 68 for child in &folder.children { 79 - let child_ids = get_tracklist_track_ids(library, &child)?; 69 + let child_ids = get_tracklist_item_ids(library, &child)?; 80 70 ids.extend(child_ids); 81 71 } 82 72 Ok(ids.into_iter().collect()) 83 73 } 84 74 TrackList::Special(special) => match special.name { 85 75 SpecialTrackListName::Root => { 86 - let track_keys = library.get_tracks().keys(); 87 - let ids = track_keys.map(|track| track.to_string()).collect(); 88 - Ok(ids) 76 + let item_ids = library.get_track_item_ids().values().cloned().collect(); 77 + Ok(item_ids) 89 78 } 90 79 }, 91 80 }
+10 -6
src-native/sort.rs
··· 1 1 use crate::library::{get_track_field_type, TrackField}; 2 - use crate::library_types::{Library, Track, TrackID}; 3 - use crate::page::{get_tracklist_track_ids, TracksPageOptions}; 2 + use crate::library_types::{ItemId, Library, Track, TRACK_ID_MAP}; 3 + use crate::page::{get_tracklist_item_ids, TracksPageOptions}; 4 4 use crate::UniResult; 5 5 use alphanumeric_sort::compare_str; 6 6 use std::cmp::Ordering; ··· 85 85 } 86 86 } 87 87 88 - pub fn sort(options: TracksPageOptions, library: &Library) -> UniResult<Vec<TrackID>> { 88 + pub fn sort(options: TracksPageOptions, library: &Library) -> UniResult<Vec<ItemId>> { 89 89 let now = Instant::now(); 90 90 91 - let mut track_ids = get_tracklist_track_ids(library, &options.playlist_id)?; 91 + let id_map = TRACK_ID_MAP.read().unwrap(); 92 + 93 + let mut track_ids = get_tracklist_item_ids(library, &options.playlist_id)?; 92 94 93 95 if options.sort_key == "index" { 94 96 // No need to sort for index. Note: Indexes descend from "first to last", ··· 109 111 _ => false, 110 112 }; 111 113 track_ids.sort_by(|id_a, id_b| { 112 - let track_a = tracks.get(id_a).expect("Track ID non-existant (1)"); 113 - let track_b = tracks.get(id_b).expect("Track ID non-existant (2)"); 114 + let track_id_a = &id_map[*id_a as usize]; 115 + let track_id_b = &id_map[*id_b as usize]; 116 + let track_a = tracks.get(track_id_a).expect("Track ID non-existant (1)"); 117 + let track_b = tracks.get(track_id_b).expect("Track ID non-existant (2)"); 114 118 if subsort_field 115 119 && track_a.albumName.is_some() 116 120 && track_a.albumName == track_b.albumName
+32 -3
src-native/tracks/mod.rs
··· 2 2 use crate::data_js::get_data; 3 3 use crate::get_now_timestamp; 4 4 use crate::js::nerr; 5 - use crate::library_types::{MsSinceUnixEpoch, Track, TrackID}; 5 + use crate::library_types::{ItemId, MsSinceUnixEpoch, Track, TrackID, TRACK_ID_MAP}; 6 6 use napi::{Env, JsArrayBuffer, JsBuffer, JsObject, Result, Task}; 7 7 use std::fs; 8 8 use std::path::{Path, PathBuf}; ··· 24 24 #[allow(dead_code)] 25 25 pub fn get_track(id: String, env: Env) -> Result<Track> { 26 26 let data: &mut Data = get_data(&env)?; 27 - let tracks = &data.library.get_tracks(); 28 - let track = tracks.get(&id).ok_or(nerr("Track ID not found"))?; 27 + let track = data.library.get_track(&id)?; 29 28 Ok(track.clone()) 29 + } 30 + 31 + #[napi(object)] 32 + pub struct KeyedTrack { 33 + pub id: TrackID, 34 + pub track: Track, 35 + } 36 + 37 + #[napi(js_name = "get_track_by_item_id")] 38 + #[allow(dead_code)] 39 + pub fn get_track_by_item_id(item_id: ItemId, env: Env) -> Result<KeyedTrack> { 40 + let data: &mut Data = get_data(&env)?; 41 + let id_map = TRACK_ID_MAP.read().unwrap(); 42 + let track_id = &id_map[item_id as usize]; 43 + let track = data.library.get_track(&track_id)?; 44 + Ok(KeyedTrack { 45 + id: track_id.clone(), 46 + track: track.clone(), 47 + }) 48 + } 49 + 50 + #[napi(js_name = "get_track_ids")] 51 + #[allow(dead_code)] 52 + pub fn get_track_ids(item_ids: Vec<ItemId>) -> Result<Vec<TrackID>> { 53 + let id_map = TRACK_ID_MAP.read().unwrap(); 54 + let track_ids = item_ids.into_iter().map(|item_id| { 55 + let track_id = &id_map[item_id as usize]; 56 + track_id.clone() 57 + }); 58 + Ok(track_ids.collect()) 30 59 } 31 60 32 61 #[napi(js_name = "track_exists")]
+47 -43
src/components/TrackList.svelte
··· 1 1 <script lang="ts" context="module"> 2 - export let current_playlist_id = writable('') 3 2 export const sort_key = writable('index') 4 3 export const sort_desc = writable(true) 4 + export let current_playlist_id = writable('') 5 + current_playlist_id.subscribe((id) => { 6 + if (id === 'root') { 7 + sort_key.set('dateAdded') 8 + sort_desc.set(true) 9 + } else { 10 + sort_key.set('index') 11 + sort_desc.set(true) 12 + } 13 + }) 5 14 export const group_album_tracks = writable(true) 6 15 </script> 7 16 ··· 30 39 import * as dragGhost from './DragGhost.svelte' 31 40 import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' 32 41 import { open_track_info } from './TrackInfo.svelte' 33 - import type { Track } from 'ferrum-addon/addon' 42 + import type { ItemId, Track } from 'ferrum-addon/addon' 34 43 import Cover from './Cover.svelte' 35 44 import Header from './Header.svelte' 36 45 import { writable } from 'svelte/store' ··· 45 54 $current_playlist_id = params.playlist_id 46 55 } 47 56 57 + let tracks_page = methods.get_tracks_page({ 58 + playlistId: params.playlist_id, 59 + filterQuery: $filter, 60 + sortKey: $sort_key, 61 + sortDesc: $sort_desc, 62 + groupAlbumTracks: $group_album_tracks, 63 + }) 48 64 $: tracks_page = methods.get_tracks_page({ 49 65 playlistId: params.playlist_id, 50 66 filterQuery: $filter, ··· 52 68 sortDesc: $sort_desc, 53 69 groupAlbumTracks: $group_album_tracks, 54 70 }) 55 - $: track_indexes = tracks_page.trackIds.map((_, i) => i) 56 71 57 - let selection = new SvelteSelection(track_indexes, { 72 + let selection = new SvelteSelection(tracks_page.itemIds, { 58 73 scroll_to_item(i) { 59 74 tracklist_actions.scroll_to_index?.(i) 60 75 }, 61 76 async on_contextmenu(selected_track_indexes) { 62 - await show_track_menu(tracks_page.trackIds, [...selected_track_indexes], { 63 - editable: tracks_page.playlistKind === 'playlist', 64 - }) 77 + // await show_track_menu(tracks_page.trackIds, [...selected_track_indexes], { 78 + // editable: tracks_page.playlistKind === 'playlist', 79 + // }) 65 80 }, 66 81 }) 67 - $: { 68 - selection.clear() 69 - selection.update_all_items(track_indexes) 70 - } 71 - 72 - function get_selected_track_ids() { 73 - return Array.from(selection.items).map((track_index) => tracks_page.trackIds[track_index]) 74 - } 82 + $: selection.update_all_items(tracks_page.itemIds) 75 83 76 84 const track_action_unlisten = ipc_listen('selectedTracksAction', (_, action) => { 77 - let first_index = selection.find_first_index() 78 - if (first_index === null || !tracklist_element.contains(document.activeElement)) { 85 + let first_item_id = selection.find_first() 86 + if (first_item_id === undefined || !tracklist_element.contains(document.activeElement)) { 79 87 return 80 88 } 81 89 if (action === 'Play Next') { 82 - prepend_to_user_queue(get_selected_track_ids()) 90 + // const selected_item_ids = Array.from(selection.items) 91 + // prepend_to_user_queue(methods.get_track_ids(selected_item_ids)) 83 92 } else if (action === 'Add to Queue') { 84 - append_to_user_queue(get_selected_track_ids()) 93 + // const selected_item_ids = Array.from(selection.items) 94 + // append_to_user_queue(methods.get_track_ids(selected_item_ids)) 85 95 } else if (action === 'Get Info') { 86 - open_track_info(tracks_page.trackIds, first_index) 96 + // open_track_info(tracks_page.trackIds, first_index) 87 97 } else if (action === 'revealTrackFile') { 88 - const track_id = tracks_page.trackIds[first_index] 89 - const track = methods.getTrack(track_id) 98 + const { track } = methods.get_track_by_item_id(first_item_id) 90 99 ipc_renderer.invoke('revealTrackFile', paths.tracksDir, track.file) 91 100 } else if (action === 'Remove from Playlist') { 92 101 // remove_from_playlist(params.playlist_id, item_ids) ··· 225 234 drag_to_index = null 226 235 } 227 236 228 - function get_item(index: number) { 237 + function get_item(item_id: ItemId) { 229 238 try { 230 - const track_id = tracks_page.trackIds[index] 231 - const track = methods.getTrack(track_id) 232 - return { ...track, id: track_id } 239 + return methods.get_track_by_item_id(item_id) 233 240 } catch (_) { 234 - return null 241 + return { id: null, track: null } 235 242 } 236 243 } 237 244 238 - let virtual_list: VirtualListBlock<number> 245 + let virtual_list: VirtualListBlock<ItemId> 239 246 240 247 $: if (virtual_list) { 241 248 virtual_list.refresh() ··· 396 403 } 397 404 </script> 398 405 399 - {#if params.playlist_id === 'root'} 400 - <Header title="Songs" subtitle="{tracks_page.trackIds.length} songs" description={undefined} /> 401 - {:else} 402 - <Header 403 - title={tracks_page.playlistName} 404 - subtitle="{tracks_page.trackIds.length} songs" 405 - description={tracks_page.playlistDescription} 406 - /> 407 - {/if} 406 + <Header 407 + title={params.playlist_id === 'root' ? 'Songs' : tracks_page.playlistName} 408 + subtitle="{tracks_page.itemIds.length} songs" 409 + description={tracks_page.playlistDescription} 410 + /> 408 411 <div 409 412 bind:this={tracklist_element} 410 413 class="tracklist h-full" ··· 458 461 > 459 462 <VirtualListBlock 460 463 bind:this={virtual_list} 461 - items={track_indexes} 464 + items={tracks_page.itemIds} 462 465 item_height={24} 463 466 {scroll_container} 464 - let:item={i} 467 + let:item={item_id} 468 + let:i 465 469 buffer={5} 466 470 > 467 - {@const track = get_item(i)} 471 + {@const { id: track_id, track } = get_item(item_id)} 468 472 {#if track !== null} 469 473 <!-- svelte-ignore a11y-click-events-have-key-events --> 470 474 <!-- svelte-ignore a11y-interactive-supports-focus --> ··· 481 485 on:drop={drop_handler} 482 486 on:dragend={drag_end_handler} 483 487 class:odd={i % 2 === 0} 484 - class:selected={$selection.has(i)} 485 - class:playing={track.id === $playing_id} 488 + class:selected={$selection.has(item_id)} 489 + class:playing={track_id === $playing_id} 486 490 > 487 491 {#each columns as column} 488 492 <div class="c {column.key}" style:width={column.width}> 489 493 {#if column.key === 'index'} 490 - {#if track.id === $playing_id} 494 + {#if track_id === $playing_id} 491 495 <svg 492 496 class="playing-icon inline" 493 497 xmlns="http://www.w3.org/2000/svg"
+6
src/lib/data.ts
··· 193 193 getTrack: (id: TrackID) => { 194 194 return call((data) => data.get_track(id)) 195 195 }, 196 + get_track_by_item_id(item_id: ItemId) { 197 + return call((data) => data.get_track_by_item_id(item_id)) 198 + }, 199 + get_track_ids(item_ids: ItemId[]) { 200 + return call((data) => data.get_track_ids(item_ids)) 201 + }, 196 202 get_tracks_page: (options: TracksPageOptions) => { 197 203 return call((data) => data.get_tracks_page(options)) 198 204 },
+10
src/lib/selection-new.ts
··· 138 138 return item_i 139 139 } 140 140 141 + /** Get first selected item, or `undefined` if selection is empty */ 142 + find_first() { 143 + const item_i = this.all.find((item) => this.items.has(item)) 144 + return item_i 145 + } 146 + 141 147 /** Replace selection with the previous index, like perssing `ArrowUp` in a list. */ 142 148 go_backward() { 143 149 if (this.items.size === 0) { ··· 307 313 308 314 find_first_index() { 309 315 return this.#selection.find_first_index() 316 + } 317 + 318 + find_first() { 319 + return this.#selection.find_first() 310 320 } 311 321 312 322 clear() {