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:
15 files changed
+266
-13
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export global HomeState {
|
||||
}
|
||||
|
||||
export struct AlbumTrackItem {
|
||||
id: string,
|
||||
number: string,
|
||||
title: string,
|
||||
artist: string,
|
||||
|
||||
Reference in new issue
Block a user