feat(slint): playlist detail polish, sort (incl. custom) + WIP track drag

Polish + consistency:
  - Compact playlist header (cover 150px; nav->header spacing
    homologated with AlbumView); description truncated to ~2 lines +
    "Read more" modal; custom-artwork buttons only on cover hover.
  - New Typography.section (18px) token for hero/section titles,
    applied across Album / Artist / Mix / Playlist + Home carousels.
  - Shorter track rows app-wide (50px row, 36px artwork, -1 fonts);
    +3px column-header/divider gap; wider track-number column.
  - Reusable ExpandableSearch (the JumpNavBar page-search pattern:
    magnifier -> opens left -> X to close) in the playlist action row.
  - Custom ListScrollbar (thicker, min thumb height, own gutter) since
    Slint's ListView scrollbar can't be styled.

Sort:
  - Default / Title / Artist / Album / Duration with asc/desc per
    field, plus Custom (manual order persisted in library.db with
    up/down reorder). Client-side search + sort combined.

WIP: track drag-and-drop onto sidebar playlists
  - DragState/DragActions globals, drag source on the shared TrackRow,
    follow-cursor DragGhost, sidebar playlist rows self-detect the drop
    via absolute-position, multi-select group drag, add_tracks_to_playlist
    on drop, Flickables freeze scroll during a drag.
  - KNOWN ISSUE: the ghost is offset from the cursor (the row
    absolute-position + pointer mapping is off, esp. inside Flickables).
    Diagnostic logging is left in (TrackRow debug() + a start log) to
    pin down the wrong term before the generic fix.

cc
This commit is contained in:
vicrodh committed 2026-05-22 18:26:55 -06:00
1 parent 85223b03ff
commit de826bf560
21 files changed
+1223 -65

No files matched your search

+4
View File
@@ -14,6 +14,7 @@ import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
import { DragGhost } from "../primitives/DragGhost.slint";
import { PlayerBar } from "PlayerBar.slint";
import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint";
@@ -237,4 +238,7 @@ export component AppShell inherits Rectangle {
// Global "Create folder" modal.
CreateFolderModal { }
// Floating track-drag ghost (follows the cursor, on top of all).
DragGhost { }
}
+39 -3
View File
@@ -12,7 +12,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry } from "../state.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState } from "../state.slint";
import { ScrollView } from "std-widgets.slint";
// A single context-menu item row.
@@ -55,10 +55,46 @@ component SidebarRow inherits Rectangle {
callback delete-folder(string);
property <bool> is-folder: root.entry.kind == "folder";
// Track-drag drop target: a playlist row self-detects whether the
// global drag pointer is within its bounds (the source grabs the
// pointer, so normal hover doesn't fire during a drag). It claims
// the drop target while hot, and releases it when the pointer
// leaves.
//
// absolute-position must be read in an event handler (recompute),
// NOT a declarative binding — reading it during the layout phase
// is a recursion. So we mirror the drag pointer/active flag and
// recompute on their change.
property <length> drag-px: DragState.pointer-x;
property <length> drag-py: DragState.pointer-y;
property <bool> drag-on: DragState.active;
property <bool> drop-hot: false;
function recompute-drop() {
root.drop-hot = root.drag-on
&& !root.is-folder
&& root.drag-px >= root.absolute-position.x
&& root.drag-px <= root.absolute-position.x + root.width
&& root.drag-py >= root.absolute-position.y
&& root.drag-py <= root.absolute-position.y + root.height;
if (root.drop-hot) {
DragState.over-playlist-id = root.entry.id;
} else if (DragState.over-playlist-id == root.entry.id) {
DragState.over-playlist-id = "";
}
}
changed drag-px => { root.recompute-drop(); }
changed drag-py => { root.recompute-drop(); }
changed drag-on => { root.recompute-drop(); }
height: 32px;
border-radius: 6px;
background: ta.has-hover || more-ta.has-hover || root.active
? Theme.surface-hover : transparent;
background: root.drop-hot
? #22c55e26
: (ta.has-hover || more-ta.has-hover || root.active
? Theme.surface-hover : transparent);
border-width: root.drop-hot ? 1px : 0px;
border-color: #22c55e66;
HorizontalLayout {
padding-left: root.entry.indent ? 24px : 8px;