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:
vicrodh committed 2026-06-10 10:09:11 -06:00
1 parent 016869e21b
commit 603a2725fb
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 => {
+65 -10
View File
@@ -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);