feat(slint): context menus for album cards and track rows

Add a first cut of context menus, opened from the "more" (ellipsis)
button on Discover album cards and on album track rows.

- ContextMenu / ContextMenuItem primitives (PopupWindow-hosted panel)
- AlbumCard: the overlay action buttons are now interactive (they gate
  their pointer handling so non-hovered cards still open on click); the
  more button opens a menu — Open album, Play, Add to queue, Add to
  favorites
- TrackRow: a hover-revealed more button opens a menu — Play, Add to
  queue, Add to favorites; AlbumTrackItem and TrackData carry the
  track id
- Menu actions bubble up as a single media-action(kind, id, action)
  callback through the shell; main.rs logs it for now — playback and
  favorites wiring lands with the playback session
- user and list-plus icons

cc
This commit is contained in:
vicrodh committed 2026-05-18 17:32:56 -06:00
1 parent 1fd3cfcb2b
commit 7eb9627eab
15 files changed
+266 -13

No files matched your search

+3
View File
@@ -26,6 +26,7 @@ pub struct AlbumData {
}
pub struct TrackData {
pub id: String,
pub number: String,
pub title: String,
pub artist: String,
@@ -103,6 +104,7 @@ fn map_track(track: Track) -> TrackData {
title = format!("{title} ({version})");
}
TrackData {
id: track.id.to_string(),
number: track.track_number.to_string(),
title,
artist: track.performer.map(|p| p.name).unwrap_or_default(),
@@ -143,6 +145,7 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
.tracks
.into_iter()
.map(|track| AlbumTrackItem {
id: track.id.into(),
number: track.number.into(),
title: track.title.into(),
artist: track.artist.into(),
+2
View File
@@ -94,6 +94,7 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackData {
title = format!("{title} ({version})");
}
TrackData {
id: track.id.to_string(),
number: (index + 1).to_string(),
title,
artist: track.artist.map(|a| a.name.display).unwrap_or_default(),
@@ -165,6 +166,7 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
.top_tracks
.into_iter()
.map(|track| AlbumTrackItem {
id: track.id.into(),
number: track.number.into(),
title: track.title.into(),
artist: track.artist.into(),
+6
View File
@@ -372,6 +372,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Context-menu / overlay media actions. Playback wiring lands with the
// playback session; for now the action is logged.
window.on_media_action(move |kind, id, action| {
log::info!("[qbz-slint] media-action: kind={kind} id={id} action={action}");
});
window.on_close_app(|| {
log::info!("[qbz-slint] closing");
let _ = slint::quit_event_loop();
+65 -2
View File
@@ -11,6 +11,8 @@ import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { AlbumState, AlbumTrackItem, NavState, ContentView } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { ScrollView } from "std-widgets.slint";
component CircleAction inherits Rectangle {
@@ -46,6 +48,8 @@ component CircleAction inherits Rectangle {
component TrackRow inherits Rectangle {
in property <AlbumTrackItem> track;
in property <int> index;
// kind ("track"), track id, action ("play" | "queue" | "favorite").
callback media-action(string, string, string);
height: 56px;
border-radius: 8px;
@@ -53,6 +57,12 @@ component TrackRow inherits Rectangle {
? Theme.surface-hover
: (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent);
// Row hover/background detector — declared before the content so the
// more-button keeps its own clicks.
ta := TouchArea {
mouse-cursor: default;
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
@@ -124,15 +134,63 @@ component TrackRow inherits Rectangle {
horizontal-alignment: center;
vertical-alignment: center;
}
// More button — fades in on row hover, opens the context menu.
Rectangle {
width: 32px;
more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-menu.show();
}
}
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
opacity: ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 120ms; }
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
track-menu := PopupWindow {
x: parent.width - 208px;
y: 46px;
width: 196px;
height: 109px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("track", root.track.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("track", root.track.id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to favorites");
clicked => {
root.media-action("track", root.track.id, "favorite");
}
}
}
}
}
export component AlbumPageView inherits Rectangle {
callback open-artist(string);
callback media-action(string, string, string);
background: Theme.surface-main;
@@ -317,11 +375,16 @@ export component AlbumPageView inherits Rectangle {
horizontal-alignment: center;
vertical-alignment: center;
}
// Aligns with the per-row more-button slot.
Rectangle { width: 32px; }
}
for track[index] in AlbumState.tracks: TrackRow {
track: track;
index: index;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
}
+4
View File
@@ -33,6 +33,7 @@ export component AppWindow inherits Window {
callback open-tos();
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback logout();
@@ -62,6 +63,9 @@ export component AppWindow inherits Window {
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
@@ -102,6 +102,7 @@ component TrackRow inherits Rectangle {
export component ArtistPageView inherits Rectangle {
callback open-album(string);
callback media-action(string, string, string);
background: Theme.surface-main;
@@ -244,6 +245,9 @@ export component ArtistPageView inherits Rectangle {
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="M18 9v6" />
<path d="M21 12h-6" />
</svg>

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="5" />
<path d="M20 21a8 8 0 0 0-16 0" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

+87 -11
View File
@@ -5,6 +5,9 @@
// genre/year meta, and play / favorite / more action buttons), then a
// tight title / artist stack alongside an icon-only quality badge.
// Deterministic 266px height.
//
// The "more" overlay button opens a context menu; its actions bubble up
// as media-action(kind, id, action).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
@@ -13,18 +16,26 @@ import { Spacing } from "../foundation/spacing.slint";
import { AlbumCardItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadge } from "../primitives/QualityBadge.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
// Circular hover-overlay action button.
// Circular hover-overlay action button. `interactive` gates its pointer
// handling so that, while the card is not hovered, clicks fall through to
// the card-open detector underneath.
component OverlayButton inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <bool> interactive: true;
in property <length> diameter: 36px;
in property <length> icon-size: 16px;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
background: root.primary ? #ffffff : transparent;
background: root.primary
? (ta.has-hover ? #ffffffd0 : #ffffff)
: (ta.has-hover ? #ffffff28 : transparent);
border-width: root.primary ? 0px : 1.5px;
border-color: #ffffffe6;
@@ -36,11 +47,21 @@ component OverlayButton inherits Rectangle {
y: (parent.height - self.height) / 2;
tint: root.primary ? #000000 : #ffffff;
}
ta := TouchArea {
enabled: root.interactive;
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component AlbumCard inherits Rectangle {
in property <AlbumCardItem> album;
callback clicked(string);
// kind ("album") , album id, action ("play" | "queue" | "favorite").
callback media-action(string, string, string);
// Fully deterministic height: artwork 220 + 6 spacer + 40 meta.
width: 220px;
@@ -102,6 +123,17 @@ export component AlbumCard inherits Rectangle {
}
}
// Card-open + hover detector. Declared before the action
// buttons so those, when interactive, win the pointer.
hover := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: pointer;
clicked => {
root.clicked(root.album.id);
}
}
// Hover action buttons — favorite / play / more.
HorizontalLayout {
x: 0px;
@@ -115,17 +147,33 @@ export component AlbumCard inherits Rectangle {
VerticalLayout {
alignment: center;
OverlayButton { icon: @image-url("../assets/icons/heart.svg"); }
OverlayButton {
icon: @image-url("../assets/icons/heart.svg");
interactive: hover.has-hover;
clicked => {
root.media-action("album", root.album.id, "favorite");
}
}
}
OverlayButton {
icon: @image-url("../assets/icons/play.svg");
primary: true;
diameter: 44px;
icon-size: 18px;
interactive: hover.has-hover;
clicked => {
root.media-action("album", root.album.id, "play");
}
}
VerticalLayout {
alignment: center;
OverlayButton { icon: @image-url("../assets/icons/ellipsis.svg"); }
OverlayButton {
icon: @image-url("../assets/icons/ellipsis.svg");
interactive: hover.has-hover;
clicked => {
album-menu.show();
}
}
}
}
@@ -169,13 +217,41 @@ export component AlbumCard inherits Rectangle {
}
}
// Hover + click detector — topmost so it owns the pointer.
hover := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: pointer;
clicked => {
root.clicked(root.album.id);
// Context menu for the "more" button.
album-menu := PopupWindow {
x: 26px;
y: 56px;
width: 196px;
height: 142px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Open album");
clicked => {
root.clicked(root.album.id);
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("album", root.album.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("album", root.album.id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to favorites");
clicked => {
root.media-action("album", root.album.id, "favorite");
}
}
}
}
}
@@ -45,6 +45,7 @@ component NavButton inherits Rectangle {
export component Carousel inherits VerticalLayout {
in property <DiscoverSection> section;
callback album-clicked(string);
callback media-action(string, string, string);
property <length> card-width: 220px;
property <length> card-height: 266px;
@@ -118,6 +119,9 @@ export component Carousel inherits VerticalLayout {
clicked(id) => {
root.album-clicked(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
}
@@ -107,6 +107,7 @@ component GearButton inherits Rectangle {
export component HomeView inherits Rectangle {
callback album-clicked(string);
callback media-action(string, string, string);
background: Theme.surface-main;
@@ -182,6 +183,9 @@ export component HomeView inherits Rectangle {
album-clicked(id) => {
root.album-clicked(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if HomeState.popular.length > 0: GridSection {
@@ -0,0 +1,22 @@
// Context-menu surface — the styled panel that hosts ContextMenuItem
// rows. Place it as the single child of a PopupWindow; the rows are
// passed as children.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
export component ContextMenu inherits Rectangle {
background: Theme.surface-elevated;
border-radius: Radius.md;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 22px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
VerticalLayout {
padding-top: 5px;
padding-bottom: 5px;
@children
}
}
@@ -0,0 +1,43 @@
// One row of a context menu — an icon, a label, and a click callback.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { QbzIcon } from "QbzIcon.slint";
export component ContextMenuItem inherits Rectangle {
in property <image> icon;
in property <string> label;
callback clicked();
height: 33px;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 11px;
padding-right: 16px;
spacing: 10px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
}
Text {
text: root.label;
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
+10
View File
@@ -18,6 +18,7 @@ import { SettingsView } from "../settings/SettingsView.slint";
export component AppShell inherits Rectangle {
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback logout();
@@ -52,16 +53,25 @@ export component AppShell inherits Rectangle {
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.album: AlbumPageView {
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.artist: ArtistPageView {
open-album(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
+1
View File
@@ -41,6 +41,7 @@ export global HomeState {
}
export struct AlbumTrackItem {
id: string,
number: string,
title: string,
artist: string,