diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5a03c..90e8753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Next +- Add "Show in Playlist" context menu item - Remember queue on restart - Keep scroll position when opening play history - Android: Add track streaming search links for Spotify and YouTube Music diff --git a/ferrum-addon/addon.d.ts b/ferrum-addon/addon.d.ts index c1be158..a0f3f3f 100644 --- a/ferrum-addon/addon.d.ts +++ b/ferrum-addon/addon.d.ts @@ -64,6 +64,8 @@ export declare function get_track_list(id: string): TrackList export declare function get_track_lists_details(): Record +export declare function get_track_playlist_ids(trackId: TrackID): Array + export declare function get_tracks_page(options: TracksPageOptions): TracksPage export declare function import_file(path: string, now: MsSinceUnixEpoch): void @@ -123,7 +125,7 @@ export interface Playlist { originalId?: string dateImported?: MsSinceUnixEpoch dateCreated?: MsSinceUnixEpoch - tracks: Array + tracks: string[] } export declare function playlist_filter_duplicates(playlistId: TrackID, ids: Array): Array diff --git a/src-native/library_types.rs b/src-native/library_types.rs index 885cfe9..710b700 100644 --- a/src-native/library_types.rs +++ b/src-native/library_types.rs @@ -473,6 +473,7 @@ pub struct Playlist { deserialize_with = "deserialize_playlist_ids", serialize_with = "serialize_playlist_ids" )] + #[cfg_attr(feature = "napi", napi(ts_type = "string[]"))] pub tracks: Vec, } impl Playlist { diff --git a/src-native/playlists.rs b/src-native/playlists.rs index 8d988d9..6b0ca4e 100644 --- a/src-native/playlists.rs +++ b/src-native/playlists.rs @@ -8,6 +8,7 @@ use crate::str_to_option; use anyhow::{Context, Result, bail}; use linked_hash_map::LinkedHashMap; use napi::{Env, Unknown}; +use rayon::prelude::*; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -172,6 +173,34 @@ pub fn filter_duplicates(playlist_id: TrackID, ids: Vec, env: Env) -> Re Ok(track_ids) } +#[napi(js_name = "get_track_playlist_ids")] +#[allow(dead_code)] +pub fn get_track_playlist_ids(track_id: TrackID, env: Env) -> Result> { + let data: &Data = get_data(&env); + Ok(get_track_playlist_ids_in_library(&data.library, &track_id)) +} + +pub fn get_track_playlist_ids_in_library(library: &Library, track_id: &str) -> Vec { + let track_id_map = TRACK_ID_MAP.read().unwrap(); + library + .trackLists + .iter() + .collect::>() + .par_iter() + .filter_map(|(playlist_id, tracklist)| { + let TrackList::Playlist(playlist) = tracklist else { + return None; + }; + for item_id in &playlist.tracks { + if track_id_map[*item_id as usize].as_str() == track_id { + return Some(playlist_id.to_string()); + } + } + None + }) + .collect() +} + #[napi(js_name = "remove_from_playlist")] #[allow(dead_code)] pub fn remove_from_playlist(playlist_id: TrackID, item_ids: Vec, env: Env) -> Result<()> { diff --git a/src/components/Player.svelte b/src/components/Player.svelte index 47dd6ef..c4353d4 100644 --- a/src/components/Player.svelte +++ b/src/components/Player.svelte @@ -17,7 +17,11 @@ import { dragged } from '$lib/drag-drop' import * as dragGhost from './DragGhost.svelte' import Slider from './Slider.svelte' - import { get_flattened_tracklists, handle_selected_tracks_action } from '$lib/menus' + import { + get_show_in_playlists_tree, + get_tracklists_tree, + handle_selected_tracks_action, + } from '$lib/menus' import { ipc_renderer } from '$lib/window' import { tracks_page_item_ids } from './TrackList.svelte' @@ -29,7 +33,8 @@ const action = await ipc_renderer.invoke('show_tracks_menu', { is_editable_playlist: false, queue: false, - lists: get_flattened_tracklists(), + lists: get_tracklists_tree(), + show_in_playlists: get_show_in_playlists_tree([playing.id]), }) if (action !== null) { handle_selected_tracks_action({ diff --git a/src/components/Queue.svelte b/src/components/Queue.svelte index d5ed1a7..9e8d15f 100644 --- a/src/components/Queue.svelte +++ b/src/components/Queue.svelte @@ -18,7 +18,11 @@ import { cubicOut } from 'svelte/easing' import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' import type { SelectedTracksAction } from '$electron/typed_ipc' - import { get_flattened_tracklists, handle_selected_tracks_action } from '$lib/menus' + import { + get_show_in_playlists_tree, + get_tracklists_tree, + handle_selected_tracks_action, + } from '$lib/menus' import { SvelteSelection } from '$lib/selection' let object_urls: string[] = [] @@ -71,10 +75,14 @@ autoplay_list.scroll_to_index(index, 40) }, async on_contextmenu() { + const indexes = selection.get_selected_indexes() + const visible_track_ids = get_visible_track_ids() + const selected_visible_track_ids = indexes.map((i) => visible_track_ids[i]) const action = await ipc_renderer.invoke('show_tracks_menu', { is_editable_playlist: false, queue: true, - lists: get_flattened_tracklists(), + lists: get_tracklists_tree(), + show_in_playlists: get_show_in_playlists_tree(selected_visible_track_ids), }) if (action !== null) { handle_action(action) @@ -83,6 +91,15 @@ }) $: selection.update_all_items(visible_qids) + function get_visible_track_ids() { + return [ + ...(show_history ? $queue.past : []), + ...(show_history && $queue.current ? [$queue.current.item] : []), + ...$queue.user_queue, + ...$queue.auto_queue, + ].map((item) => item.id) + } + function remove_from_queue() { if (selection.items.size >= 1) { const indexes = selection.get_selected_indexes().map((i) => i + first_visible_index) @@ -127,12 +144,7 @@ function handle_action(action: SelectedTracksAction) { const first_index = selection.find_first_index() const indexes = selection.get_selected_indexes() - const visible_track_ids = [ - ...(show_history ? $queue.past : []), - ...(show_history && $queue.current ? [$queue.current.item] : []), - ...$queue.user_queue, - ...$queue.auto_queue, - ].map((item) => item.id) + const visible_track_ids = get_visible_track_ids() const selected_visible_track_ids = indexes.map((i) => visible_track_ids[i]) handle_selected_tracks_action({ action, diff --git a/src/components/TrackList.svelte b/src/components/TrackList.svelte index d5b05bb..d30eeb4 100644 --- a/src/components/TrackList.svelte +++ b/src/components/TrackList.svelte @@ -45,7 +45,11 @@ import Header from './Header.svelte' import { writable } from 'svelte/store' import { SvelteSelection } from '$lib/selection' - import { get_flattened_tracklists, handle_selected_tracks_action } from '$lib/menus' + import { + get_show_in_playlists_tree, + get_tracklists_tree, + handle_selected_tracks_action, + } from '$lib/menus' import type { SelectedTracksAction } from '$electron/typed_ipc' import { RefreshLevel, VirtualGrid, type Column } from '$lib/virtual-grid.svelte' @@ -96,10 +100,12 @@ tracklist_actions.scroll_to_index?.(index) }, async on_contextmenu() { + const selected_track_ids = get_track_ids(selection.items_as_array()) const action = await ipc_renderer.invoke('show_tracks_menu', { is_editable_playlist: tracks_page.playlistKind === 'playlist', queue: false, - lists: get_flattened_tracklists(), + lists: get_tracklists_tree(), + show_in_playlists: get_show_in_playlists_tree(selected_track_ids), }) if (action !== null) { handle_action(action) @@ -503,6 +509,11 @@ onMount(() => { tracklist_actions.scroll_to_index = virtual_grid.scroll_to_index.bind(virtual_grid) + tracklist_actions.go_to_index = (index) => { + virtual_grid.scroll_to_index(index) + selection.clear() + selection.add_index(index) + } }) diff --git a/src/electron/ipc.ts b/src/electron/ipc.ts index c10c5a3..7b69ff6 100644 --- a/src/electron/ipc.ts +++ b/src/electron/ipc.ts @@ -89,6 +89,15 @@ ipc_main.handle('show_tracks_menu', (e, options) => { click: () => resolve('Get Info'), }, { type: 'separator' }, + { + label: 'Show in Playlist', + submenu: options.show_in_playlists.map((item) => { + return { + ...item, + click: () => resolve({ action: 'Show in Playlist', playlist_id: item.id }), + } + }), + }, { label: (() => { if (is.mac) return 'Reveal in Finder' diff --git a/src/electron/typed_ipc.ts b/src/electron/typed_ipc.ts index cfe2b7b..ba219fc 100644 --- a/src/electron/typed_ipc.ts +++ b/src/electron/typed_ipc.ts @@ -97,6 +97,7 @@ export type SelectedTracksAction = | 'Play Next' | 'Add to Queue' | { action: 'Add to Playlist'; playlist_id: string } + | { action: 'Show in Playlist'; playlist_id: string } | 'Get Info' | 'reveal_track_file' | 'Remove from Playlist' @@ -141,11 +142,18 @@ type Events = { } export type ShowTrackMenuOptions = { - lists: { label: string; enabled: boolean; id: string }[] + lists: TrackMenuNode[] + show_in_playlists: TrackMenuNode[] is_editable_playlist: boolean queue: boolean } +export type TrackMenuNode = { + label: string + enabled: boolean + id: string +} + type Commands = { app_loaded: () => void check_for_updates: () => Promise<{ diff --git a/src/lib/data.ts b/src/lib/data.ts index d217a60..58091f1 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -157,6 +157,9 @@ export function get_track_by_item_id(item_id: ItemId) { export function get_track_ids(item_ids: ItemId[]) { return strict_call((addon) => addon.get_track_ids(item_ids)) } +export function get_track_playlist_ids(track_id: TrackID) { + return strict_call((addon) => addon.get_track_playlist_ids(track_id)) +} export function get_tracks_page(options: TracksPageOptions) { return strict_call((addon) => addon.get_tracks_page(options)) } diff --git a/src/lib/menus.ts b/src/lib/menus.ts index 084864f..6560ac8 100644 --- a/src/lib/menus.ts +++ b/src/lib/menus.ts @@ -1,4 +1,11 @@ -import { add_tracks_to_playlist, get_track, paths, track_lists_details_map } from '$lib/data' +import { + add_tracks_to_playlist, + get_track, + get_track_list, + get_track_playlist_ids, + paths, + track_lists_details_map, +} from '$lib/data' import { flatten_child_lists } from '$lib/helpers' import { ipc_renderer } from '$lib/window' import type { TrackID } from '../../ferrum-addon' @@ -6,12 +13,29 @@ import { get } from 'svelte/store' import { append_to_user_queue, prepend_to_user_queue } from './queue' import type { SelectedTracksAction } from '$electron/typed_ipc' import { open_track_info } from '$components/TrackInfo.svelte' +import { navigate } from './router' +import { tracklist_actions } from './page' +import { tick } from 'svelte' export function get_flattened_tracklists() { const track_lists = get(track_lists_details_map) return flatten_child_lists(track_lists.root, track_lists, '') } +export function get_tracklists_tree() { + return get_flattened_tracklists() +} + +export function get_show_in_playlists_tree(track_ids: TrackID[]) { + if (track_ids.length !== 1) { + return [] + } + const matches = new Set(get_track_playlist_ids(track_ids[0])) + return get_flattened_tracklists().filter((item) => { + return item.enabled === false || matches.has(item.id) + }) +} + export function handle_selected_tracks_action({ action, track_ids, @@ -39,5 +63,15 @@ export function handle_selected_tracks_action({ ipc_renderer.invoke('revealTrackFile', paths.tracksDir, track.file) } else if (typeof action === 'object' && action.action === 'Add to Playlist') { add_tracks_to_playlist(action.playlist_id, track_ids) + } else if (typeof action === 'object' && action.action === 'Show in Playlist') { + let reveal_index = -1 + const list = get_track_list(action.playlist_id) + if (list.type === 'playlist') { + reveal_index = list.tracks.indexOf(first_track_id) + } + navigate('/playlist/' + action.playlist_id) + tick().then(() => { + tracklist_actions.go_to_index(reveal_index) + }) } } diff --git a/src/lib/page.ts b/src/lib/page.ts index 0195396..96224b0 100644 --- a/src/lib/page.ts +++ b/src/lib/page.ts @@ -1,5 +1,6 @@ export const tracklist_actions = { scroll_to_index(_index: number) {}, + go_to_index(_index: number) {}, focus() { const el = document.querySelector('.main-focus-element') if (el instanceof HTMLElement) el.focus()