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:
32 files changed
+1512
-406
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user