feat(slint): post-lyrics UI polish sweep + correctness fixes

- TrackRow: Korean/CJK titles render via layout-assigned width+height
  (SongCard parity, never femtovg-measured); explicit "E" badge hugs the
  title text; duration + position number dropped to 13px so they read
  smaller than the 14px title.
- AlbumView: label/awards rail 214->160px with smaller card/icon/padding
  and centered headings in the compact rail; removed the dark right gutter
  (full-width Flickable, content clears the scrollbar via the 32px page
  padding); column headers homologated to Playlist casing (Title/Duration/
  Quality), same in LocalAlbumView.
- ArtistView: network sidebar auto-collapses on ShellState.content-
  constrained (the AlbumView signal) and no longer re-opens on artist->
  artist navigation (reset_network_sidebar respects the constraint).
- Hero titles homologated to Typography.section (18px) across MyQBZ detail,
  Musician, Label releases, Artists-by-location, Offline Manager.
- Lyrics: Qobuz || LRCLIB parallel fetch, next-track prefetch, source
  footer, asymmetric line fade.

cc
This commit is contained in:
vicrodh committed 2026-06-11 22:03:46 -06:00
1 parent 67a0e39841
commit 999ded11ff
32 files changed
+1512 -406

No files matched your search

+207 -48
View File
@@ -9,7 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState, NowPlayingState } from "../state.slint";
import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState, NowPlayingState, ShellState, TooltipState } from "../state.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
@@ -17,50 +17,57 @@ import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { AlbumContextMenu } from "../primitives/AlbumContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
// One label/award entry in the album sidebar — a round gradient avatar
// and a name.
// and a name. In `compact` mode the name is dropped (the rail is an
// icon-only strip) and shown in a hover tooltip instead.
component SidebarCard inherits Rectangle {
in property <image> icon;
in property <string> name;
in property <brush> avatar-bg;
in property <bool> compact: false;
callback clicked();
height: 56px;
height: 48px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding: 8px;
padding: 6px;
spacing: 10px;
alignment: root.compact ? center : start;
VerticalLayout {
alignment: center;
Rectangle {
width: 36px;
height: 36px;
border-radius: 18px;
width: 28px;
height: 28px;
border-radius: 14px;
background: root.avatar-bg;
QbzIcon {
source: root.icon;
width: 18px;
height: 18px;
width: 13px;
height: 13px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
}
VerticalLayout {
if !root.compact: VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.name;
color: Theme.text-secondary;
font-size: Typography.link;
// -2pts vs the Typography.link (14px) base — the label/award
// NAME is what needed trimming, not the LABEL/AWARDS heading.
font-size: 12px;
font-weight: Typography.medium;
wrap: word-wrap;
}
@@ -69,6 +76,17 @@ component SidebarCard inherits Rectangle {
ta := TouchArea {
mouse-cursor: pointer;
// Compact rail: the name lives in a hover tooltip.
changed has-hover => {
if (self.has-hover && root.compact && root.name != "") {
TooltipState.text = root.name;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
clicked => {
root.clicked();
}
@@ -88,10 +106,27 @@ export component AlbumPageView inherits Rectangle {
// Whether the full-description modal is open.
property <bool> show-description: false;
// Header description picks the SHORTER cutoff when the content area is
// space-constrained (window <1366 AND a right panel open), so the tracks
// keep priority; back to the full short text when there's room.
property <string> desc-display:
ShellState.content-constrained
? AlbumState.description-shorter
: AlbumState.description-short;
background: Theme.surface-main;
flick := Flickable {
// Full-width: the gradient + page background bleed edge-to-edge so
// there is no dark right gutter. Content stays clear of the scrollbar
// via the page's 32px right padding — the scrollbar lives in the
// outer 18px (parent.width-18 .. parent.width-4), and every text /
// track / label-rail child ends at parent.width-32, a 14px gap, so
// nothing paints under it. The gradient is the only thing reaching
// the edge, which is exactly the desired full-bleed colour. (The
// earlier `parent.width - 20px` clipped the gradient too, leaving an
// ugly dark gutter strip on the right.)
width: parent.width;
// Freeze scroll during a track drag so the ghost doesn't drift.
interactive: !DragState.active;
viewport-width: self.width;
@@ -262,7 +297,41 @@ export component AlbumPageView inherits Rectangle {
}
}
Rectangle { height: 10px; }
Text {
// Meta line. With a navigable label, render it inline as a
// clickable link (Tauri label-link: meta color, accent on
// hover) between the pre (year) and post (genre • tracks •
// duration) segments. Otherwise the plain full line.
if AlbumState.label-id != "" && AlbumState.label != "": HorizontalLayout {
alignment: start;
spacing: 0px;
Text {
text: AlbumState.meta-pre != "" ? AlbumState.meta-pre + " • " : "";
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
label-area := TouchArea {
width: label-text.preferred-width;
height: label-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.media-action("label", AlbumState.label-id, "open");
}
label-text := Text {
text: AlbumState.label;
color: label-area.has-hover ? Theme.accent : Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
Text {
text: AlbumState.meta-post != "" ? " • " + AlbumState.meta-post : "";
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
if AlbumState.label-id == "" || AlbumState.label == "": Text {
text: AlbumState.info-line;
color: Theme.text-secondary;
font-size: Typography.body;
@@ -280,13 +349,13 @@ export component AlbumPageView inherits Rectangle {
// (not the parent layout) to avoid a binding loop:
// page padding 32+32, artwork 224, header gap 32.
width: root.width - 320px;
text: AlbumState.description-short;
text: root.desc-display;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
if AlbumState.description != root.desc-display: Rectangle { height: 4px; }
if AlbumState.description != root.desc-display: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
@@ -310,7 +379,6 @@ export component AlbumPageView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
// Spin only while THIS album's first track is fetching.
loading: NowPlayingState.loading
&& NowPlayingState.album-id == AlbumState.id;
@@ -341,18 +409,17 @@ export component AlbumPageView inherits Rectangle {
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
// Live library state: filled accent heart when
// favorited, outline otherwise; spinner while a
// toggle is in flight. "favorite-toggle" adds or
// removes (the grid hearts stay add-only).
icon: AlbumState.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
active: AlbumState.is-favorite;
loading: AlbumState.favorite-loading;
clicked => {
root.media-action("album", AlbumState.id, "favorite");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cloud-download.svg");
clicked => {
root.media-action("album", AlbumState.id, "cache");
root.media-action("album", AlbumState.id, "favorite-toggle");
}
}
}
@@ -365,16 +432,7 @@ export component AlbumPageView inherits Rectangle {
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/user.svg");
clicked => {
root.open-artist(AlbumState.artist-id);
}
}
}
VerticalLayout {
if AlbumState.has-booklet: VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/book-open.svg"); }
}
@@ -387,13 +445,36 @@ export component AlbumPageView inherits Rectangle {
}
}
}
// Album info (i) — icon only for now; the detail panel
// logic lands later.
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/square-check-big.svg"); }
CircleAction { icon: @image-url("../assets/icons/info.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); }
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
album-menu.show();
}
}
// Opens below the ⋯, right edges aligned — the
// button sits at the right end of the action row
// so the menu drops leftward (negative x).
album-menu := AlbumContextMenu {
x: 32px - self.width;
y: 38px;
album-id: AlbumState.id;
fully-cached: AlbumState.album-fully-cached;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
}
}
@@ -410,7 +491,7 @@ export component AlbumPageView inherits Rectangle {
// No `alignment: start` here: it would defeat the track list's
// horizontal-stretch and leave the sidebar floating mid-row
// with dead space to its right. The track list stretches and
// the 254px sidebar is pushed flush to the right edge; when a
// the 214px sidebar is pushed flush to the right edge; when a
// main right sidebar opens, the whole content region narrows
// and the sidebar rides along with it.
HorizontalLayout {
@@ -454,7 +535,9 @@ export component AlbumPageView inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: 280px;
// 60% of the previous 280px so the toolbar gives
// room to the square select button beside it.
width: 168px;
height: 34px;
border-radius: 6px;
border-width: 1px;
@@ -502,8 +585,63 @@ export component AlbumPageView inherits Rectangle {
}
}
}
// Multi-select toggle — square (matches the search box,
// not the round CircleActions), slightly smaller. Active
// (accent) while select mode is on; toggles it.
VerticalLayout {
alignment: center;
Rectangle {
width: 30px;
height: 30px;
border-radius: 6px;
border-width: 1px;
border-color: AlbumState.multi-select
? Theme.accent
: Theme.border-subtle;
background: (AlbumState.multi-select || select-ta.has-hover)
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/square-check-big.svg");
width: 15px;
height: 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: AlbumState.multi-select
? Theme.accent
: (select-ta.has-hover
? Theme.text-primary
: Theme.text-secondary);
}
select-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
AlbumActions.toggle-multi-select();
}
}
}
}
}
// Bulk action bar — visible in multi-select mode, over the
// selected catalog rows.
if AlbumState.multi-select: MultiSelectBar {
selected-count: AlbumState.selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-favorites", label: @tr("Add to favorites"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
AlbumActions.bulk-action(id);
}
}
if AlbumState.multi-select: Rectangle { height: 8px; }
// Column header.
HorizontalLayout {
height: 40px;
@@ -519,7 +657,8 @@ export component AlbumPageView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("TITLE");
// Capital-case to match PlaylistView's column headers.
text: @tr("Title");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -527,7 +666,8 @@ export component AlbumPageView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
// Capital-case to match PlaylistView's column headers.
text: @tr("Duration");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -536,7 +676,8 @@ export component AlbumPageView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("QUALITY");
// Capital-case to match PlaylistView's column headers.
text: @tr("Quality");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -585,24 +726,38 @@ export component AlbumPageView inherits Rectangle {
show-favorite: !AlbumState.is-local;
show-download: !AlbumState.is-local;
show-source: AlbumState.is-local;
multi-select-mode: AlbumState.multi-select;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
// Label / awards sidebar.
// Label / awards sidebar. Collapses to a slim ICON RAIL (56px)
// when the content area is space-constrained (window <1366 AND
// a right panel open), so the tracks — what matters in a music
// app — keep priority. In the rail, only the LABEL/AWARDS
// headings + the avatars show; names move to hover tooltips.
if AlbumState.label != "" || AlbumState.awards.length > 0: VerticalLayout {
width: 254px;
// 160px (was 214px): the LABEL/AWARDS column was too wide
// and the track TITLE — the most important column — paid
// for it. Narrower here returns that width to the list.
width: ShellState.content-constrained ? 56px : 160px;
spacing: 24px;
alignment: start;
if AlbumState.label != "": VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("LABEL"); }
SidebarHeading {
text: @tr("LABEL");
// Centered over the avatar when collapsed to the
// icon rail; left-aligned in the full column.
horizontal-alignment: ShellState.content-constrained ? center : left;
}
SidebarCard {
icon: @image-url("../assets/icons/disc.svg");
name: AlbumState.label;
compact: ShellState.content-constrained;
avatar-bg: @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
clicked => {
if (AlbumState.label-id != "") {
@@ -614,10 +769,14 @@ export component AlbumPageView inherits Rectangle {
if AlbumState.awards.length > 0: VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("AWARDS"); }
SidebarHeading {
text: @tr("AWARDS");
horizontal-alignment: ShellState.content-constrained ? center : left;
}
for award in AlbumState.awards: SidebarCard {
icon: @image-url("../assets/laurels.svg");
name: award;
compact: ShellState.content-constrained;
avatar-bg: @linear-gradient(135deg, #b45309 0%, #eab308 100%);
}
}
@@ -270,7 +270,6 @@ export component LocalAlbumView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
loading: NowPlayingState.loading
&& NowPlayingState.album-id == LocalAlbumState.id;
clicked => {
@@ -442,7 +441,8 @@ export component LocalAlbumView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("TITLE");
// Capital-case to match PlaylistView's column headers.
text: @tr("Title");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -450,7 +450,8 @@ export component LocalAlbumView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
// Capital-case to match PlaylistView's column headers.
text: @tr("Duration");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
@@ -459,7 +460,8 @@ export component LocalAlbumView inherits Rectangle {
vertical-alignment: center;
}
Text {
text: @tr("QUALITY");
// Capital-case to match PlaylistView's column headers.
text: @tr("Quality");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
+134 -82
View File
@@ -18,6 +18,7 @@ import {
NetworkSidebarState,
NetworkSidebarActions,
DragState,
ShellState,
} from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
@@ -27,6 +28,7 @@ import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ReleaseGrid } from "ReleaseGrid.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint";
@@ -109,10 +111,32 @@ export component ArtistPageView inherits Rectangle {
// Popular Tracks shows the first 5; Load more expands to all.
property <bool> top-tracks-expanded: false;
property <int> top-tracks-preview: 5;
// Popular Tracks multi-select mode — toggled by the section header's
// Select button. When true, each row reveals a leading checkbox column
// (TrackRow's `multi-select-mode` prop). Mirrors Tauri's flow.
property <bool> multi-select-mode: false;
// Popular Tracks multi-select now lives in ArtistState
// (top-tracks-multi-select / top-tracks-selected-count), so it resets on
// artist change and drives the shared MultiSelectBar.
// Responsiveness: the 300px Artist Network sidebar AUTO-collapses when the
// content area is space-constrained — a right panel (Queue / Lyrics) is
// open on a non-wide window — so the Popular Tracks list keeps priority
// (tracks win — it's a music app), and AUTO-opens when there's room again.
// The user can still toggle it manually (the button below); that override
// sticks until the next constrain-edge or artist change.
//
// Uses ShellState.content-constrained (the SAME signal AlbumView uses),
// NOT a raw `root.width < N`. The old `root.width < 1080px` trigger WAS
// the regression: at a normal window the content area is already < 1080px
// WITHOUT any panel, so `net-cramped` was permanently true — opening the
// Queue produced no transition, the sidebar never auto-closed, and (being
// a 300px overlay) it covered the Popular Tracks action cluster instead of
// yielding to it.
property <bool> net-cramped: ShellState.content-constrained;
property <string> net-nav-watch: ArtistState.id;
changed net-cramped => {
NetworkSidebarState.open = !root.net-cramped;
}
changed net-nav-watch => {
NetworkSidebarState.open = !root.net-cramped;
}
background: Theme.surface-main;
@@ -337,8 +361,8 @@ export component ArtistPageView inherits Rectangle {
// vs Qobuz radio (qobuz-curated). Both call
// distinct media-actions; main.rs wires them.
Rectangle {
width: 40px;
height: 40px;
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
clicked => {
@@ -347,7 +371,7 @@ export component ArtistPageView inherits Rectangle {
}
radio-popup := PopupWindow {
x: 0px;
y: 50px;
y: 38px;
width: 180px;
close-policy: close-on-click;
ContextMenu {
@@ -443,79 +467,105 @@ export component ArtistPageView inherits Rectangle {
HorizontalLayout {
horizontal-stretch: 0;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 40px;
clicked => {
root.media-action(
"artist", ArtistState.id, "play-top");
// Wrapped in VerticalLayout (alignment:center) so the round
// buttons keep their 40px size — an unwrapped CircleAction
// in this row was stretching tall ("giant button").
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
clicked => {
root.media-action(
"artist", ArtistState.id, "play-top");
}
}
}
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
clicked => {
root.multi-select-mode = !root.multi-select-mode;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: ArtistState.top-tracks-multi-select;
clicked => {
ArtistActions.toggle-top-tracks-select();
}
}
}
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
root.media-action("artist", "", "top-tracks-menu");
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
top-tracks-menu.show();
}
}
// Popular Tracks section menu (all-tracks actions),
// opens below the ⋯ with right edges aligned.
top-tracks-menu := PopupWindow {
x: 32px - self.width;
y: 38px;
width: 224px;
height: 4 * 33px + 10px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play all next");
clicked => {
ArtistActions.top-tracks-menu-action("next-all");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add all to queue");
clicked => {
ArtistActions.top-tracks-menu-action("queue-all");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/shuffle.svg");
label: @tr("Shuffle all");
clicked => {
ArtistActions.top-tracks-menu-action("shuffle-all");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add all to playlist");
clicked => {
ArtistActions.top-tracks-menu-action("playlist-all");
}
}
}
}
}
}
}
}
if ArtistState.top-tracks.length > 0: Rectangle { height: 10px; }
// Check-all row — shown above the list whenever the
// section is in multi-select mode. Matches the QBZ
// pattern: leading checkbox on the left of the row, just
// like the per-row selectors below.
if root.multi-select-mode
&& ArtistState.top-tracks.length > 0:
HorizontalLayout {
height: 36px;
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
check-all-box := Rectangle {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 22px;
height: 22px;
border-radius: 11px;
background: transparent;
border-width: 1.5px;
border-color: check-all-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
check-all-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action(
"artist", "", "select-all-top-tracks");
}
}
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Select all");
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
}
// Bulk action bar — homologated icon bar, shown in multi-select
// mode over the selected Popular Tracks.
if ArtistState.top-tracks-multi-select
&& ArtistState.top-tracks.length > 0: MultiSelectBar {
selected-count: ArtistState.top-tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-favorites", label: @tr("Add to favorites"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
ArtistActions.top-tracks-bulk-action(id);
}
}
if ArtistState.top-tracks-multi-select
&& ArtistState.top-tracks.length > 0: Rectangle { height: 10px; }
for track[index] in ArtistState.top-tracks: TrackRow {
visible: root.top-tracks-expanded || index < root.top-tracks-preview;
@@ -525,7 +575,7 @@ export component ArtistPageView inherits Rectangle {
// Artist Popular Tracks: number cell (no cover), with
// the multi-select checkbox.
show-artwork: false;
multi-select-mode: root.multi-select-mode;
multi-select-mode: ArtistState.top-tracks-multi-select;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
@@ -587,19 +637,6 @@ export component ArtistPageView inherits Rectangle {
} // close page VerticalLayout
} // close Flickable
// Shared scrollbar in the right gutter (homologated cross-view).
// Hidden while the network sidebar is open — that panel pins to the
// same right edge and carries its own scroll, so the page scrollbar
// there would overlap it.
if !NetworkSidebarState.open: ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// --- Sticky JUMP TO bar -------------------------------------------------
// Overlay anchored to the top of ArtistPageView with the same
// sticky math as the sidebar: tracks the page scroll until the
@@ -647,6 +684,21 @@ export component ArtistPageView inherits Rectangle {
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
// Declared AFTER the sticky JUMP TO bar so it paints ON TOP of it —
// the full-width jump bar was overlapping the scrollbar. Still BELOW
// the modals/dialogs below (those must stay on top). Hidden while the
// network sidebar is open — that panel pins to the same right edge and
// carries its own scroll.
if !NetworkSidebarState.open: ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// Network sidebar — overlay anchored to the right edge of the
// ArtistPageView, with sticky-style y positioning so it tracks
// the page scroll while the header is visible and then pins
@@ -935,13 +935,13 @@ export component FavoritesView inherits Rectangle {
if FavoritesState.tracks-multi-select: MultiSelectBar {
selected-count: FavoritesState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from favorites"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
FavoritesActions.bulk-action(id);
+13 -11
View File
@@ -169,7 +169,6 @@ export component LabelPageView inherits Rectangle {
HorizontalLayout {
alignment: start;
CircleAction {
diameter: 44px;
active: LabelState.is-following;
icon: LabelState.is-following
? @image-url("../assets/icons/check.svg")
@@ -409,16 +408,6 @@ export component LabelPageView inherits Rectangle {
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// --- Sticky JUMP TO bar -------------------------------------------------
// Same sticky math as ArtistPageView: tracks the page scroll until the
// header scrolls away, then pins to y = 0. No inline search (Tauri's
@@ -438,6 +427,19 @@ export component LabelPageView inherits Rectangle {
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
// Declared AFTER the sticky JUMP TO bar so it paints ON TOP of it (the
// full-width jump bar was overlapping it), but BEFORE the modal below
// so the modal still stacks above the scrollbar.
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
}
// --- Full-description modal ---------------------------------------------
if root.show-desc: Rectangle {
background: #000000bf;
@@ -103,7 +103,8 @@ export component LabelReleasesView inherits Rectangle {
Text {
text: LabelState.name;
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section).
font-size: Typography.section;
font-weight: Typography.bold;
}
if LabelState.total > 0: Text {
@@ -150,7 +150,6 @@ component EphemeralPane inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
tooltip: @tr("Play all");
clicked => {
LocalLibraryActions.ephemeral-play-all();
@@ -161,7 +160,6 @@ component EphemeralPane inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/x.svg");
diameter: 40px;
tooltip: @tr("Clear session");
clicked => {
LocalLibraryActions.ephemeral-clear();
@@ -1326,12 +1324,12 @@ export component LocalLibraryView inherits Rectangle {
if LocalLibraryState.tracks-multi-select: MultiSelectBar {
selected-count: LocalLibraryState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
LocalLibraryActions.tracks-bulk-action(id);
@@ -1860,7 +1858,6 @@ export component LocalLibraryView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 40px;
tooltip: @tr("Play all");
clicked => {
LocalLibraryActions.folders-play-node(
@@ -136,7 +136,8 @@ export component ArtistsByLocationView inherits Rectangle {
? LocationViewState.scene-label
: @tr("Scene");
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section).
font-size: Typography.section;
font-weight: Typography.bold;
vertical-alignment: center;
}
-1
View File
@@ -160,7 +160,6 @@ export component MixView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: MixState.tracks.length > 0;
clicked => {
root.play-all();
@@ -147,7 +147,9 @@ export component MusicianPageView inherits Rectangle {
Text {
text: MusicianState.name;
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section), like
// the Album / Artist / Playlist / MyQBZ detail heroes.
font-size: Typography.section;
font-weight: Typography.bold;
}
Text {
@@ -23,7 +23,6 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { LineEdit } from "std-widgets.slint";
// A single labelled radio option in the create-panel Kind toggle.
component KindRadio inherits TouchArea {
@@ -308,11 +307,48 @@ export component AddToMixtapeModal inherits Rectangle {
tint: Theme.text-muted;
}
}
search-input := LineEdit {
placeholder-text: @tr("Search…");
text <=> MyQbzAddState.search;
edited(t) => {
MyQbzAddActions.search-changed(t);
// Plain Rectangle + TextInput recipe (see
// PlaylistPickerModal) — std-widgets LineEdit
// misrenders here.
search-box := Rectangle {
height: 43px;
horizontal-stretch: 1;
border-radius: Radius.sm;
background: Theme.surface-card;
border-width: 1px;
border-color: search-input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
Rectangle {
horizontal-stretch: 1;
clip: true;
search-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
single-line: true;
text <=> MyQbzAddState.search;
edited => {
MyQbzAddActions.search-changed(self.text);
}
}
Text {
// Placeholder — Slint TextInput has no
// placeholder property; a sibling Text
// gated on empty text.
visible: search-input.text == "";
width: 100%;
height: 100%;
text: @tr("Search…");
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
@@ -409,13 +445,44 @@ export component AddToMixtapeModal inherits Rectangle {
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
create-name-input := LineEdit {
placeholder-text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
text <=> MyQbzAddState.create-name;
enabled: !MyQbzAddState.create-busy;
accepted => {
if (root.can-create) {
MyQbzAddActions.create-and-add();
// Same Rectangle + TextInput recipe as the search box.
create-name-box := Rectangle {
height: 43px;
border-radius: Radius.sm;
background: Theme.surface-card;
border-width: 1px;
border-color: create-name-input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
Rectangle {
horizontal-stretch: 1;
clip: true;
create-name-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
single-line: true;
enabled: !MyQbzAddState.create-busy;
text <=> MyQbzAddState.create-name;
accepted => {
if (root.can-create) {
MyQbzAddActions.create-and-add();
}
}
}
Text {
visible: create-name-input.text == "";
width: 100%;
height: 100%;
text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
}
@@ -701,11 +701,14 @@ export component MixtapeDetailView inherits Rectangle {
font-weight: Typography.semibold;
letter-spacing: 1.2px;
}
// Title — canonical hero title size (Album/Playlist heroes).
// Title — canonical hero size, homologated to the Album /
// Artist / Playlist detail heroes (Typography.section, 18px).
// Was Typography.title (25px) despite this comment already
// claiming Album/Playlist parity — a homologation bug.
Text {
text: MyQbzDetailState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-size: Typography.section;
font-weight: Typography.bold;
wrap: word-wrap;
}
@@ -737,7 +740,6 @@ export component MixtapeDetailView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.play-all(); }
}
@@ -746,7 +748,6 @@ export component MixtapeDetailView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
diameter: 40px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.shuffle(); }
}
@@ -757,7 +758,6 @@ export component MixtapeDetailView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/turntable.svg");
diameter: 40px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.dj-mix(); }
}
@@ -766,16 +766,15 @@ export component MixtapeDetailView inherits Rectangle {
VerticalLayout {
alignment: center;
overflow-anchor := Rectangle {
width: 40px;
height: 40px;
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
diameter: 40px;
clicked => { overflow-menu.show(); }
}
overflow-menu := PopupWindow {
x: 0;
y: 44px;
y: 36px;
width: 220px;
// Explicit height (same fix as row-menu) sized
// to the MAX item set: 7 items (32px) + 2
@@ -1245,12 +1244,12 @@ export component MixtapeDetailView inherits Rectangle {
width: 100%;
selected-count: MyQbzDetailState.selected-count;
actions: [
{ id: "add-to-queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
{ id: "add-to-queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
MyQbzDetailActions.bulk-action(id);
@@ -13,7 +13,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { OfflineManagerState, OfflineManagerActions, OfflineRow, OfflineArtist } from "../state.slint";
import { NavState } from "../state.slint";
import { NavState, TooltipState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
@@ -110,30 +110,48 @@ component ArtistRailRow inherits Rectangle {
}
}
// A small labelled ghost button (toolbar / bulk actions).
component TxtBtn inherits Rectangle {
in property <string> label;
// Bulk-action icon button — homologated with MultiSelectBar's BulkButton
// (icon + hover tooltip, borderless normal, red-bordered danger) so the
// offline-cache track rows share the app-wide bulk-bar style. Named
// distinctly from the existing `IconBtn` (a tinted toolbar glyph button).
component BulkIconBtn inherits Rectangle {
in property <image> icon;
in property <string> tooltip;
in property <bool> danger: false;
callback clicked;
width: lbl.preferred-width + 24px;
height: 32px;
width: 34px;
height: 30px;
border-radius: Radius.sm;
border-width: 1px;
border-color: root.danger ? #e0564f : Theme.border-subtle;
background: ta.has-hover ? Theme.surface-elevated : transparent;
lbl := Text {
x: 12px;
text: root.label;
color: root.danger ? #e0564f : Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
height: 100%;
background: root.danger
? (ta.has-hover ? #ef444433 : transparent)
: (ta.has-hover ? Theme.surface-hover : transparent);
border-width: root.danger ? 1px : 0px;
border-color: #ef4444;
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.danger
? #ef4444
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
@@ -154,7 +172,8 @@ export component OfflineManagerView inherits Rectangle {
Text {
text: "Offline Cache";
color: Theme.text-primary;
font-size: Typography.title;
// Hero homologated to the canonical 18px (section).
font-size: Typography.section;
font-weight: Typography.bold;
}
}
@@ -314,14 +333,16 @@ export component OfflineManagerView inherits Rectangle {
font-size: Typography.legal;
}
}
TxtBtn {
label: @tr("Re-download");
BulkIconBtn {
icon: @image-url("../assets/icons/refresh-cw.svg");
tooltip: @tr("Re-download");
clicked => {
OfflineManagerActions.bulk-redownload();
}
}
TxtBtn {
label: @tr("Remove");
BulkIconBtn {
icon: @image-url("../assets/icons/trash-2.svg");
tooltip: @tr("Remove");
danger: true;
clicked => {
OfflineManagerActions.bulk-remove();
@@ -329,8 +350,11 @@ export component OfflineManagerView inherits Rectangle {
}
}
// Select all / clear toggle.
TxtBtn {
label: OfflineManagerState.selected-count > 0 ? @tr("Clear") : @tr("Select all");
BulkIconBtn {
icon: OfflineManagerState.selected-count > 0
? @image-url("../assets/icons/x.svg")
: @image-url("../assets/icons/square-check-big.svg");
tooltip: OfflineManagerState.selected-count > 0 ? @tr("Clear") : @tr("Select all");
clicked => {
if (OfflineManagerState.selected-count > 0) {
OfflineManagerActions.clear-selection();
+12 -11
View File
@@ -77,17 +77,17 @@ export component PlaylistView inherits Rectangle {
// Play next / Add to queue / Add to playlist over the selection,
// Remove owner-only — followed playlists keep the queueing actions.
property <[BulkAction]> bulk-actions-owner: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
];
property <[BulkAction]> bulk-actions-follower: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
];
// Label for the active sort field.
@@ -324,7 +324,6 @@ export component PlaylistView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.play-all();
@@ -716,7 +715,9 @@ export component PlaylistView inherits Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: parent.width - self.width - 24px;
// Clear the right-gutter scrollbar (14px + 4px margin) so the FAB
// never sits over it.
x: parent.width - self.width - 40px;
y: parent.height - self.height - 24px;
background: btt-ta.has-hover ? Theme.accent : Theme.surface-elevated;
border-width: 1px;
@@ -0,0 +1,102 @@
// Album header "more" (⋯) context menu — ported 1:1 from the Tauri
// `AlbumMenu` (src/lib/components/AlbumMenu.svelte) as wired by the album
// detail header. Tauri nests Share/Download as submenus; we render them
// FLAT (the same cosmetic-flat convention as TrackContextMenu) with group
// separators matching Tauri's menu grouping.
//
// Item -> media-action("album", <album-id>, <action>) mapping:
// Play next -> "play-next"
// Add to queue -> "queue"
// --
// Add to playlist -> "add-to-playlist"
// Add to Mixtape/Coll. -> "add-to-mixtape"
// --
// Share Qobuz link -> "share-qobuz"
// Share Album.link -> "share-songlink"
// --
// Make available offline -> "cache" (album NOT fully cached)
// Refresh offline copy -> "recache" (album fully cached)
//
// Tauri's onMakeOffline ("download.makeAvailable") item is NOT passed by
// the album detail header, so it is intentionally absent (1:1). The last
// row swaps Make-available-offline <-> Refresh-offline-copy on
// `fully-cached`, the flat form of Tauri's isAlbumFullyDownloaded submenu
// (which only carries Refresh — no Remove at album level).
import { Theme } from "../foundation/semantic-colors.slint";
import { ContextMenu } from "ContextMenu.slint";
import { ContextMenuItem } from "ContextMenuItem.slint";
export component AlbumContextMenu inherits PopupWindow {
in property <string> album-id;
// Every track of the album already has a ready offline copy.
in property <bool> fully-cached: false;
callback media-action(string, string, string);
width: 224px;
// 7 rows x 33px + 3 separators x 1px + ContextMenu's 5/5 padding. The
// offline row swaps label/action on `fully-cached` — count is constant.
height: 7 * 33px + 3 * 1px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("album", root.album-id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("album", root.album-id, "queue");
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("album", root.album-id, "add-to-playlist");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("album", root.album-id, "add-to-mixtape");
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share Qobuz link");
clicked => {
root.media-action("album", root.album-id, "share-qobuz");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share Album.link");
clicked => {
root.media-action("album", root.album-id, "share-songlink");
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
if !root.fully-cached: ContextMenuItem {
icon: @image-url("../assets/icons/cloud-download.svg");
label: @tr("Make available offline");
clicked => {
root.media-action("album", root.album-id, "cache");
}
}
if root.fully-cached: ContextMenuItem {
icon: @image-url("../assets/icons/refresh-cw.svg");
label: @tr("Refresh offline copy");
clicked => {
root.media-action("album", root.album-id, "recache");
}
}
}
}
@@ -17,12 +17,16 @@ export component IconButton inherits Rectangle {
// Footprint width; the right-cluster buttons are narrower (28) than the
// transport (32) so they pack tighter.
in property <length> btn-width: 32px;
// Disabled (e.g. transport with nothing playing) — dimmed + non-clickable,
// 1:1 with Tauri's `.disabled` controls (opacity 0.3).
in property <bool> enabled: true;
callback clicked();
width: root.btn-width;
height: 32px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
opacity: root.enabled ? 1.0 : 0.3;
background: (ta.has-hover && root.enabled) ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
@@ -37,12 +41,14 @@ export component IconButton inherits Rectangle {
}
ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
root.clicked();
if (root.enabled) {
root.clicked();
}
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
if (self.has-hover && root.enabled && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
@@ -64,14 +70,17 @@ export component PlayButton inherits Rectangle {
// While a play is resolving/downloading (no audio yet) the glyph is
// swapped for a spinner so the click reads as "heard, audio is coming".
in property <bool> loading: false;
// Disabled when there is nothing to play (no current track).
in property <bool> enabled: true;
callback clicked();
width: root.circle ? 38px : 34px;
height: root.circle ? 38px : 34px;
border-radius: root.circle ? 19px : Radius.sm;
opacity: root.enabled ? 1.0 : 0.3;
background: root.circle
? (ta.has-hover ? #ffffffd6 : #ffffff)
: (ta.has-hover ? Theme.surface-hover : transparent);
? ((ta.has-hover && root.enabled) ? #ffffffd6 : #ffffff)
: ((ta.has-hover && root.enabled) ? Theme.surface-hover : transparent);
if !root.loading: QbzIcon {
source: root.icon;
@@ -94,12 +103,14 @@ export component PlayButton inherits Rectangle {
}
ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
root.clicked();
if (root.enabled) {
root.clicked();
}
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
if (self.has-hover && root.enabled && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
@@ -16,7 +16,12 @@ export component CircleAction inherits Rectangle {
in property <bool> active: false;
// While the album's first track is fetching, the icon swaps for a spinner.
in property <bool> loading: false;
in property <length> diameter: 40px;
// Homologated header sizing — the size hierarchy lives HERE, not in each
// caller: the primary (Play) button is prominent (44px), every secondary
// action is 80% of the old 40px default (32px). Callers no longer set
// `diameter` for header buttons; an explicit override is only for special
// contexts (e.g. a small card-overlay play).
in property <length> diameter: root.primary ? 44px : 32px;
// When false the button dims and the click is gated (e.g. an empty
// collection's hero CTAs). Additive — existing callers default to enabled.
in property <bool> enabled: true;
@@ -38,8 +43,10 @@ export component CircleAction inherits Rectangle {
if !root.loading: QbzIcon {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
// Glyph scales with the tier so the smaller secondary buttons don't
// look icon-heavy (primary keeps the larger 19px glyph).
width: root.primary ? 19px : 15px;
height: root.primary ? 19px : 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
@@ -8,34 +8,38 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { BulkAction } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { BulkAction, TooltipState } from "../state.slint";
// One action button — borderless hover for a normal action, red-bordered
// for a danger action, dimmed + non-interactive when it needs a selection
// and there is none.
// One action button — an ICON with a hover tooltip (the action's label).
// Borderless hover for a normal action, red-bordered for a danger action,
// dimmed + non-interactive when it needs a selection and there is none.
component BulkButton inherits Rectangle {
in property <string> label;
in property <image> icon;
in property <string> tooltip;
in property <bool> danger: false;
in property <bool> enabled-when: true;
callback clicked();
width: lbl.preferred-width + 24px;
width: 34px;
height: 30px;
border-radius: Radius.sm;
opacity: root.enabled-when ? 1.0 : 0.5;
opacity: root.enabled-when ? 1.0 : 0.4;
background: root.danger
? (ta.has-hover ? #ef444433 : transparent)
: (ta.has-hover ? Theme.surface-hover : transparent);
border-width: root.danger ? 1px : 0px;
border-color: #ef4444;
lbl := Text {
text: root.label;
color: root.danger ? #ef4444 : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.danger ? Typography.medium : Typography.regular;
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.danger
? #ef4444
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
@@ -45,6 +49,18 @@ component BulkButton inherits Rectangle {
root.clicked();
}
}
// Shared tooltip bubble (same channel as CircleAction), so each
// icon explains itself on hover.
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
@@ -60,7 +76,7 @@ export component MultiSelectBar inherits Rectangle {
HorizontalLayout {
padding-left: 14px;
padding-right: 10px;
spacing: 12px;
spacing: 6px;
Text {
text: root.selected-count + " " + @tr("selected");
color: Theme.text-secondary;
@@ -71,7 +87,8 @@ export component MultiSelectBar inherits Rectangle {
for act in root.actions: VerticalLayout {
alignment: center;
BulkButton {
label: act.label;
icon: act.icon;
tooltip: act.label;
danger: act.danger;
enabled-when: !act.needs-selection || root.selected-count > 0;
clicked => {
+51 -21
View File
@@ -253,7 +253,9 @@ export component TrackRow inherits Rectangle {
// Wide enough for 4-digit positions (big playlists).
width: 32px;
color: Theme.text-muted;
font-size: Typography.body;
// Secondary column — must read SMALLER than the track title
// (Typography.link, 14px), not bigger. Was Typography.body (15px).
font-size: Typography.legal;
horizontal-alignment: center;
overflow: elide;
}
@@ -342,10 +344,25 @@ export component TrackRow inherits Rectangle {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
HorizontalLayout {
alignment: start;
spacing: 6px;
Text {
// Title line — femtovg measures some scripts' runs at ~0 (Korean
// at Typography.link; Japanese measures fine), so NOTHING about
// this line may be content-measured or the line collapses and the
// glyphs have nowhere to paint. The first failed fix assigned the
// WIDTH (stretch Rectangle) but missed that `alignment: center`
// on this column sizes each line to its preferred HEIGHT — the
// Korean line still collapsed to ~0 tall and its `clip: true`
// erased the glyphs. SongCard parity (the one surface where
// Korean renders): a plain Rectangle direct in the column, NO
// clip, Text width assigned — plus a FIXED height here, so both
// axes are layout-assigned and measurement is never trusted.
title-line := Rectangle {
height: 20px;
title-text := Text {
x: 0;
// Reserve the badge slot (16px + 6px gap) when explicit
// so the elide stops before the badge, never under it.
width: parent.width - (root.track.explicit ? 22px : 0px);
height: 100%;
text: root.track.title;
color: Theme.text-primary;
font-size: Typography.link;
@@ -353,21 +370,31 @@ export component TrackRow inherits Rectangle {
vertical-alignment: center;
overflow: elide;
}
if root.track.explicit: VerticalLayout {
alignment: center;
Rectangle {
width: 16px;
height: 16px;
border-radius: 3px;
background: Theme.surface-elevated;
Text {
text: "E";
color: Theme.text-muted;
font-size: 9px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
// Explicit "E" badge — hugs the END of the title text (Tauri's
// .track-title-row: flex, gap 6px, badge flex-shrink:0), not
// the column's far right. preferred-width is only used to
// POSITION the badge (the title's own width stays assigned,
// so a bogus ~0 measurement can't blank it); when the run
// measures ~0 with a non-empty title (the femtovg Korean
// case), hugging is impossible, so the badge parks in the
// right-edge slot the title width already reserves.
if root.track.explicit: Rectangle {
x: (root.track.title != "" && title-text.preferred-width < 1px)
? title-text.width + 6px
: Math.min(title-text.preferred-width + 6px,
title-text.width + 6px);
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
width: 16px;
height: 16px;
border-radius: 3px;
background: Theme.surface-elevated;
Text {
text: "E";
color: Theme.text-muted;
font-size: 9px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
@@ -433,7 +460,10 @@ export component TrackRow inherits Rectangle {
text: root.track.duration;
width: 70px;
color: Theme.text-muted;
font-size: Typography.body;
// Secondary metadata — must read SMALLER than the track title
// (Typography.link, 14px), not bigger. Was Typography.body (15px),
// which made the duration larger than the title — illogical.
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
}
@@ -470,8 +470,14 @@ export component SearchResultsView inherits Rectangle {
if SearchState.tab == 0 && SearchState.most-popular-kind != "": HorizontalLayout {
spacing: 24px;
// Most-popular hero column.
// Most-popular hero column. Fixed at 200px (= the widest
// hero, the album card) so the Artists carousel always
// starts at the same x regardless of the hero kind — the
// 160px artist/track cards used to pull the carousel ~40px
// left versus an album hero. The card is centered in the
// slot.
VerticalLayout {
width: 200px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
@@ -492,20 +498,23 @@ export component SearchResultsView inherits Rectangle {
vertical-alignment: center;
}
}
if SearchState.most-popular-kind == "artist": ArtistCard {
artist: SearchState.most-popular-artist;
clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.most-popular-kind == "album": AlbumCard {
album: SearchState.most-popular-album;
clicked(id) => { root.open-album(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.most-popular-kind == "track": SearchTrackHero {
track: SearchState.most-popular-track;
quality-label: SearchState.most-popular-quality-label;
media-action(k, i, a) => { root.media-action(k, i, a); }
HorizontalLayout {
alignment: center;
if SearchState.most-popular-kind == "artist": ArtistCard {
artist: SearchState.most-popular-artist;
clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.most-popular-kind == "album": AlbumCard {
album: SearchState.most-popular-album;
clicked(id) => { root.open-album(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.most-popular-kind == "track": SearchTrackHero {
track: SearchState.most-popular-track;
quality-label: SearchState.most-popular-quality-label;
media-action(k, i, a) => { root.media-action(k, i, a); }
}
}
}
+32 -15
View File
@@ -138,22 +138,46 @@ component LyricsLineRow inherits VerticalLayout {
if (root.dimming-mode == 1) {
return 0.6;
}
// strong (default) — ASYMMETRIC: incoming (future) lines fade much
// harder than already-sung ones, nearly dissolving into the
// background and brightening as they approach the active line, for a
// stronger timing cue. Past lines keep a gentler fall-off.
if (root.is-past) {
if (root.distance == 1) {
return 0.5;
}
if (root.distance == 2) {
return 0.35;
}
if (root.distance == 3) {
return 0.25;
}
return 0.18;
}
if (root.distance == 1) {
return 0.5;
return 0.4;
}
if (root.distance == 2) {
return 0.35;
return 0.2;
}
if (root.distance == 3) {
return 0.25;
return 0.08;
}
return 0.15;
return 0.03;
}
base := Text {
text: root.display-text;
wrap: word-wrap;
opacity: root.line-opacity();
// 1:1 with Tauri's `transition: opacity 220ms ease-out`
// (LyricsLines.svelte:613-614): upcoming lines fade IN as they brighten
// toward active, passed lines fade OUT to their dim tier — instead of
// the opacity snapping between dimming steps.
animate opacity {
duration: 220ms;
easing: ease-out;
}
// Active base = the DIM (unsung) layer under the karaoke overlay
// (Tauri gradient parity: unsung stays dim, sung sweeps in accent).
color: root.is-active
@@ -324,17 +348,10 @@ export component LyricsLinesView inherits Rectangle {
}
if LyricsState.synced: Rectangle { height: root.spacer-height; }
// Fallback-provider attribution (LRCLIB / lyrics.ovh). Tauri keeps
// these in the About modal (AboutModal.svelte:141,144); Slint has
// no About surface yet, so the line lives at the end of the scroll.
// Qobuz is first-party: provider-label is empty, nothing renders.
if root.show-attribution && LyricsState.provider-label != "": Text {
text: @tr("Lyrics provided by {}", LyricsState.provider-label);
color: Theme.text-muted;
font-size: 10px;
horizontal-alignment: center;
}
// Provider attribution moved to a fixed footer in LyricsSidebar
// ("Lyrics source: <provider>"), shown for ALL providers incl.
// Qobuz — so the scrolling attribution that used to live here is
// gone (it only covered the LRCLIB/ovh fallbacks).
}
}
}
+59 -1
View File
@@ -23,7 +23,7 @@ import { IconButton } from "../primitives/BarControls.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { LyricsLinesView } from "LyricsLinesView.slint";
import { LyricsControlsPanel } from "LyricsControlsFlyout.slint";
import { ShellState, LyricsState } from "../state.slint";
import { ShellState, LyricsState, ArtworkActions } from "../state.slint";
component CloseButton inherits Rectangle {
callback clicked();
@@ -198,6 +198,7 @@ export component LyricsSidebar inherits Rectangle {
|| (LyricsState.status == 2 && LyricsState.lines.length == 0): VerticalLayout {
alignment: center;
padding: Spacing.md;
spacing: 12px;
Text {
text: @tr("No lyrics available");
color: Theme.text-muted;
@@ -205,6 +206,36 @@ export component LyricsSidebar inherits Rectangle {
horizontal-alignment: center;
wrap: word-wrap;
}
// Crowd-source nudge — LRCLIB is community-contributed, so a
// miss is an invitation to add them. Small, with a clickable
// link to the LRCLIB submission tool.
VerticalLayout {
spacing: 2px;
Text {
text: @tr("Missing lyrics? You can contribute them to LRCLIB:");
color: Theme.text-muted;
font-size: 10px;
horizontal-alignment: center;
wrap: word-wrap;
}
HorizontalLayout {
alignment: center;
link-ta := TouchArea {
width: link-text.preferred-width;
height: link-text.preferred-height;
mouse-cursor: pointer;
clicked => {
ArtworkActions.open-in-browser("https://lrclibup.boidu.dev/");
}
link-text := Text {
text: "lrclibup.boidu.dev";
color: link-ta.has-hover ? Theme.accent-hover : Theme.accent;
font-size: 10px;
horizontal-alignment: center;
}
}
}
}
}
// Offline with nothing cached — the typed NotAvailableOffline
@@ -265,5 +296,32 @@ export component LyricsSidebar inherits Rectangle {
: Theme.accent;
}
}
// Source attribution — a tiny line pinned at the very bottom, always
// shown once lyrics are present (incl. Qobuz). "Lyrics source:" in
// muted + the provider name in accent. Smallest legible size.
if LyricsState.status == 2 && LyricsState.lines.length > 0: HorizontalLayout {
alignment: start;
padding-left: Spacing.sm;
padding-right: Spacing.sm;
padding-top: 3px;
padding-bottom: 5px;
spacing: 3px;
Text {
text: @tr("Lyrics source") + ":";
color: Theme.text-muted;
font-size: 9px;
vertical-alignment: center;
}
Text {
text: LyricsState.provider == "lrclib" ? "LRCLIB"
: LyricsState.provider == "ovh" ? "lyrics.ovh"
: "Qobuz";
color: Theme.accent;
font-size: 9px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
}
}
@@ -37,6 +37,7 @@ export component TransportControls inherits HorizontalLayout {
IconButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
enabled: NowPlayingState.has-track;
clicked => {
if (NowPlayingState.has-track) {
root.media-action("track", NowPlayingState.track-id, "track-info");
@@ -51,6 +52,7 @@ export component TransportControls inherits HorizontalLayout {
icon-size: 16px;
tooltip: @tr("Shuffle");
active: NowPlayingState.shuffle;
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.toggle-shuffle();
}
@@ -61,6 +63,7 @@ export component TransportControls inherits HorizontalLayout {
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
tooltip: @tr("Previous");
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.previous();
}
@@ -75,6 +78,7 @@ export component TransportControls inherits HorizontalLayout {
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play");
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.toggle-play();
}
@@ -85,6 +89,7 @@ export component TransportControls inherits HorizontalLayout {
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
tooltip: @tr("Next");
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.next();
}
@@ -99,6 +104,7 @@ export component TransportControls inherits HorizontalLayout {
icon-size: 16px;
tooltip: @tr("Repeat");
active: NowPlayingState.repeat-mode != 0;
enabled: NowPlayingState.has-track;
clicked => {
NowPlayingState.cycle-repeat();
}
@@ -115,6 +121,7 @@ export component TransportControls inherits HorizontalLayout {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to…");
enabled: NowPlayingState.has-track;
clicked => {
add-menu.show();
}
@@ -181,6 +188,7 @@ export component TransportControls inherits HorizontalLayout {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to playlist");
enabled: NowPlayingState.has-track;
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
@@ -193,7 +201,8 @@ export component TransportControls inherits HorizontalLayout {
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: fav-ta.has-hover ? Theme.surface-hover : transparent;
opacity: NowPlayingState.has-track ? 1.0 : 0.3;
background: (fav-ta.has-hover && NowPlayingState.has-track) ? Theme.surface-hover : transparent;
QbzIcon {
source: QueueState.now-playing-favorite
? @image-url("../assets/icons/heart-filled.svg")
@@ -207,12 +216,14 @@ export component TransportControls inherits HorizontalLayout {
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
fav-ta := TouchArea {
mouse-cursor: pointer;
mouse-cursor: NowPlayingState.has-track ? pointer : default;
clicked => {
QueueState.toggle-now-playing-favorite();
if (NowPlayingState.has-track) {
QueueState.toggle-now-playing-favorite();
}
}
changed has-hover => {
if (self.has-hover) {
if (self.has-hover && NowPlayingState.has-track) {
TooltipState.text = QueueState.now-playing-favorite
? @tr("Remove from library")
: @tr("Add to library");