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:
21 files changed
+1223
-65
No files matched your search
@@ -0,0 +1,45 @@
|
||||
// Floating drag ghost — the dark pill that follows the cursor while
|
||||
// tracks are being dragged onto the sidebar. Mirrors Tauri's
|
||||
// setDragImage pill: "N tracks" for a group, or title + "artist ·
|
||||
// album" for one. Visual only (no TouchArea, so it never blocks the
|
||||
// pointer). Rendered once at the top of AppShell.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { DragState } from "../state.slint";
|
||||
|
||||
export component DragGhost inherits Rectangle {
|
||||
if DragState.active: Rectangle {
|
||||
x: DragState.pointer-x + 10px;
|
||||
y: DragState.pointer-y + 14px;
|
||||
width: pill.preferred-width;
|
||||
height: pill.preferred-height;
|
||||
border-radius: 8px;
|
||||
background: #1e1e28e0;
|
||||
border-width: 1px;
|
||||
border-color: #ffffff1f;
|
||||
drop-shadow-blur: 14px;
|
||||
drop-shadow-color: #00000059;
|
||||
|
||||
pill := VerticalLayout {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: DragState.count > 1
|
||||
? DragState.count + " " + @tr("tracks")
|
||||
: DragState.ghost-title;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
if DragState.count == 1 && DragState.ghost-subtitle != "": Text {
|
||||
text: DragState.ghost-subtitle;
|
||||
color: #ffffff8c;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Reusable expandable page-search — the exact pattern from the JUMP TO
|
||||
// bar (ArtistView): a magnifier that, on click, opens a right-anchored
|
||||
// input that grows to the LEFT (over whatever sits to its left), with
|
||||
// a leading magnifier glyph + a trailing X that clears and closes.
|
||||
//
|
||||
// Self-contained: occupies a small fixed footprint when closed; the
|
||||
// open overlay overflows to the left, so nothing to its right moves.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
|
||||
export component ExpandableSearch inherits Rectangle {
|
||||
in property <string> placeholder: "Search…";
|
||||
in property <length> max-open-width: 216px;
|
||||
in-out property <bool> open: false;
|
||||
in-out property <string> query;
|
||||
callback edited(string);
|
||||
|
||||
changed open => {
|
||||
if (root.open) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Closed footprint — just the magnifier slot.
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
||||
// Closed: the magnifier toggle.
|
||||
Rectangle {
|
||||
visible: !root.open;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: open-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: open-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
open-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open: right-anchored overlay that grows left.
|
||||
overlay := Rectangle {
|
||||
visible: root.open;
|
||||
x: parent.width - self.width;
|
||||
width: root.open ? root.max-open-width : 0px;
|
||||
height: 34px;
|
||||
background: Theme.surface-card;
|
||||
border-radius: 6px;
|
||||
border-width: 1px;
|
||||
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
|
||||
clip: true;
|
||||
animate width { duration: 200ms; easing: ease-in-out; }
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 11px;
|
||||
padding-right: 5px;
|
||||
spacing: 8px;
|
||||
// Leading magnifier glyph (decorative).
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 0;
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
// Input + placeholder.
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
clip: true;
|
||||
input := TextInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
single-line: true;
|
||||
text <=> root.query;
|
||||
edited => {
|
||||
root.edited(self.text);
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: input.text == "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text: root.placeholder;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
// Trailing X — clears + closes (back to the magnifier).
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 0;
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: clear-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
clear-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.query = "";
|
||||
root.edited("");
|
||||
root.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Custom vertical scrollbar for a ListView — Slint's built-in one is
|
||||
// too thin and its thumb has no minimum size, so on a thousands-row
|
||||
// list it shrinks to a sliver. This sits in its own gutter (not over
|
||||
// the rows), is thicker, keeps a minimum thumb height, and is
|
||||
// draggable. Bind `viewport-y` two-way to the ListView and feed it the
|
||||
// list's viewport/visible heights.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
|
||||
export component ListScrollbar inherits Rectangle {
|
||||
in property <length> viewport-height;
|
||||
in property <length> visible-height;
|
||||
in-out property <length> viewport-y;
|
||||
|
||||
// Only meaningful when the content overflows.
|
||||
property <length> max-scroll: max(0px, root.viewport-height - root.visible-height);
|
||||
property <length> thumb-h:
|
||||
root.viewport-height > 0px
|
||||
? max(48px, root.visible-height / root.viewport-height * root.height)
|
||||
: root.height;
|
||||
property <length> travel: max(0px, root.height - root.thumb-h);
|
||||
|
||||
width: 14px;
|
||||
visible: root.max-scroll > 0px;
|
||||
|
||||
// Track.
|
||||
Rectangle {
|
||||
width: 6px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
height: 100%;
|
||||
background: #ffffff12;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// Thumb.
|
||||
thumb := Rectangle {
|
||||
width: bar-ta.has-hover || bar-ta.pressed ? 10px : 8px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
height: root.thumb-h;
|
||||
y: root.max-scroll > 0px
|
||||
? (-root.viewport-y / root.max-scroll) * root.travel
|
||||
: 0px;
|
||||
border-radius: 5px;
|
||||
background: bar-ta.has-hover || bar-ta.pressed ? #ffffff77 : #ffffff44;
|
||||
animate width { duration: 100ms; }
|
||||
}
|
||||
|
||||
// Drag / click-to-position over the whole gutter — centers the
|
||||
// thumb on the pointer.
|
||||
bar-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
moved => {
|
||||
if (bar-ta.pressed && root.travel > 0px) {
|
||||
root.viewport-y = -min(root.max-scroll, max(0px,
|
||||
(bar-ta.mouse-y - root.thumb-h / 2) / root.travel * root.max-scroll));
|
||||
}
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && root.travel > 0px) {
|
||||
root.viewport-y = -min(root.max-scroll, max(0px,
|
||||
(bar-ta.mouse-y - root.thumb-h / 2) / root.travel * root.max-scroll));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { TrackItem } from "../state.slint";
|
||||
import { TrackItem, DragActions } from "../state.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
import { TrackPlayCell } from "TrackPlayCell.slint";
|
||||
import { TrackContextMenu } from "TrackContextMenu.slint";
|
||||
@@ -31,6 +31,11 @@ export component TrackRow inherits Rectangle {
|
||||
in property <bool> show-favorite: false;
|
||||
in property <bool> show-download: false;
|
||||
in property <bool> multi-select-mode: false;
|
||||
// Custom-order reorder controls (up/down chevrons) at the leading
|
||||
// edge — used by the playlist custom sort.
|
||||
in property <bool> show-reorder: false;
|
||||
// Whether the row can be dragged onto sidebar playlists.
|
||||
in property <bool> draggable: true;
|
||||
callback media-action(string, string, string);
|
||||
|
||||
// Explicit track number, else the 1-based list position.
|
||||
@@ -39,14 +44,58 @@ export component TrackRow inherits Rectangle {
|
||||
property <bool> row-hovered:
|
||||
ta.has-hover || play-cell.hovered || more-ta.has-hover;
|
||||
|
||||
height: 56px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
background: root.row-hovered
|
||||
? #ffffff16
|
||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||
|
||||
// Background TouchArea — also the drag source. Pressing the row
|
||||
// body (not the play/fav/more controls, which sit on top) and
|
||||
// moving past a threshold begins a track drag onto the sidebar
|
||||
// playlists. Global coords = the row's absolute-position + the
|
||||
// pointer offset; the TouchArea grabs on press so moves keep
|
||||
// arriving even once the pointer leaves the row.
|
||||
ta := TouchArea {
|
||||
mouse-cursor: default;
|
||||
property <length> down-x;
|
||||
property <length> down-y;
|
||||
property <bool> dragging: false;
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down) {
|
||||
ta.down-x = root.absolute-position.x + ta.mouse-x;
|
||||
ta.down-y = root.absolute-position.y + ta.mouse-y;
|
||||
ta.dragging = false;
|
||||
}
|
||||
if (e.kind == PointerEventKind.up && ta.dragging) {
|
||||
DragActions.end();
|
||||
ta.dragging = false;
|
||||
}
|
||||
}
|
||||
moved => {
|
||||
if (ta.pressed && root.draggable) {
|
||||
if (!ta.dragging
|
||||
&& (Math.abs(root.absolute-position.x + ta.mouse-x - ta.down-x) > 6px
|
||||
|| Math.abs(root.absolute-position.y + ta.mouse-y - ta.down-y) > 6px)) {
|
||||
ta.dragging = true;
|
||||
debug("DRAG absx=", root.absolute-position.x, " absy=", root.absolute-position.y,
|
||||
" mx=", ta.mouse-x, " my=", ta.mouse-y);
|
||||
DragActions.start(
|
||||
root.track.id,
|
||||
root.track.title,
|
||||
root.track.album != ""
|
||||
? root.track.artist + " · " + root.track.album
|
||||
: root.track.artist,
|
||||
root.absolute-position.x + ta.mouse-x,
|
||||
root.absolute-position.y + ta.mouse-y);
|
||||
}
|
||||
if (ta.dragging) {
|
||||
DragActions.move(
|
||||
root.absolute-position.x + ta.mouse-x,
|
||||
root.absolute-position.y + ta.mouse-y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
@@ -54,6 +103,48 @@ export component TrackRow inherits Rectangle {
|
||||
padding-right: 12px;
|
||||
spacing: 14px;
|
||||
|
||||
// Reorder chevrons (custom-order mode) — leading edge.
|
||||
if root.show-reorder: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 0px;
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/chevron-up.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: up-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
up-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("track", root.track.id, "move-up");
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/chevron-down.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: down-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
down-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("track", root.track.id, "move-down");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position number — only for artwork rows (the cover doesn't
|
||||
// carry the number; non-artwork rows show it in the play cell).
|
||||
if root.show-artwork && !root.multi-select-mode: VerticalLayout {
|
||||
@@ -75,13 +166,14 @@ export component TrackRow inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: root.show-artwork ? 40px : 32px;
|
||||
height: root.show-artwork ? 40px : 28px;
|
||||
width: root.show-artwork ? 36px : 32px;
|
||||
height: root.show-artwork ? 36px : 28px;
|
||||
play-cell := TrackPlayCell {
|
||||
track-id: root.track.id;
|
||||
number: root.number-text;
|
||||
artwork: root.track.artwork;
|
||||
show-artwork: root.show-artwork;
|
||||
art-size: 36px;
|
||||
number-width: 32px;
|
||||
row-hovered: root.row-hovered && !root.multi-select-mode;
|
||||
visible: !root.multi-select-mode;
|
||||
@@ -129,7 +221,7 @@ export component TrackRow inherits Rectangle {
|
||||
Text {
|
||||
text: root.track.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-size: Typography.link;
|
||||
font-weight: Typography.medium;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
@@ -155,7 +247,7 @@ export component TrackRow inherits Rectangle {
|
||||
if root.track.artist != "": Text {
|
||||
text: root.track.artist;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.link;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user