feat(slint): rebuild mix views to AlbumView-level rendering
The mix detail pages were a thin header + flat track list. Rebuilt to
mirror Tauri's playlist-detail layout at the AlbumView port quality.
- Header: a per-mix gradient artwork (224px, same gradients as the
QobuzMixesRow tiles) + "Qobuz Mixes" label + title + description +
"N tracks • duration" info line + circular action buttons (Play
all, Shuffle, Save-as-playlist, Refresh) — matching AlbumView's
header anatomy.
- Track list with a real column header (#, Title, Album, Duration,
Quality) over rich MixTrackRows: artwork thumbnail + number/play
cell (TrackPlayCell, artwork-on), title + explicit badge, artist,
album column, duration, quality, favorite + more controls.
- MixTrackItem gains an album column; MixState carries track-count +
total-duration for the info line.
- Play all / per-track play (from that track), Shuffle (plays a
random order without mutating the list), and Refresh (re-fetches)
wired through media-action.
Fixes a wiring bug: the tile click sends id=kind/action="open", but
the handler matched on the action and passed "open" as the mix kind —
now it reads the kind from id.
cc
This commit is contained in:
5 files changed
+423
-63
No files matched your search
@@ -1311,6 +1311,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The mix tile sends id = mix kind, action = "open".
|
||||||
|
("mix", "open") => {
|
||||||
|
nav::record(nav::NavEntry::Mix { kind: id.clone() });
|
||||||
|
navigate_mix(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
&handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
id.clone(),
|
||||||
|
);
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
update_nav_flags(&w);
|
||||||
|
}
|
||||||
|
}
|
||||||
("mix", "play-all") => {
|
("mix", "play-all") => {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
let weak = weak.clone();
|
let weak = weak.clone();
|
||||||
@@ -1320,6 +1334,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
("mix", "shuffle") => {
|
||||||
|
let runtime = runtime.clone();
|
||||||
|
let weak = weak.clone();
|
||||||
|
let handle = handle.clone();
|
||||||
|
handle.clone().spawn(async move {
|
||||||
|
let tracks = mix::shuffled_tracks();
|
||||||
|
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
("mix", "refresh") => {
|
||||||
|
// Re-load the current mix (re-fetch its tracks).
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
let kind = w.global::<MixState>().get_kind().to_string();
|
||||||
|
if !kind.is_empty() {
|
||||||
|
navigate_mix(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
&handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
("mix-track", track_id) => {
|
("mix-track", track_id) => {
|
||||||
let idx = mix::index_of(track_id);
|
let idx = mix::index_of(track_id);
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -1330,20 +1368,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
playback::play_tracks(runtime, weak, handle, tracks, idx);
|
playback::play_tracks(runtime, weak, handle, tracks, idx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
("mix", which) => {
|
|
||||||
// A Qobuz Mixes tile — open its detail view.
|
|
||||||
nav::record(nav::NavEntry::Mix { kind: which.to_string() });
|
|
||||||
navigate_mix(
|
|
||||||
runtime.clone(),
|
|
||||||
weak.clone(),
|
|
||||||
&handle,
|
|
||||||
image_cache.clone(),
|
|
||||||
which.to_string(),
|
|
||||||
);
|
|
||||||
if let Some(w) = weak.upgrade() {
|
|
||||||
update_nav_flags(&w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use qbz_models::Track;
|
|||||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||||
|
|
||||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||||
use crate::{AppWindow, MixState, SearchTrackItem};
|
use crate::{AppWindow, MixState, MixTrackItem};
|
||||||
|
|
||||||
/// The currently-loaded mix track list, kept so play-all / per-track
|
/// The currently-loaded mix track list, kept so play-all / per-track
|
||||||
/// play can build the queue without re-fetching.
|
/// play can build the queue without re-fetching.
|
||||||
@@ -124,13 +124,14 @@ fn mmss(secs: u32) -> String {
|
|||||||
format!("{}:{:02}", secs / 60, secs % 60)
|
format!("{}:{:02}", secs / 60, secs % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_item(track: &Track) -> SearchTrackItem {
|
fn to_item(index: usize, track: &Track) -> MixTrackItem {
|
||||||
let mut title = track.title.clone();
|
let mut title = track.title.clone();
|
||||||
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
|
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
|
||||||
title = format!("{title} ({v})");
|
title = format!("{title} ({v})");
|
||||||
}
|
}
|
||||||
SearchTrackItem {
|
MixTrackItem {
|
||||||
id: track.id.to_string().into(),
|
id: track.id.to_string().into(),
|
||||||
|
number: (index + 1).to_string().into(),
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
artist: track
|
artist: track
|
||||||
.performer
|
.performer
|
||||||
@@ -138,6 +139,12 @@ fn to_item(track: &Track) -> SearchTrackItem {
|
|||||||
.map(|p| p.name.clone())
|
.map(|p| p.name.clone())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into(),
|
.into(),
|
||||||
|
album: track
|
||||||
|
.album
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.title.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into(),
|
||||||
duration: mmss(track.duration).into(),
|
duration: mmss(track.duration).into(),
|
||||||
quality_tier: match track.maximum_bit_depth {
|
quality_tier: match track.maximum_bit_depth {
|
||||||
Some(d) if d >= 24 => "hires",
|
Some(d) if d >= 24 => "hires",
|
||||||
@@ -156,9 +163,26 @@ fn to_item(track: &Track) -> SearchTrackItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Human total duration: "1 h 23 min" or "23 min".
|
||||||
|
fn total_duration(tracks: &[Track]) -> String {
|
||||||
|
let secs: u64 = tracks.iter().map(|t| t.duration as u64).sum();
|
||||||
|
let mins = secs / 60;
|
||||||
|
if mins >= 60 {
|
||||||
|
format!("{} h {} min", mins / 60, mins % 60)
|
||||||
|
} else {
|
||||||
|
format!("{} min", mins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec<Track>) {
|
pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec<Track>) {
|
||||||
let (title, subtitle) = mix_meta(kind);
|
let (title, subtitle) = mix_meta(kind);
|
||||||
let items: Vec<SearchTrackItem> = tracks.iter().map(to_item).collect();
|
let items: Vec<MixTrackItem> = tracks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, t)| to_item(i, t))
|
||||||
|
.collect();
|
||||||
|
let count = tracks.len() as i32;
|
||||||
|
let duration = total_duration(&tracks);
|
||||||
if let Ok(mut cur) = CURRENT_MIX.lock() {
|
if let Ok(mut cur) = CURRENT_MIX.lock() {
|
||||||
*cur = tracks;
|
*cur = tracks;
|
||||||
}
|
}
|
||||||
@@ -167,6 +191,8 @@ pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec<Track>) {
|
|||||||
state.set_title(title.into());
|
state.set_title(title.into());
|
||||||
state.set_subtitle(subtitle.into());
|
state.set_subtitle(subtitle.into());
|
||||||
state.set_tracks(ModelRc::new(VecModel::from(items)));
|
state.set_tracks(ModelRc::new(VecModel::from(items)));
|
||||||
|
state.set_track_count(count);
|
||||||
|
state.set_total_duration(duration.into());
|
||||||
state.set_loading(false);
|
state.set_loading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +202,9 @@ pub fn reset_mix(window: &AppWindow, kind: &str) {
|
|||||||
state.set_kind(kind.into());
|
state.set_kind(kind.into());
|
||||||
state.set_title(title.into());
|
state.set_title(title.into());
|
||||||
state.set_subtitle(subtitle.into());
|
state.set_subtitle(subtitle.into());
|
||||||
state.set_tracks(ModelRc::new(VecModel::from(Vec::<SearchTrackItem>::new())));
|
state.set_tracks(ModelRc::new(VecModel::from(Vec::<MixTrackItem>::new())));
|
||||||
|
state.set_track_count(0);
|
||||||
|
state.set_total_duration("".into());
|
||||||
state.set_loading(true);
|
state.set_loading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +213,14 @@ pub fn current_tracks() -> Vec<Track> {
|
|||||||
CURRENT_MIX.lock().map(|c| c.clone()).unwrap_or_default()
|
CURRENT_MIX.lock().map(|c| c.clone()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The current mix tracks in a fresh random order (for the Shuffle
|
||||||
|
/// action) — does not mutate the displayed list.
|
||||||
|
pub fn shuffled_tracks() -> Vec<Track> {
|
||||||
|
let mut tracks = current_tracks();
|
||||||
|
shuffle(&mut tracks);
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
/// Index of `track_id` within the current mix (for play-from-here).
|
/// Index of `track_id` within the current mix (for play-from-here).
|
||||||
pub fn index_of(track_id: &str) -> usize {
|
pub fn index_of(track_id: &str) -> usize {
|
||||||
CURRENT_MIX
|
CURRENT_MIX
|
||||||
|
|||||||
@@ -1,18 +1,220 @@
|
|||||||
// Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Header with
|
// Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Ported to
|
||||||
// the mix name + description + a Play all button, over the mix's
|
// the AlbumView level: a header (gradient artwork + "Qobuz Mixes"
|
||||||
// track list (reusing the shared SearchTrackRow).
|
// label + title + description + "N tracks • duration" info line +
|
||||||
|
// circular action buttons) over a track list with a column header
|
||||||
|
// and rich rows (artwork + title/artist + album + duration + quality
|
||||||
|
// + favorite + more).
|
||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { MixState } from "../state.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { SearchTrackRow } from "../search/SearchTrackRow.slint";
|
import { MixState, MixTrackItem } from "../state.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
|
||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
import { NavButtons } from "../shell/NavButtons.slint";
|
import { NavButtons } from "../shell/NavButtons.slint";
|
||||||
|
|
||||||
|
// Circular header action button (Play / Shuffle / Save / Refresh).
|
||||||
|
component CircleAction inherits Rectangle {
|
||||||
|
in property <image> icon;
|
||||||
|
in property <bool> primary: false;
|
||||||
|
in property <length> diameter: 38px;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
callback clicked();
|
||||||
|
|
||||||
|
width: root.diameter;
|
||||||
|
height: root.diameter;
|
||||||
|
border-radius: root.diameter / 2;
|
||||||
|
opacity: root.enabled ? 1.0 : 0.4;
|
||||||
|
background: root.primary
|
||||||
|
? (ta.has-hover ? #ffffffd6 : Theme.accent)
|
||||||
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
||||||
|
QbzIcon {
|
||||||
|
source: root.icon;
|
||||||
|
width: root.primary ? 18px : 16px;
|
||||||
|
height: root.primary ? 18px : 16px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: root.primary ? #ffffff : Theme.text-secondary;
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: root.enabled ? pointer : default;
|
||||||
|
clicked => {
|
||||||
|
if (root.enabled) {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component MixTrackRow inherits Rectangle {
|
||||||
|
in property <MixTrackItem> track;
|
||||||
|
in property <int> index;
|
||||||
|
callback play(string);
|
||||||
|
|
||||||
|
property <bool> row-hovered: ta.has-hover || play-cell.hovered || fav-ta.has-hover || more-ta.has-hover;
|
||||||
|
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: root.row-hovered
|
||||||
|
? #ffffff16
|
||||||
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
spacing: 16px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
play-cell := TrackPlayCell {
|
||||||
|
track-id: root.track.id;
|
||||||
|
number: root.track.number;
|
||||||
|
artwork: root.track.artwork;
|
||||||
|
show-artwork: true;
|
||||||
|
row-hovered: root.row-hovered;
|
||||||
|
play => {
|
||||||
|
root.play(root.track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title + artist.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
spacing: 2px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: start;
|
||||||
|
spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.track.title;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.track.artist;
|
||||||
|
visible: root.track.artist != "";
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.link;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album column.
|
||||||
|
Text {
|
||||||
|
text: root.track.album;
|
||||||
|
width: 220px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
// Duration.
|
||||||
|
Text {
|
||||||
|
text: root.track.duration;
|
||||||
|
width: 64px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.body;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
// Quality.
|
||||||
|
Text {
|
||||||
|
text: root.track.quality-tier == "hires"
|
||||||
|
? "Hi-Res"
|
||||||
|
: (root.track.quality-tier == "cd" ? "CD" : "");
|
||||||
|
width: 64px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
// Favorite.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: fav-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/heart.svg");
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: fav-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||||
|
}
|
||||||
|
fav-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// More.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/ellipsis.svg");
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||||
|
}
|
||||||
|
more-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export component MixView inherits Rectangle {
|
export component MixView inherits Rectangle {
|
||||||
callback play-track(string);
|
callback play-track(string);
|
||||||
callback play-all();
|
callback play-all();
|
||||||
|
callback shuffle();
|
||||||
|
callback refresh();
|
||||||
|
|
||||||
|
// Per-mix header gradient, matching the QobuzMixesRow tiles.
|
||||||
|
property <brush> mix-gradient:
|
||||||
|
MixState.kind == "daily"
|
||||||
|
? @linear-gradient(135deg, #1e3a8a 0%, #6366f1 50%, #c084fc 100%)
|
||||||
|
: MixState.kind == "weekly"
|
||||||
|
? @linear-gradient(135deg, #065f46 0%, #10b981 50%, #fbbf24 100%)
|
||||||
|
: MixState.kind == "fav"
|
||||||
|
? @linear-gradient(135deg, #7f1d1d 0%, #ef4444 50%, #fb923c 100%)
|
||||||
|
: @linear-gradient(135deg, #1f2937 0%, #4b5563 50%, #fbbf24 100%);
|
||||||
|
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
|
|
||||||
@@ -38,67 +240,96 @@ export component MixView inherits Rectangle {
|
|||||||
|
|
||||||
// --- Header ------------------------------------------------
|
// --- Header ------------------------------------------------
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: 20px;
|
spacing: 32px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
|
// Gradient artwork.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 120px;
|
width: 224px;
|
||||||
height: 120px;
|
height: 224px;
|
||||||
border-radius: 8px;
|
border-radius: Radius.md;
|
||||||
background: @linear-gradient(135deg, #1e3a8a 0%, #6366f1 50%, #c084fc 100%);
|
background: root.mix-gradient;
|
||||||
Text {
|
Text {
|
||||||
text: MixState.title;
|
text: MixState.title;
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 18px;
|
font-size: 34px;
|
||||||
font-weight: Typography.bold;
|
font-weight: Typography.bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Metadata.
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: start;
|
||||||
spacing: 8px;
|
padding-top: 4px;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: @tr("Qobuz Mixes");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
Rectangle { height: 6px; }
|
||||||
Text {
|
Text {
|
||||||
text: MixState.title;
|
text: MixState.title;
|
||||||
color: Theme.text-primary;
|
color: Theme.text-primary;
|
||||||
font-size: Typography.title;
|
font-size: Typography.title;
|
||||||
font-weight: Typography.bold;
|
font-weight: Typography.bold;
|
||||||
}
|
}
|
||||||
|
Rectangle { height: 8px; }
|
||||||
Text {
|
Text {
|
||||||
|
width: root.width - 320px;
|
||||||
text: MixState.subtitle;
|
text: MixState.subtitle;
|
||||||
color: Theme.text-muted;
|
color: Theme.text-secondary;
|
||||||
font-size: Typography.body;
|
font-size: Typography.body;
|
||||||
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
if MixState.tracks.length > 0: Rectangle {
|
Rectangle { height: 10px; }
|
||||||
width: play-row.preferred-width + 28px;
|
if MixState.track-count > 0: Text {
|
||||||
height: 36px;
|
text: MixState.track-count + " " + @tr("tracks") + " • " + MixState.total-duration;
|
||||||
border-radius: 18px;
|
color: Theme.text-secondary;
|
||||||
background: play-ta.has-hover ? #ffffffd6 : Theme.accent;
|
font-size: Typography.legal;
|
||||||
play-row := HorizontalLayout {
|
|
||||||
x: (parent.width - self.preferred-width) / 2;
|
|
||||||
spacing: 8px;
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
QbzIcon {
|
|
||||||
source: @image-url("../assets/icons/play-fill.svg");
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
tint: #ffffff;
|
|
||||||
}
|
}
|
||||||
}
|
Rectangle { height: 20px; }
|
||||||
Text {
|
// Action buttons.
|
||||||
text: @tr("Play all");
|
HorizontalLayout {
|
||||||
color: #ffffff;
|
alignment: start;
|
||||||
font-size: 14px;
|
spacing: 12px;
|
||||||
font-weight: Typography.semibold;
|
CircleAction {
|
||||||
vertical-alignment: center;
|
icon: @image-url("../assets/icons/play-fill.svg");
|
||||||
}
|
primary: true;
|
||||||
}
|
diameter: 44px;
|
||||||
play-ta := TouchArea {
|
enabled: MixState.tracks.length > 0;
|
||||||
mouse-cursor: pointer;
|
|
||||||
clicked => {
|
clicked => {
|
||||||
root.play-all();
|
root.play-all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
CircleAction {
|
||||||
|
icon: @image-url("../assets/icons/shuffle.svg");
|
||||||
|
enabled: MixState.tracks.length > 0;
|
||||||
|
clicked => {
|
||||||
|
root.shuffle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
CircleAction {
|
||||||
|
icon: @image-url("../assets/icons/list-plus.svg");
|
||||||
|
enabled: MixState.tracks.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
CircleAction {
|
||||||
|
icon: @image-url("../assets/icons/home-gear.svg");
|
||||||
|
clicked => {
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,11 +346,57 @@ export component MixView inherits Rectangle {
|
|||||||
font-size: Typography.body;
|
font-size: Typography.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
for track[i] in MixState.tracks: SearchTrackRow {
|
// --- Track list column header ------------------------------
|
||||||
|
if MixState.tracks.length > 0: HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
spacing: 16px;
|
||||||
|
Text {
|
||||||
|
text: "#";
|
||||||
|
width: 40px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Title");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Album");
|
||||||
|
width: 220px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Duration");
|
||||||
|
width: 64px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Quality");
|
||||||
|
width: 64px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Rectangle { width: 28px; }
|
||||||
|
Rectangle { width: 32px; }
|
||||||
|
}
|
||||||
|
if MixState.tracks.length > 0: Rectangle {
|
||||||
|
height: 1px;
|
||||||
|
background: Theme.border-subtle;
|
||||||
|
}
|
||||||
|
Rectangle { height: 6px; }
|
||||||
|
|
||||||
|
for track[i] in MixState.tracks: MixTrackRow {
|
||||||
track: track;
|
track: track;
|
||||||
index: i;
|
index: i;
|
||||||
show-artwork: true;
|
play(id) => {
|
||||||
media-action(kind, id, action) => {
|
|
||||||
root.play-track(id);
|
root.play-track(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ export component AppShell inherits Rectangle {
|
|||||||
play-all => {
|
play-all => {
|
||||||
root.media-action("mix", "", "play-all");
|
root.media-action("mix", "", "play-all");
|
||||||
}
|
}
|
||||||
|
shuffle => {
|
||||||
|
root.media-action("mix", "", "shuffle");
|
||||||
|
}
|
||||||
|
refresh => {
|
||||||
|
root.media-action("mix", "", "refresh");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if NavState.view == ContentView.settings: SettingsView {
|
if NavState.view == ContentView.settings: SettingsView {
|
||||||
settings-bool(k, v) => {
|
settings-bool(k, v) => {
|
||||||
|
|||||||
@@ -791,13 +791,30 @@ export global FavoritesActions {
|
|||||||
callback track-action(string /* track id */, string /* action */);
|
callback track-action(string /* track id */, string /* action */);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One track row in a Qobuz mix detail page — carries an album column
|
||||||
|
// (mix tracks span many albums, unlike an album view).
|
||||||
|
export struct MixTrackItem {
|
||||||
|
id: string,
|
||||||
|
number: string,
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
album: string,
|
||||||
|
duration: string,
|
||||||
|
quality-tier: string,
|
||||||
|
explicit: bool,
|
||||||
|
artwork-url: string,
|
||||||
|
artwork: image,
|
||||||
|
}
|
||||||
|
|
||||||
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened
|
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened
|
||||||
// from the For You Qobuz Mixes tiles. Populated by crate::mix.
|
// from the For You Qobuz Mixes tiles. Populated by crate::mix.
|
||||||
export global MixState {
|
export global MixState {
|
||||||
in property <string> kind; // daily | weekly | fav | top
|
in property <string> kind; // daily | weekly | fav | top
|
||||||
in property <string> title;
|
in property <string> title;
|
||||||
in property <string> subtitle;
|
in property <string> subtitle;
|
||||||
in property <[SearchTrackItem]> tracks: [];
|
in property <[MixTrackItem]> tracks: [];
|
||||||
|
in property <int> track-count: 0;
|
||||||
|
in property <string> total-duration;
|
||||||
in property <bool> loading: false;
|
in property <bool> loading: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user