feat(slint): LocalLibrary track-row context menu homologated 1:1 with Tauri
Per the reviewed spec (qbz-nix-docs/handoffs/2026-06-10-locallibrary- trackmenu-1to1-spec.md): Add to Mixtape/Collection unhidden for local surfaces via an independent mixtape-action flag; force-local-menu kills the dead 9-entry Qobuz block on qobuz_download rows (SourceGlyph untouched); favorite entry on qobuz_download rows only (Tauri's Add to Library ported minus its local-id-to-Qobuz-API bug, spec 3.2); Add to Playlist stays visible offline (deliberate deviation, our local playlists accept tracks offline, spec 3.3/D7-D8); right-click opens the same menu at the pointer (PointerEventButton.right, suppressed in multi-select, Tauri parity). Qobuz surfaces byte-identical (flags default to the old gate). Rust arms ride the next commit (shared main.rs). cc
This commit is contained in:
4 files changed
+94
-20
No files matched your search
@@ -482,6 +482,9 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
show-download: false;
|
show-download: false;
|
||||||
// Show the per-row source glyph (local / qobuz-offline / Plex).
|
// Show the per-row source glyph (local / qobuz-offline / Plex).
|
||||||
show-source: true;
|
show-source: true;
|
||||||
|
// Library surface: local menu even for qobuz_download
|
||||||
|
// rows (spec §3.4).
|
||||||
|
force-local-menu: true;
|
||||||
media-action(kind, id, action) => {
|
media-action(kind, id, action) => {
|
||||||
// Local-only: ignore album/artist link clicks. "play"
|
// Local-only: ignore album/artist link clicks. "play"
|
||||||
// (row body / play cell) plays; the context-menu
|
// (row body / play cell) plays; the context-menu
|
||||||
|
|||||||
@@ -1405,6 +1405,9 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
show-favorite: false;
|
show-favorite: false;
|
||||||
show-download: false;
|
show-download: false;
|
||||||
show-source: true;
|
show-source: true;
|
||||||
|
// Library surface: local menu even for
|
||||||
|
// qobuz_download rows (spec §3.4).
|
||||||
|
force-local-menu: true;
|
||||||
multi-select-mode: LocalLibraryState.tracks-multi-select;
|
multi-select-mode: LocalLibraryState.tracks-multi-select;
|
||||||
media-action(kind, id, action) => {
|
media-action(kind, id, action) => {
|
||||||
if (kind == "album" && action == "open") {
|
if (kind == "album" && action == "open") {
|
||||||
@@ -2066,6 +2069,9 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
show-favorite: false;
|
show-favorite: false;
|
||||||
show-download: false;
|
show-download: false;
|
||||||
show-source: true;
|
show-source: true;
|
||||||
|
// Library surface: local menu even
|
||||||
|
// for qobuz_download rows (§3.4).
|
||||||
|
force-local-menu: true;
|
||||||
media-action(kind, id, action) => {
|
media-action(kind, id, action) => {
|
||||||
if (kind == "album" && action == "open") {
|
if (kind == "album" && action == "open") {
|
||||||
LocalLibraryActions.open-album(id);
|
LocalLibraryActions.open-album(id);
|
||||||
|
|||||||
@@ -9,19 +9,29 @@ import { ContextMenuItem } from "ContextMenuItem.slint";
|
|||||||
export component TrackContextMenu inherits PopupWindow {
|
export component TrackContextMenu inherits PopupWindow {
|
||||||
in property <string> track-id;
|
in property <string> track-id;
|
||||||
// Per-source applicability (the surrounding row sets these from the track's
|
// Per-source applicability (the surrounding row sets these from the track's
|
||||||
// source). `qobuz-actions` = the Qobuz-backed block (favorite, radio,
|
// source). `qobuz-actions` = the Qobuz-backed block (radio, share,
|
||||||
// mixtape, share, make-offline, go-to album/artist, track info) — on for
|
// make-offline, go-to album/artist, track info) — on for Qobuz + offline,
|
||||||
// Qobuz + offline, off for local / Plex / ephemeral. `playlist-action` =
|
// off for local / Plex / ephemeral. `favorite-action` / `mixtape-action`
|
||||||
// Add to playlist — on for Qobuz + offline + local + Plex (local playlists
|
// gate the favorite and Add-to-Mixtape entries independently of the block
|
||||||
// store each source's own ref), off for ephemeral. The transient actions
|
// (library surfaces show them on rows the Qobuz block is dead for — spec
|
||||||
// (play / next / queue) always show.
|
// §3.1/§3.2); they default to the block so Qobuz surfaces are unchanged.
|
||||||
|
// `playlist-action` = Add to playlist — on for Qobuz + offline + local +
|
||||||
|
// Plex (local playlists store each source's own ref), off for ephemeral.
|
||||||
|
// The transient actions (play / next / queue) always show.
|
||||||
in property <bool> qobuz-actions: true;
|
in property <bool> qobuz-actions: true;
|
||||||
|
in property <bool> favorite-action: root.qobuz-actions;
|
||||||
|
in property <bool> mixtape-action: root.qobuz-actions;
|
||||||
in property <bool> playlist-action: true;
|
in property <bool> playlist-action: true;
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
width: 224px;
|
width: 224px;
|
||||||
// 3 transient + optional add-to-playlist + the 9-item Qobuz block.
|
// 3 transient + optional favorite/mixtape/add-to-playlist + the
|
||||||
height: (3 + (root.playlist-action ? 1 : 0) + (root.qobuz-actions ? 9 : 0)) * 30px;
|
// 7-item Qobuz block.
|
||||||
|
height: (3
|
||||||
|
+ (root.favorite-action ? 1 : 0)
|
||||||
|
+ (root.mixtape-action ? 1 : 0)
|
||||||
|
+ (root.playlist-action ? 1 : 0)
|
||||||
|
+ (root.qobuz-actions ? 7 : 0)) * 30px;
|
||||||
|
|
||||||
ContextMenu {
|
ContextMenu {
|
||||||
ContextMenuItem {
|
ContextMenuItem {
|
||||||
@@ -52,14 +62,14 @@ export component TrackContextMenu inherits PopupWindow {
|
|||||||
root.media-action("track", root.track-id, "create-radio");
|
root.media-action("track", root.track-id, "create-radio");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.qobuz-actions: ContextMenuItem {
|
if root.favorite-action: ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/heart.svg");
|
icon: @image-url("../assets/icons/heart.svg");
|
||||||
label: @tr("Add to library");
|
label: @tr("Add to library");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.media-action("track", root.track-id, "favorite");
|
root.media-action("track", root.track-id, "favorite");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.qobuz-actions: ContextMenuItem {
|
if root.mixtape-action: ContextMenuItem {
|
||||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||||
label: @tr("Add to mixtape");
|
label: @tr("Add to mixtape");
|
||||||
clicked => {
|
clicked => {
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export component TrackRow inherits Rectangle {
|
|||||||
// Trailing "..." context-menu column. Off for rows that have no menu
|
// Trailing "..." context-menu column. Off for rows that have no menu
|
||||||
// actions (e.g. ephemeral-folder rows — play-only, no persistence).
|
// actions (e.g. ephemeral-folder rows — play-only, no persistence).
|
||||||
in property <bool> show-menu: true;
|
in property <bool> show-menu: true;
|
||||||
|
// Library surfaces (LocalLibrary Tracks tab / local album detail /
|
||||||
|
// folder detail) force the LOCAL menu even for rows whose source
|
||||||
|
// renders as "qobuz" (qobuz_download offline copies): Tauri shows the
|
||||||
|
// same minimal local menu there, and the Qobuz block's arms are dead
|
||||||
|
// on library row ids (spec §3.4). The SourceGlyph keeps reading the
|
||||||
|
// unchanged source field.
|
||||||
|
in property <bool> force-local-menu: false;
|
||||||
// Custom-order reorder controls (up/down chevrons) at the leading
|
// Custom-order reorder controls (up/down chevrons) at the leading
|
||||||
// edge — used by the playlist custom sort.
|
// edge — used by the playlist custom sort.
|
||||||
in property <bool> show-reorder: false;
|
in property <bool> show-reorder: false;
|
||||||
@@ -57,6 +64,12 @@ export component TrackRow inherits Rectangle {
|
|||||||
// Set from the (conditional) context-menu button so the row highlight
|
// Set from the (conditional) context-menu button so the row highlight
|
||||||
// still reacts to hovering it without referencing a possibly-absent id.
|
// still reacts to hovering it without referencing a possibly-absent id.
|
||||||
property <bool> menu-hovered: false;
|
property <bool> menu-hovered: false;
|
||||||
|
// Right-click context mode (spec §3.5): the popup opens at the pointer
|
||||||
|
// instead of the "..." anchor. ta fills the row, so ta.mouse-x/y are
|
||||||
|
// already the popup's parent-relative coordinates.
|
||||||
|
property <bool> menu-at-pointer: false;
|
||||||
|
property <length> menu-click-x;
|
||||||
|
property <length> menu-click-y;
|
||||||
property <bool> row-hovered:
|
property <bool> row-hovered:
|
||||||
ta.has-hover || play-cell.hovered || root.menu-hovered;
|
ta.has-hover || play-cell.hovered || root.menu-hovered;
|
||||||
|
|
||||||
@@ -97,6 +110,19 @@ export component TrackRow inherits Rectangle {
|
|||||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.forward) {
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.forward) {
|
||||||
NavState.request-forward();
|
NavState.request-forward();
|
||||||
}
|
}
|
||||||
|
// Right-click opens the same context menu at the pointer (Tauri's
|
||||||
|
// contextmenu mode, spec §3.5). Suppressed in multi-select mode
|
||||||
|
// (Tauri parity: row right-click toggles nothing there) and on
|
||||||
|
// menu-less rows (ephemeral). x clamps to the row so the menu
|
||||||
|
// never overflows past the "..." gutter; vertical overflow at the
|
||||||
|
// window bottom matches the existing "..." anchor behavior.
|
||||||
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right
|
||||||
|
&& root.show-menu && !root.multi-select-mode) {
|
||||||
|
root.menu-at-pointer = true;
|
||||||
|
root.menu-click-x = ta.mouse-x;
|
||||||
|
root.menu-click-y = ta.mouse-y;
|
||||||
|
track-menu.show();
|
||||||
|
}
|
||||||
if (e.kind == PointerEventKind.down) {
|
if (e.kind == PointerEventKind.down) {
|
||||||
// absolute-position double-counts this row's own layout offset
|
// absolute-position double-counts this row's own layout offset
|
||||||
// when the row sits inside a Flickable (the track list), so the
|
// when the row sits inside a Flickable (the track list), so the
|
||||||
@@ -534,6 +560,7 @@ export component TrackRow inherits Rectangle {
|
|||||||
more-ta := TouchArea {
|
more-ta := TouchArea {
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
clicked => {
|
clicked => {
|
||||||
|
root.menu-at-pointer = false;
|
||||||
track-menu.show();
|
track-menu.show();
|
||||||
}
|
}
|
||||||
changed has-hover => {
|
changed has-hover => {
|
||||||
@@ -544,18 +571,46 @@ export component TrackRow inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True for rows whose source is Qobuz-backed (Qobuz surfaces + offline
|
||||||
|
// copies); the menu gates below split on it.
|
||||||
|
property <bool> qobuz-row: root.track.source != "local"
|
||||||
|
&& root.track.source != "plex"
|
||||||
|
&& root.track.source != "ephemeral";
|
||||||
|
|
||||||
track-menu := TrackContextMenu {
|
track-menu := TrackContextMenu {
|
||||||
x: parent.width - self.width - 12px;
|
// Anchored at the "..." gutter, or at the right-click pointer in
|
||||||
y: 48px;
|
// context mode (spec §3.5).
|
||||||
|
x: root.menu-at-pointer
|
||||||
|
? Math.max(0px, Math.min(root.menu-click-x, root.width - self.width - 12px))
|
||||||
|
: parent.width - self.width - 12px;
|
||||||
|
y: root.menu-at-pointer ? root.menu-click-y : 48px;
|
||||||
track-id: root.track.id;
|
track-id: root.track.id;
|
||||||
// Per-source menu logic: Qobuz-backed actions (favorite, radio, share,
|
// Per-source menu logic: the Qobuz block (radio, share, offline,
|
||||||
// mixtape, offline, go-to, track-info) only for Qobuz/offline rows;
|
// go-to, track-info) only for Qobuz/offline rows, and never on
|
||||||
// Add-to-playlist for Qobuz/offline/local/Plex (local playlists store
|
// library surfaces (force-local-menu — its arms are dead on library
|
||||||
// each source's own ref), not ephemeral (no persistent row to
|
// row ids, spec §3.4); transient play/queue always.
|
||||||
// reference); transient play/queue always.
|
qobuz-actions: !root.force-local-menu && root.qobuz-row;
|
||||||
qobuz-actions: root.track.source != "local"
|
// Favorite: on library surfaces only qobuz_download rows (source
|
||||||
&& root.track.source != "plex"
|
// renders "qobuz") get the entry, wired to the REAL Qobuz id in
|
||||||
&& root.track.source != "ephemeral";
|
// Rust. Plain local/Plex rows get NO favorite entry — deliberate
|
||||||
|
// deviation from Tauri, whose "Add to Library" there sends the
|
||||||
|
// LOCAL row id to the Qobuz favorites API (spec §1.4/§3.2 latent
|
||||||
|
// bug): we port the intent, not the bug.
|
||||||
|
favorite-action: root.force-local-menu
|
||||||
|
? root.track.source == "qobuz"
|
||||||
|
: root.qobuz-row;
|
||||||
|
// Add to Mixtape/Collection on library surfaces for every
|
||||||
|
// persistent row (local + Plex + qobuz_download; spec §3.1 —
|
||||||
|
// Tauri's Tracks-tab-only asymmetry is an artifact, not a design).
|
||||||
|
mixtape-action: root.force-local-menu
|
||||||
|
? root.track.source != "ephemeral"
|
||||||
|
: root.qobuz-row;
|
||||||
|
// Add-to-playlist for Qobuz/offline/local/Plex (local playlists
|
||||||
|
// store each source's own ref), not ephemeral (no persistent row
|
||||||
|
// to reference). Deliberately NOT hidden offline (deviation from
|
||||||
|
// Tauri's shouldHidePlaylistFeatures, spec §3.3): our local
|
||||||
|
// playlists accept tracks offline (D7/D8) and the picker already
|
||||||
|
// hides the Qobuz set offline.
|
||||||
playlist-action: root.track.source != "ephemeral";
|
playlist-action: root.track.source != "ephemeral";
|
||||||
media-action(k, i, a) => {
|
media-action(k, i, a) => {
|
||||||
root.media-action(k, i, a);
|
root.media-action(k, i, a);
|
||||||
|
|||||||
Reference in new issue
Block a user