Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/audio/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export interface NativeLibrary {
playlistAdd(id: string, trackIds: string[]): Promise<unknown>
playlistRemove(id: string, trackIds: string[]): Promise<unknown>
playlistMove(id: string, trackIds: string[], index: number): Promise<unknown>
playlistTreeMove(id: string, parent: string, index: number): Promise<unknown>
playlistCreate(name: string, parentId: string | null, folder: boolean, trackIds: string[]): Promise<unknown>
playlistRename(id: string, name: string): Promise<unknown>
playlistDelete(id: string): Promise<unknown>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const WRITES: CommandHandlers = {
'playlist.add': (lib, p) => lib.playlistAdd(text(p?.id), list(p?.trackIds)),
'playlist.remove': (lib, p) => lib.playlistRemove(text(p?.id), list(p?.trackIds)),
'playlist.move': (lib, p) => lib.playlistMove(text(p?.id), list(p?.trackIds), Number(p?.index ?? 0)),
'playlist.treeMove': (lib, p) => lib.playlistTreeMove(text(p?.id), text(p?.parent), Number(p?.index ?? 0)),
'playlist.create': (lib, p) =>
lib.playlistCreate(
text(p?.name),
Expand Down
1 change: 1 addition & 0 deletions app/src/preload/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface EngineCommandParams {
'playlist.add': { id: string; trackIds: string[] }
'playlist.remove': { id: string; trackIds: string[] }
'playlist.move': { id: string; trackIds: string[]; index: number }
'playlist.treeMove': { id: string; parent: string; index: number }
'playlist.create': { name: string; parentId?: string | null; folder: boolean; trackIds: string[] }
'playlist.rename': { id: string; name: string }
'playlist.delete': { id: string }
Expand Down
2 changes: 2 additions & 0 deletions app/src/renderer/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const api = {
removeFromPlaylist: (id: string, trackIds: string[]) => call<string[]>('playlist.remove', { id, trackIds }),
moveInPlaylist: (id: string, trackIds: string[], index: number) =>
call<string[]>('playlist.move', { id, trackIds, index }),
movePlaylistTree: (id: string, parent: string, index: number) =>
call<TreeItem[]>('playlist.treeMove', { id, parent, index }),
createPlaylist: (name: string, parentId: string, folder: boolean, trackIds: string[]) =>
call<{ id: string; playlists: TreeItem[] }>('playlist.create', { name, parentId, folder, trackIds }),
renamePlaylist: (id: string, name: string) => call<TreeItem[]>('playlist.rename', { id, name }),
Expand Down
88 changes: 86 additions & 2 deletions app/src/renderer/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
} from '@lucide/svelte'
import { getApp } from '$lib/context'
import { droppedPaths, isFileDrag } from '$lib/library/drop'
import { PLAYLIST_TREE_DRAG_TYPE } from '$lib/library.svelte'
import { buildTree, type TreeNode } from '$lib/library/tree'
import { menuAt, tidy, type MenuEntry, type MenuRequest } from '$lib/menu'
import { SEPARATOR, unlockEntry } from '$lib/trackMenu'
Expand Down Expand Up @@ -75,6 +76,8 @@

const expanded = new SvelteSet<string>()
const collapsedSections = new SvelteSet<string>(['histories'])
let treeDragId = $state<string | null>(null)
let treeDrop = $state<{ key: string; placement: 'before' | 'after' | 'inside' } | null>(null)

type SourceType = 'playlist' | 'history' | 'tag'

Expand Down Expand Up @@ -106,6 +109,51 @@
return (type === 'playlist' && node.kind === 'playlist') || (type === 'tag' && node.kind === 'tag')
}

function isPlaylistTreeDrag(e: DragEvent): boolean {
return !!e.dataTransfer?.types.includes(PLAYLIST_TREE_DRAG_TYPE)
}

function playlistDragStart(e: DragEvent, type: SourceType, node: TreeNode): void {
if (type !== 'playlist') return
if (!library.writable) {
e.preventDefault()
return
}
treeDragId = node.id
const transfer = e.dataTransfer
if (!transfer) return
transfer.effectAllowed = 'move'
transfer.setData(PLAYLIST_TREE_DRAG_TYPE, node.id)
}

function playlistDragEnd(): void {
treeDragId = null
treeDrop = null
}

function playlistMoveTarget(
e: DragEvent,
node: TreeNode
): { parent: string; index: number; placement: 'before' | 'after' | 'inside' } | null {
const draggedId = treeDragId ?? e.dataTransfer?.getData(PLAYLIST_TREE_DRAG_TYPE)
if (!draggedId || draggedId === node.id) return null
const bounds = (e.currentTarget as HTMLElement).getBoundingClientRect()
const position = (e.clientY - bounds.top) / bounds.height
const placement = node.kind === 'folder' && position >= 0.25 && position <= 0.75
? 'inside'
: position < 0.5
? 'before'
: 'after'
const parent = placement === 'inside' ? node.id : node.parentId
const siblings = library.playlists
.filter((item) => item.parentId === parent && item.id !== draggedId)
.sort((a, b) => a.seq - b.seq)
const targetIndex = siblings.findIndex((item) => item.id === node.id)
const index = placement === 'inside' ? siblings.length : targetIndex + Number(placement === 'after')
Comment thread
claw1200 marked this conversation as resolved.
Resolved
if (targetIndex < 0 && placement !== 'inside') return null
return { parent, index, placement }
}

function expandSoon(key: string, expand: () => void): void {
if (expandKey === key) return
clearTimeout(expandTimer)
Expand All @@ -123,6 +171,19 @@
}

function dragOver(e: DragEvent, type: SourceType, node: TreeNode): void {
if (isPlaylistTreeDrag(e)) {
if (type !== 'playlist' || !library.writable) return
const target = playlistMoveTarget(e, node)
if (!target) return
const key = `${type}:${node.id}`
if (target.placement === 'inside' && node.kind === 'folder' && !expanded.has(key)) {
expandSoon(key, () => expanded.add(key))
}
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
treeDrop = { key, placement: target.placement }
return
}
const files = !library.dragging && isFileDrag(e)
if (!library.dragging && !files) return
const key = `${type}:${node.id}`
Expand Down Expand Up @@ -185,11 +246,21 @@
const into = e.relatedTarget
if (into instanceof Node && (e.currentTarget as HTMLElement).contains(into)) return
if (dropKey === key) dropKey = null
if (treeDrop?.key === key) treeDrop = null
cancelExpand(key)
}

function drop(e: DragEvent, type: SourceType, node: TreeNode): void {
dropKey = null
if (isPlaylistTreeDrag(e)) {
const draggedId = treeDragId ?? e.dataTransfer?.getData(PLAYLIST_TREE_DRAG_TYPE)
const target = playlistMoveTarget(e, node)
if (!draggedId || !target || type !== 'playlist' || !library.writable) return
e.preventDefault()
treeDrop = null
void library.movePlaylistTree(draggedId, target.parent, target.index)
return
}
if (!library.dragging && isFileDrag(e)) {
if (type !== 'playlist' || node.kind !== 'playlist' || !library.writable) return
e.preventDefault()
Expand Down Expand Up @@ -425,20 +496,33 @@
{:else}
<button
class={[
'group flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-[12.5px]',
dropKey === key
'group relative flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-[12.5px]',
treeDrop?.key === key && treeDrop.placement === 'inside'
? 'bg-accent-soft text-fg ring-1 ring-accent/70 ring-inset'
: dropKey === key
? 'bg-accent-soft text-fg ring-1 ring-accent/70 ring-inset'
: active
? 'bg-select text-fg'
: 'text-muted hover:bg-hover hover:text-fg'
]}
style:padding-left="{8 + depth * 14}px"
draggable={type === 'playlist' && library.writable}
Comment thread
claw1200 marked this conversation as resolved.
Resolved
onclick={() => open(type, node)}
oncontextmenu={type === 'playlist' ? (e) => playlistMenu(e, node) : undefined}
ondragstart={(e) => playlistDragStart(e, type, node)}
ondragend={playlistDragEnd}
ondragover={(e) => dragOver(e, type, node)}
ondragleave={(e) => dragLeave(e, key)}
ondrop={(e) => drop(e, type, node)}
>
{#if treeDrop?.key === key && treeDrop.placement !== 'inside'}
<span
class={[
'pointer-events-none absolute inset-x-1 z-10 h-px bg-accent',
treeDrop.placement === 'before' ? 'top-0' : 'bottom-0'
]}
></span>
{/if}
{@render icon(node, type, isOpen)}
<span class="min-w-0 flex-1 truncate">{node.name || 'Untitled'}</span>
{#if !isFolder}
Expand Down
8 changes: 8 additions & 0 deletions app/src/renderer/src/lib/library.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
} from './types'

export const TRACK_DRAG_TYPE = 'application/x-exportstudio-tracks'
export const PLAYLIST_TREE_DRAG_TYPE = 'application/x-exportstudio-playlist-tree'

/** How cover art is drawn in the browser. Fill uses the column width and crops the bottom. */
export type BrowserArtwork = 'fill' | 'whole'
Expand Down Expand Up @@ -798,6 +799,13 @@ export class Library {
return result.id
}

async movePlaylistTree(id: string, parent: string, index: number): Promise<boolean> {
const result = await this.edit(() => api.movePlaylistTree(id, parent, index))
if (!result) return false
this.applyPlaylists(result)
return true
}

async renamePlaylist(id: string, name: string): Promise<void> {
this.renamingId = null
const current = this.playlistById.get(id)
Expand Down
21 changes: 21 additions & 0 deletions native/crates/es-engine/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,11 @@ impl LibraryService {
self.playlist_tracks(id)
}

pub fn playlist_tree_move(&mut self, id: &str, parent: &str, index: usize) -> Result<Vec<es_index::Node>> {
self.edit(|tx| tx.move_playlist(id, parent, index))?;
self.playlists()
}

pub fn playlist_create(
&mut self,
name: &str,
Expand Down Expand Up @@ -1242,6 +1247,22 @@ mod tests {
assert_eq!(service.playlist_tracks("playlist-1").unwrap(), ["track-1"]);
}

#[test]
fn playlist_tree_move_refreshes_the_tree() {
let directory = tempfile::tempdir().unwrap();
let path = fixture::build(directory.path(), 1).unwrap();
{
let connection = fixture::connect(&path).unwrap();
Rows(&connection).playlist("playlist-1", "Test playlist", 0).unwrap();
Rows(&connection).playlist("folder-1", "Folder", 1).unwrap();
}
let mut service = writable_fixture_service(directory.path(), &path);

let nodes = service.playlist_tree_move("playlist-1", "folder-1", 0).unwrap();

assert_eq!(nodes.iter().find(|node| node.id == "playlist-1").unwrap().parent_id, "folder-1");
}

#[test]
fn track_update_service_returns_the_refreshed_row() {
let directory = tempfile::tempdir().unwrap();
Expand Down
10 changes: 10 additions & 0 deletions native/crates/es-node/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ enum Op {
PlaylistAdd(String, Vec<String>),
PlaylistRemove(String, Vec<String>),
PlaylistMove(String, Vec<String>, u32),
PlaylistTreeMove { id: String, parent: String, index: u32 },
PlaylistCreate { name: String, parent: Option<String>, folder: bool, ids: Vec<String> },
PlaylistRename(String, String),
PlaylistDelete(String),
Expand All @@ -202,6 +203,7 @@ impl Run {
Op::Histories => "library.histories",
Op::MyTags => "library.myTags",
Op::PlaylistMembers => "library.playlistMembers",
Op::PlaylistTreeMove { .. } => "playlist.treeMove",
Op::DailyBackup => "backup.daily",
Op::BackupCreate => "backup.create",
Op::CueSetHot(_, _, _) => "cue.setHot",
Expand Down Expand Up @@ -460,6 +462,9 @@ impl Run {
Op::PlaylistMove(id, ids, index) => {
return json_of(&service.playlist_move(id, ids, *index as usize).map_err(failure)?);
}
Op::PlaylistTreeMove { id, parent, index } => {
return json_of(&service.playlist_tree_move(id, parent, *index as usize).map_err(failure)?);
}
Op::PlaylistCreate { name, parent, folder, ids } => {
let (id, playlists) = service.playlist_create(name, parent.as_deref(), *folder, ids).map_err(failure)?;
return json_of(&json!({ "id": id, "playlists": playlists }));
Expand Down Expand Up @@ -726,6 +731,11 @@ impl RekordboxLibrary {
self.run(Op::PlaylistMove(id, track_ids, index))
}

#[napi(ts_return_type = "Promise<unknown>")]
pub fn playlist_tree_move(&self, id: String, parent: String, index: u32) -> AsyncTask<Run> {
self.run(Op::PlaylistTreeMove { id, parent, index })
}

#[napi(ts_return_type = "Promise<unknown>")]
pub fn playlist_create(
&self,
Expand Down
Loading