[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 "Show in Playlist" context menu item

Kasper (Mar 8, 2026, 9:45 AM +0100) 9f1ae7b6 367b7303

+131 -15
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 ## Next 4 + - Add "Show in Playlist" context menu item 4 5 - Remember queue on restart 5 6 - Keep scroll position when opening play history 6 7 - Android: Add track streaming search links for Spotify and YouTube Music
+3 -1
ferrum-addon/addon.d.ts
··· 64 64 65 65 export declare function get_track_lists_details(): Record<string, TrackListDetails> 66 66 67 + export declare function get_track_playlist_ids(trackId: TrackID): Array<TrackID> 68 + 67 69 export declare function get_tracks_page(options: TracksPageOptions): TracksPage 68 70 69 71 export declare function import_file(path: string, now: MsSinceUnixEpoch): void ··· 123 125 originalId?: string 124 126 dateImported?: MsSinceUnixEpoch 125 127 dateCreated?: MsSinceUnixEpoch 126 - tracks: Array<ItemId> 128 + tracks: string[] 127 129 } 128 130 129 131 export declare function playlist_filter_duplicates(playlistId: TrackID, ids: Array<string>): Array<TrackID>
+1
src-native/library_types.rs
··· 473 473 deserialize_with = "deserialize_playlist_ids", 474 474 serialize_with = "serialize_playlist_ids" 475 475 )] 476 + #[cfg_attr(feature = "napi", napi(ts_type = "string[]"))] 476 477 pub tracks: Vec<ItemId>, 477 478 } 478 479 impl Playlist {
+29
src-native/playlists.rs
··· 8 8 use anyhow::{Context, Result, bail}; 9 9 use linked_hash_map::LinkedHashMap; 10 10 use napi::{Env, Unknown}; 11 + use rayon::prelude::*; 11 12 use std::collections::{HashMap, HashSet}; 12 13 use std::path::PathBuf; 13 14 ··· 170 171 } 171 172 let track_ids: Vec<TrackID> = track_ids.into_iter().collect(); 172 173 Ok(track_ids) 174 + } 175 + 176 + #[napi(js_name = "get_track_playlist_ids")] 177 + #[allow(dead_code)] 178 + pub fn get_track_playlist_ids(track_id: TrackID, env: Env) -> Result<Vec<TrackID>> { 179 + let data: &Data = get_data(&env); 180 + Ok(get_track_playlist_ids_in_library(&data.library, &track_id)) 181 + } 182 + 183 + pub fn get_track_playlist_ids_in_library(library: &Library, track_id: &str) -> Vec<TrackID> { 184 + let track_id_map = TRACK_ID_MAP.read().unwrap(); 185 + library 186 + .trackLists 187 + .iter() 188 + .collect::<Vec<_>>() 189 + .par_iter() 190 + .filter_map(|(playlist_id, tracklist)| { 191 + let TrackList::Playlist(playlist) = tracklist else { 192 + return None; 193 + }; 194 + for item_id in &playlist.tracks { 195 + if track_id_map[*item_id as usize].as_str() == track_id { 196 + return Some(playlist_id.to_string()); 197 + } 198 + } 199 + None 200 + }) 201 + .collect() 173 202 } 174 203 175 204 #[napi(js_name = "remove_from_playlist")]
+7 -2
src/components/Player.svelte
··· 17 17 import { dragged } from '$lib/drag-drop' 18 18 import * as dragGhost from './DragGhost.svelte' 19 19 import Slider from './Slider.svelte' 20 - import { get_flattened_tracklists, handle_selected_tracks_action } from '$lib/menus' 20 + import { 21 + get_show_in_playlists_tree, 22 + get_tracklists_tree, 23 + handle_selected_tracks_action, 24 + } from '$lib/menus' 21 25 import { ipc_renderer } from '$lib/window' 22 26 import { tracks_page_item_ids } from './TrackList.svelte' 23 27 ··· 29 33 const action = await ipc_renderer.invoke('show_tracks_menu', { 30 34 is_editable_playlist: false, 31 35 queue: false, 32 - lists: get_flattened_tracklists(), 36 + lists: get_tracklists_tree(), 37 + show_in_playlists: get_show_in_playlists_tree([playing.id]), 33 38 }) 34 39 if (action !== null) { 35 40 handle_selected_tracks_action({
+20 -8
src/components/Queue.svelte
··· 18 18 import { cubicOut } from 'svelte/easing' 19 19 import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' 20 20 import type { SelectedTracksAction } from '$electron/typed_ipc' 21 - import { get_flattened_tracklists, handle_selected_tracks_action } from '$lib/menus' 21 + import { 22 + get_show_in_playlists_tree, 23 + get_tracklists_tree, 24 + handle_selected_tracks_action, 25 + } from '$lib/menus' 22 26 import { SvelteSelection } from '$lib/selection' 23 27 24 28 let object_urls: string[] = [] ··· 71 75 autoplay_list.scroll_to_index(index, 40) 72 76 }, 73 77 async on_contextmenu() { 78 + const indexes = selection.get_selected_indexes() 79 + const visible_track_ids = get_visible_track_ids() 80 + const selected_visible_track_ids = indexes.map((i) => visible_track_ids[i]) 74 81 const action = await ipc_renderer.invoke('show_tracks_menu', { 75 82 is_editable_playlist: false, 76 83 queue: true, 77 - lists: get_flattened_tracklists(), 84 + lists: get_tracklists_tree(), 85 + show_in_playlists: get_show_in_playlists_tree(selected_visible_track_ids), 78 86 }) 79 87 if (action !== null) { 80 88 handle_action(action) ··· 83 91 }) 84 92 $: selection.update_all_items(visible_qids) 85 93 94 + function get_visible_track_ids() { 95 + return [ 96 + ...(show_history ? $queue.past : []), 97 + ...(show_history && $queue.current ? [$queue.current.item] : []), 98 + ...$queue.user_queue, 99 + ...$queue.auto_queue, 100 + ].map((item) => item.id) 101 + } 102 + 86 103 function remove_from_queue() { 87 104 if (selection.items.size >= 1) { 88 105 const indexes = selection.get_selected_indexes().map((i) => i + first_visible_index) ··· 127 144 function handle_action(action: SelectedTracksAction) { 128 145 const first_index = selection.find_first_index() 129 146 const indexes = selection.get_selected_indexes() 130 - const visible_track_ids = [ 131 - ...(show_history ? $queue.past : []), 132 - ...(show_history && $queue.current ? [$queue.current.item] : []), 133 - ...$queue.user_queue, 134 - ...$queue.auto_queue, 135 - ].map((item) => item.id) 147 + const visible_track_ids = get_visible_track_ids() 136 148 const selected_visible_track_ids = indexes.map((i) => visible_track_ids[i]) 137 149 handle_selected_tracks_action({ 138 150 action,
+13 -2
src/components/TrackList.svelte
··· 45 45 import Header from './Header.svelte' 46 46 import { writable } from 'svelte/store' 47 47 import { SvelteSelection } from '$lib/selection' 48 - import { get_flattened_tracklists, handle_selected_tracks_action } from '$lib/menus' 48 + import { 49 + get_show_in_playlists_tree, 50 + get_tracklists_tree, 51 + handle_selected_tracks_action, 52 + } from '$lib/menus' 49 53 import type { SelectedTracksAction } from '$electron/typed_ipc' 50 54 import { RefreshLevel, VirtualGrid, type Column } from '$lib/virtual-grid.svelte' 51 55 ··· 96 100 tracklist_actions.scroll_to_index?.(index) 97 101 }, 98 102 async on_contextmenu() { 103 + const selected_track_ids = get_track_ids(selection.items_as_array()) 99 104 const action = await ipc_renderer.invoke('show_tracks_menu', { 100 105 is_editable_playlist: tracks_page.playlistKind === 'playlist', 101 106 queue: false, 102 - lists: get_flattened_tracklists(), 107 + lists: get_tracklists_tree(), 108 + show_in_playlists: get_show_in_playlists_tree(selected_track_ids), 103 109 }) 104 110 if (action !== null) { 105 111 handle_action(action) ··· 503 509 504 510 onMount(() => { 505 511 tracklist_actions.scroll_to_index = virtual_grid.scroll_to_index.bind(virtual_grid) 512 + tracklist_actions.go_to_index = (index) => { 513 + virtual_grid.scroll_to_index(index) 514 + selection.clear() 515 + selection.add_index(index) 516 + } 506 517 }) 507 518 </script> 508 519
+9
src/electron/ipc.ts
··· 90 90 }, 91 91 { type: 'separator' }, 92 92 { 93 + label: 'Show in Playlist', 94 + submenu: options.show_in_playlists.map((item) => { 95 + return { 96 + ...item, 97 + click: () => resolve({ action: 'Show in Playlist', playlist_id: item.id }), 98 + } 99 + }), 100 + }, 101 + { 93 102 label: (() => { 94 103 if (is.mac) return 'Reveal in Finder' 95 104 else if (is.windows) return 'Reveal in File Explorer'
+9 -1
src/electron/typed_ipc.ts
··· 97 97 | 'Play Next' 98 98 | 'Add to Queue' 99 99 | { action: 'Add to Playlist'; playlist_id: string } 100 + | { action: 'Show in Playlist'; playlist_id: string } 100 101 | 'Get Info' 101 102 | 'reveal_track_file' 102 103 | 'Remove from Playlist' ··· 141 142 } 142 143 143 144 export type ShowTrackMenuOptions = { 144 - lists: { label: string; enabled: boolean; id: string }[] 145 + lists: TrackMenuNode[] 146 + show_in_playlists: TrackMenuNode[] 145 147 is_editable_playlist: boolean 146 148 queue: boolean 149 + } 150 + 151 + export type TrackMenuNode = { 152 + label: string 153 + enabled: boolean 154 + id: string 147 155 } 148 156 149 157 type Commands = {
+3
src/lib/data.ts
··· 157 157 export function get_track_ids(item_ids: ItemId[]) { 158 158 return strict_call((addon) => addon.get_track_ids(item_ids)) 159 159 } 160 + export function get_track_playlist_ids(track_id: TrackID) { 161 + return strict_call((addon) => addon.get_track_playlist_ids(track_id)) 162 + } 160 163 export function get_tracks_page(options: TracksPageOptions) { 161 164 return strict_call((addon) => addon.get_tracks_page(options)) 162 165 }
+35 -1
src/lib/menus.ts
··· 1 - import { add_tracks_to_playlist, get_track, paths, track_lists_details_map } from '$lib/data' 1 + import { 2 + add_tracks_to_playlist, 3 + get_track, 4 + get_track_list, 5 + get_track_playlist_ids, 6 + paths, 7 + track_lists_details_map, 8 + } from '$lib/data' 2 9 import { flatten_child_lists } from '$lib/helpers' 3 10 import { ipc_renderer } from '$lib/window' 4 11 import type { TrackID } from '../../ferrum-addon' ··· 6 13 import { append_to_user_queue, prepend_to_user_queue } from './queue' 7 14 import type { SelectedTracksAction } from '$electron/typed_ipc' 8 15 import { open_track_info } from '$components/TrackInfo.svelte' 16 + import { navigate } from './router' 17 + import { tracklist_actions } from './page' 18 + import { tick } from 'svelte' 9 19 10 20 export function get_flattened_tracklists() { 11 21 const track_lists = get(track_lists_details_map) 12 22 return flatten_child_lists(track_lists.root, track_lists, '') 13 23 } 14 24 25 + export function get_tracklists_tree() { 26 + return get_flattened_tracklists() 27 + } 28 + 29 + export function get_show_in_playlists_tree(track_ids: TrackID[]) { 30 + if (track_ids.length !== 1) { 31 + return [] 32 + } 33 + const matches = new Set(get_track_playlist_ids(track_ids[0])) 34 + return get_flattened_tracklists().filter((item) => { 35 + return item.enabled === false || matches.has(item.id) 36 + }) 37 + } 38 + 15 39 export function handle_selected_tracks_action({ 16 40 action, 17 41 track_ids, ··· 39 63 ipc_renderer.invoke('revealTrackFile', paths.tracksDir, track.file) 40 64 } else if (typeof action === 'object' && action.action === 'Add to Playlist') { 41 65 add_tracks_to_playlist(action.playlist_id, track_ids) 66 + } else if (typeof action === 'object' && action.action === 'Show in Playlist') { 67 + let reveal_index = -1 68 + const list = get_track_list(action.playlist_id) 69 + if (list.type === 'playlist') { 70 + reveal_index = list.tracks.indexOf(first_track_id) 71 + } 72 + navigate('/playlist/' + action.playlist_id) 73 + tick().then(() => { 74 + tracklist_actions.go_to_index(reveal_index) 75 + }) 42 76 } 43 77 }
+1
src/lib/page.ts
··· 1 1 export const tracklist_actions = { 2 2 scroll_to_index(_index: number) {}, 3 + go_to_index(_index: number) {}, 3 4 focus() { 4 5 const el = document.querySelector('.main-focus-element') 5 6 if (el instanceof HTMLElement) el.focus()