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

WIP artists page

Kasper (Sep 12, 2024, 9:22 AM +0200) b724dbc5 310372c1

+140 -88
+6 -1
ferrum-addon/addon.d.ts
··· 105 105 export const enum SpecialTrackListName { 106 106 Root = 0 107 107 } 108 - export declare function open_playlist(openPlaylistId: string): void 108 + export declare function open_playlist(openPlaylistId: string, viewAs?: ViewAs | undefined | null): void 109 109 export declare function get_page_track(index: number): Track 110 110 export declare function get_page_track_id(index: number): string 111 111 export declare function refresh_page(): void 112 112 export declare function get_page_track_ids(): Array<TrackID> 113 + export const enum ViewAs { 114 + Songs = 0, 115 + Artists = 1 116 + } 113 117 export interface PageInfo { 114 118 id: string 119 + viewAs: ViewAs 115 120 tracklist: TrackList 116 121 sortKey: string 117 122 sortDesc: boolean
+15
src-native/artists.rs
··· 1 + use crate::library_types::Library; 2 + use std::collections::HashSet; 3 + use std::time::Instant; 4 + 5 + pub fn get_artists(library: &Library) -> HashSet<String> { 6 + let now = Instant::now(); 7 + let mut artists = HashSet::new(); 8 + for (_, track) in &library.tracks { 9 + if !artists.contains(&track.artist) { 10 + artists.insert(track.artist.clone()); 11 + } 12 + } 13 + println!("Get artists: {}ms", now.elapsed().as_millis()); 14 + return artists; 15 + }
+10 -2
src-native/data.rs
··· 1 + use crate::artists::get_artists; 1 2 use crate::library::{load_library, Paths}; 2 3 use crate::library_types::{Library, TrackID, TrackList, TrackListID}; 3 - use crate::page::get_track_ids; 4 + use crate::page::{get_track_ids, ViewAs}; 4 5 use crate::sidebar_view::SidebarView; 5 6 use crate::sort::sort; 6 7 use crate::tracks::Tag; ··· 8 9 use atomicwrites::{AllowOverwrite, AtomicFile}; 9 10 use napi::Result; 10 11 use serde::Serialize; 12 + use std::collections::HashSet; 11 13 use std::env; 12 14 use std::io::Write; 13 15 use std::path::PathBuf; ··· 23 25 /// The visible tracks on the current page 24 26 pub page_track_ids: Option<Vec<TrackID>>, 25 27 pub open_playlist_id: TrackListID, 28 + pub view_as: ViewAs, 26 29 pub filter: String, 27 30 pub sort_key: String, 28 31 pub sort_desc: bool, 29 32 pub group_album_tracks: bool, 30 33 /// Current tag being edited 31 34 pub current_tag: Option<Tag>, 35 + pub artists: HashSet<String>, 32 36 } 33 37 34 38 impl Data { ··· 99 103 100 104 let loaded_library = load_library(&paths)?; 101 105 let loaded_cache = SidebarView::load(&paths); 106 + let artists = get_artists(&loaded_library); 102 107 103 108 let mut data = Data { 104 109 is_dev, 105 110 paths, 106 111 library: loaded_library, 112 + artists, 107 113 view_cache: loaded_cache, 108 114 open_playlist_id: "root".to_string(), 109 115 open_playlist_track_ids: vec![], 116 + view_as: ViewAs::Songs, 110 117 page_track_ids: None, 111 118 filter: "".to_string(), 112 119 sort_key: "index".to_string(), ··· 118 125 sort(&mut data, "dateAdded", true)?; 119 126 return Ok(data); 120 127 } 121 - pub fn open_playlist(&mut self, playlist_id: TrackID) -> Result<()> { 128 + pub fn open_playlist(&mut self, playlist_id: TrackID, view_as: Option<ViewAs>) -> Result<()> { 122 129 self.open_playlist_id = playlist_id; 130 + self.view_as = view_as.unwrap_or_default(); 123 131 self.open_playlist_track_ids = get_track_ids(self)?; 124 132 self.page_track_ids = None; 125 133 match self.library.get_tracklist(&self.open_playlist_id)? {
+1
src-native/lib.rs
··· 20 20 #[macro_use] 21 21 extern crate napi_derive; 22 22 23 + mod artists; 23 24 mod data; 24 25 mod data_js; 25 26 mod filter;
+15 -2
src-native/page.rs
··· 11 11 12 12 #[napi(js_name = "open_playlist")] 13 13 #[allow(dead_code)] 14 - pub fn open_playlist(open_playlist_id: String, env: Env) -> Result<()> { 14 + pub fn open_playlist(open_playlist_id: String, view_as: Option<ViewAs>, env: Env) -> Result<()> { 15 15 let data: &mut Data = get_data(&env)?; 16 - data.open_playlist(open_playlist_id) 16 + data.open_playlist(open_playlist_id, view_as) 17 17 } 18 18 19 19 #[napi(js_name = "get_page_track")] ··· 97 97 Ok(ids) 98 98 } 99 99 100 + #[napi] 101 + pub enum ViewAs { 102 + Songs, 103 + Artists, 104 + } 105 + impl Default for ViewAs { 106 + fn default() -> Self { 107 + Self::Songs 108 + } 109 + } 110 + 100 111 #[napi(object)] 101 112 pub struct PageInfo { 102 113 pub id: String, 114 + pub view_as: ViewAs, 103 115 #[napi(ts_type = "TrackList")] 104 116 pub tracklist: JsUnknown, 105 117 pub sort_key: String, ··· 115 127 116 128 Ok(PageInfo { 117 129 id: data.open_playlist_id.clone(), 130 + view_as: data.view_as, 118 131 tracklist: env.to_js_value(tracklist)?, 119 132 sort_key: data.sort_key.clone(), 120 133 sort_desc: data.sort_desc,
+1 -1
src-native/playlists.rs
··· 135 135 data.library.trackLists.remove(id); 136 136 } 137 137 if ids.contains(&data.open_playlist_id) { 138 - data.open_playlist("root".to_string())?; 138 + data.open_playlist("root".to_string(), None)?; 139 139 } 140 140 Ok(()) 141 141 }
+17 -13
src/components/Sidebar.svelte
··· 8 8 import { dragged } from '../lib/drag-drop' 9 9 import { tracklist_actions } from '@/lib/page' 10 10 11 - const special = { 12 - children: ['root'], 13 - } 11 + const special = [ 12 + { id: 'root', name: 'Songs', kind: 'special', view_as: 0 }, 13 + { id: 'root', name: 'Artists', kind: 'special', view_as: 1 }, 14 + ] 14 15 let viewport: HTMLElement 15 16 const itemHandle = setContext('itemHandle', writable(null as SidebarItemHandle | null)) 16 17 17 18 onDestroy( 18 19 ipcListen('Select Previous List', () => { 19 - console.log('Select Previous List') 20 20 $itemHandle?.handleKey(new KeyboardEvent('keydown', { key: 'ArrowUp' })) 21 21 }), 22 22 ) 23 23 onDestroy( 24 24 ipcListen('Select Next List', () => { 25 - console.log('Select Next List') 26 25 $itemHandle?.handleKey(new KeyboardEvent('keydown', { key: 'ArrowDown' })) 27 26 }), 28 27 ) ··· 33 32 isFolder: false, 34 33 isRoot: true, 35 34 }) 36 - } 37 - function open(id: string) { 38 - if ($page.id !== id) page.openPlaylist(id) 39 35 } 40 36 41 37 let rootDroppable = false ··· 129 125 <div class="focuser" tabindex="0" on:focus={focuser} /> 130 126 <div class="spacer" /> 131 127 <SidebarItems 132 - trackList={special} 133 - on:selectDown={() => { 128 + parentId={null} 129 + children={special} 130 + on_open={(item) => { 131 + page.openPlaylist('root', item.view_as ?? 0) 132 + }} 133 + on_select_down={() => { 134 134 if ($trackListsDetailsMap.root.children && $trackListsDetailsMap.root.children[0]) { 135 - open($trackListsDetailsMap.root.children[0]) 135 + page.openPlaylist($trackListsDetailsMap.root.children[0], 0) 136 136 } 137 137 }} 138 - parentId={null} 139 138 /> 140 139 <div class="spacer" /> 141 140 <SidebarItems 142 - trackList={{ children: $trackListsDetailsMap['root'].children || [] }} 143 141 parentId={$trackListsDetailsMap['root'].id} 142 + children={($trackListsDetailsMap['root'].children || []).map( 143 + (childId) => $trackListsDetailsMap[childId], 144 + )} 145 + on_open={(item) => { 146 + if ($page.id !== item.id) page.openPlaylist(item.id, item.view_as ?? 0) 147 + }} 144 148 /> 145 149 </nav> 146 150 </div>
+65 -66
src/components/SidebarItems.svelte
··· 29 29 </script> 30 30 31 31 <script lang="ts"> 32 - import type { TrackListDetails } from '../../ferrum-addon' 32 + import type { TrackListDetails, ViewAs } from '../../ferrum-addon' 33 33 import { type Writable, writable } from 'svelte/store' 34 - import { createEventDispatcher } from 'svelte' 35 34 import { getContext } from 'svelte' 36 35 import { dragged } from '../lib/drag-drop' 37 36 import * as dragGhost from './DragGhost.svelte' 38 37 import { ipcRenderer } from '@/lib/window' 39 38 import { checkShortcut } from '@/lib/helpers' 40 39 41 - export let parentId: string | null 42 40 export let show = true 43 - export let trackList: { children: string[] } 41 + export let parentId: string | null 44 42 export let preventDrop = false 43 + export let children: (TrackListDetails & { view_as?: ViewAs })[] 45 44 46 45 export let level = 0 47 - $: childLists = trackList.children.map((childId) => { 48 - return $trackListsDetailsMap[childId] 49 - }) 50 - function open(id: string) { 51 - if ($page.id !== id) page.openPlaylist(id) 52 - } 46 + export let on_open: (item: { id: string; view_as?: ViewAs }) => void 53 47 async function tracklistContextMenu(id: string, isFolder: boolean) { 54 48 await ipcRenderer.invoke('showTracklistMenu', { id, isFolder, isRoot: false }) 55 49 } ··· 59 53 return list.children && list.children.length > 0 && $shownFolders.has(id) 60 54 } 61 55 62 - const dispatch = createEventDispatcher<{ selectDown: null }>() 63 - function selectFirst(in_id: string) { 64 - const children = $trackListsDetailsMap[in_id].children 65 - if (children && children[0]) { 66 - open(children[0]) 56 + export let on_select_down = () => {} 57 + function selectFirst(item: TrackListDetails) { 58 + const child_id = item.children?.[0] 59 + if (child_id) { 60 + on_open($trackListsDetailsMap[child_id]) 67 61 } 68 62 } 69 63 function selectLast(in_id: string) { ··· 71 65 if (children && (hasShowingChildren(in_id) || in_id === 'root')) { 72 66 selectLast(children[children.length - 1]) 73 67 } else { 74 - open(in_id) 68 + on_open($trackListsDetailsMap[in_id]) 75 69 } 76 70 } 77 71 function selectUp(i: number) { 78 - const prevId = trackList.children[i - 1] || null 72 + const prevId = children[i - 1]?.id || null 79 73 if (i === 0 && parentId) { 80 - open(parentId) 74 + on_open({ id: parentId }) 81 75 } else if (prevId && hasShowingChildren(prevId)) { 82 76 selectLast(prevId) 83 77 } else if (prevId) { 84 - open(prevId) 78 + on_open({ id: prevId }) 85 79 } 86 80 } 87 81 function selectDown(i: number) { 88 - if (hasShowingChildren(trackList.children[i])) { 89 - selectFirst(trackList.children[i]) 90 - } else if (trackList.children[i + 1]) { 91 - open(trackList.children[i + 1]) 82 + if (hasShowingChildren(children[i].id)) { 83 + selectFirst(children[i]) 84 + } else if (children[i + 1]) { 85 + console.trace() 86 + on_open(children[i + 1]) 92 87 } else { 93 - dispatch('selectDown') 88 + on_select_down() 94 89 } 95 90 } 96 91 97 92 export function handleKey(e: KeyboardEvent) { 98 - const index = trackList.children.findIndex((id) => id === $page.tracklist.id) 93 + const index = children.findIndex((child) => { 94 + if (child.id === 'root') { 95 + return child.id === $page.tracklist.id && child.view_as === $page.viewAs 96 + } else { 97 + return child.id === $page.tracklist.id 98 + } 99 + }) 99 100 if (index < 0) { 100 101 return 101 102 } ··· 103 104 if (checkShortcut(e, 'ArrowUp')) { 104 105 selectUp(index) 105 106 } else if (checkShortcut(e, 'ArrowUp', { alt: true })) { 106 - open('root') 107 + on_open({ id: 'root' }) 107 108 } else if (checkShortcut(e, 'ArrowDown', { alt: true })) { 108 109 selectLast('root') 109 110 } else if (checkShortcut(e, 'ArrowDown')) { ··· 112 113 if (selectedList.kind === 'folder' && $shownFolders.has(selectedList.id)) { 113 114 hideFolder(selectedList.id) 114 115 } else if (parentId) { 115 - open(parentId) 116 + on_open({ id: parentId }) 116 117 } 117 118 } else if (checkShortcut(e, 'ArrowRight') && selectedList.kind === 'folder') { 118 119 showFolder(selectedList.id) ··· 121 122 } 122 123 e.preventDefault() 123 124 } 124 - $: if (trackList.children.includes($page.id)) { 125 + $: if (!!children.find((child) => child.id === $page.id)) { 125 126 const itemHandle = getContext<Writable<SidebarItemHandle | null>>('itemHandle') 126 127 itemHandle.set({ handleKey }) 127 128 } ··· 146 147 </script> 147 148 148 149 <div class="sub" class:show> 149 - {#each childLists as childList, i} 150 - {#if childList.kind === 'folder'} 150 + {#each children as child_list, i} 151 + {#if child_list.kind === 'folder'} 151 152 <div 152 153 class="item" 153 154 style:padding-left={14 * level + 'px'} 154 - class:active={$page.id === childList.id} 155 + class:active={$page.id === child_list.id} 155 156 draggable="true" 156 - on:dragstart={(e) => onDragStart(e, childList)} 157 - class:show={$shownFolders.has(childList.id)} 157 + on:dragstart={(e) => onDragStart(e, child_list)} 158 + class:show={$shownFolders.has(child_list.id)} 158 159 class:droppable={dragPlaylistOntoIndex === i} 159 160 role="none" 160 161 on:drop={(e) => { ··· 163 164 e.dataTransfer?.types[0] === 'ferrum.playlist' && 164 165 dragged.playlist && 165 166 !preventDrop && 166 - dragged.playlist.id !== childList.id && 167 - childList.children !== undefined 167 + dragged.playlist.id !== child_list.id && 168 + child_list.children !== undefined 168 169 ) { 169 170 movePlaylist( 170 171 dragged.playlist.id, 171 172 dragged.playlist.fromFolder, 172 - childList.id, 173 - Math.max(0, childList.children.length - 1), 173 + child_list.id, 174 + Math.max(0, child_list.children.length - 1), 174 175 ) 175 176 dragPlaylistOntoIndex = null 176 177 } 177 178 }} 178 - on:mousedown={() => open(childList.id)} 179 - on:contextmenu={() => tracklistContextMenu(childList.id, true)} 179 + on:mousedown={() => on_open(child_list)} 180 + on:contextmenu={() => tracklistContextMenu(child_list.id, true)} 180 181 > 181 182 <!-- svelte-ignore a11y-click-events-have-key-events --> 182 183 <!-- svelte-ignore a11y-interactive-supports-focus --> ··· 186 187 aria-label="Arrow button" 187 188 on:mousedown|stopPropagation 188 189 on:click={() => { 189 - if ($shownFolders.has(childList.id)) { 190 - hideFolder(childList.id) 190 + if ($shownFolders.has(child_list.id)) { 191 + hideFolder(child_list.id) 191 192 } else { 192 - showFolder(childList.id) 193 + showFolder(child_list.id) 193 194 } 194 195 }} 195 196 xmlns="http://www.w3.org/2000/svg" ··· 209 210 e.dataTransfer?.types[0] === 'ferrum.playlist' && 210 211 dragged.playlist && 211 212 !preventDrop && 212 - dragged.playlist.id !== childList.id 213 + dragged.playlist.id !== child_list.id 213 214 ) { 214 215 dragPlaylistOntoIndex = i 215 216 e.preventDefault() ··· 219 220 dragPlaylistOntoIndex = null 220 221 }} 221 222 > 222 - {childList.name} 223 + {child_list.name} 223 224 </div> 224 225 </div> 225 226 <svelte:self 226 - show={$shownFolders.has(childList.id)} 227 - trackList={childList} 228 - parentId={childList.id} 227 + show={$shownFolders.has(child_list.id)} 228 + parentId={child_list.id} 229 + children={child_list.children?.map((childId) => $trackListsDetailsMap[childId]) || []} 229 230 level={level + 1} 230 - preventDrop={preventDrop || dragged.playlist?.id === childList.id} 231 - on:selectDown={() => { 232 - if (i < trackList.children.length - 1) { 233 - open(trackList.children[i + 1]) 231 + preventDrop={preventDrop || dragged.playlist?.id === child_list.id} 232 + {on_open} 233 + on_select_down={() => { 234 + if (i < children.length - 1) { 235 + on_open(children[i + 1]) 234 236 } else { 235 - dispatch('selectDown') 237 + on_select_down() 236 238 } 237 239 }} 238 240 /> 239 - {:else if childList.kind === 'playlist'} 241 + {:else if child_list.kind === 'playlist'} 240 242 <!-- svelte-ignore a11y-interactive-supports-focus --> 241 243 <div 242 244 class="item" ··· 244 246 aria-label="playlist" 245 247 style:padding-left={14 * level + 'px'} 246 248 draggable="true" 247 - on:dragstart={(e) => onDragStart(e, childList)} 248 - class:active={$page.id === childList.id} 249 - on:mousedown={() => open(childList.id)} 249 + on:dragstart={(e) => onDragStart(e, child_list)} 250 + class:active={$page.id === child_list.id} 251 + on:mousedown={() => on_open(child_list)} 250 252 class:droppable={dragTrackOntoIndex === i} 251 253 class:droppable-above={dragPlaylistOntoIndex === i && dropAbove} 252 254 class:droppable-below={dragPlaylistOntoIndex === i && !dropAbove} 253 255 on:drop={(e) => { 254 256 if (e.currentTarget && e.dataTransfer?.types[0] === 'ferrum.tracks' && dragged.tracks) { 255 - addTracksToPlaylist(childList.id, dragged.tracks.ids) 257 + addTracksToPlaylist(child_list.id, dragged.tracks.ids) 256 258 dragTrackOntoIndex = null 257 259 } else if ( 258 260 e.currentTarget && ··· 272 274 dragPlaylistOntoIndex = null 273 275 } 274 276 }} 275 - on:contextmenu={() => tracklistContextMenu(childList.id, false)} 277 + on:contextmenu={() => tracklistContextMenu(child_list.id, false)} 276 278 > 277 279 <div class="arrow" /> 278 280 <div ··· 299 301 dragPlaylistOntoIndex = null 300 302 }} 301 303 > 302 - {childList.name} 304 + {child_list.name} 303 305 </div> 304 306 </div> 305 307 {:else} ··· 308 310 class="item" 309 311 role="link" 310 312 style:padding-left={14 * level + 'px'} 311 - on:mousedown={() => open(childList.id)} 312 - class:active={$page.id === childList.id} 313 + on:mousedown={() => on_open(child_list)} 314 + class:active={$page.id === child_list.id && 315 + child_list.name === ['Songs', 'Artists'][$page.viewAs]} 313 316 > 314 317 <div class="arrow" /> 315 318 <div class="text"> 316 - {#if childList.id === 'root'} 317 - Songs 318 - {:else} 319 - {childList.name} 320 - {/if} 319 + {child_list.name} 321 320 </div> 322 321 </div> 323 322 {/if}
+10 -3
src/lib/data.ts
··· 1 1 import { writable } from 'svelte/store' 2 2 import { ipcRenderer } from '@/lib/window' 3 - import type { MsSinceUnixEpoch, TrackID, TrackList, TrackListID, TrackMd } from '../../ferrum-addon' 3 + import type { 4 + MsSinceUnixEpoch, 5 + TrackID, 6 + TrackList, 7 + TrackListID, 8 + TrackMd, 9 + ViewAs, 10 + } from '../../ferrum-addon' 4 11 import { selection as pageSelection } from './page' 5 12 import { queue } from './queue' 6 13 ··· 289 296 set, 290 297 update, 291 298 refreshIdsAndKeepSelection, 292 - openPlaylist: (id: string) => { 293 - call((data) => data.open_playlist(id)) 299 + openPlaylist: (id: string, view_as: ViewAs) => { 300 + call((data) => data.open_playlist(id, view_as)) 294 301 refreshIdsAndKeepSelection() 295 302 pageSelection.clear() 296 303 filter.set('')