feat(slint): Qobuz mix detail views (DailyQ / WeeklyQ / FavQ / TopQ)

The For You Qobuz Mixes tiles now open functional mix detail pages.

  - New MixView: header (mix name + description + Play all) over the
    mix's track list (reusing SearchTrackRow). Clicking a track plays
    the whole mix from there; Play all starts at the top.
  - crate::mix resolves each mix to a track list from available
    backend: DailyQ/WeeklyQ -> /dynamic/suggest seeded from the local
    play-history track ids; FavQ -> favorite tracks shuffled (xorshift,
    no rng dep); TopQ -> tracks aggregated from the user's playlists.
    The loaded list is cached so play-all / play-from-track build the
    queue without re-fetching.
  - Backend: ported /dynamic/suggest into qbz-qobuz
    (QobuzClient::get_dynamic_suggest — POST, items parsed leniently
    into Vec<Track>) + QbzCore::get_dynamic_suggest.
  - ContentView.mix + NavEntry::Mix { kind } so each mix is a history
    entry; the tile click records + navigates. New MixTrack artwork
    target. playback::play_tracks generalizes the radio queue builder
    with a start index (radio reuses it at 0).

Tauri's exact generation (listened-track analysis payloads, playlist
play-stats ranking) is approximated; same surfaces + playback, sourced
from the backend the MVP has.

cc
This commit is contained in:
vicrodh committed 2026-05-21 08:16:31 -06:00
1 parent 4a9cbdeb90
commit 3b8f0a4597
11 files changed
+502 -12

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, ForYouState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint";
export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
+128
View File
@@ -0,0 +1,128 @@
// Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Header with
// the mix name + description + a Play all button, over the mix's
// track list (reusing the shared SearchTrackRow).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { MixState } from "../state.slint";
import { SearchTrackRow } from "../search/SearchTrackRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
export component MixView inherits Rectangle {
callback play-track(string);
callback play-all();
background: Theme.surface-main;
Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
// --- Header ------------------------------------------------
HorizontalLayout {
spacing: 20px;
alignment: start;
Rectangle {
width: 120px;
height: 120px;
border-radius: 8px;
background: @linear-gradient(135deg, #1e3a8a 0%, #6366f1 50%, #c084fc 100%);
Text {
text: MixState.title;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
color: #ffffff;
font-size: 18px;
font-weight: Typography.bold;
}
}
VerticalLayout {
alignment: center;
spacing: 8px;
Text {
text: MixState.title;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
}
Text {
text: MixState.subtitle;
color: Theme.text-muted;
font-size: Typography.body;
}
if MixState.tracks.length > 0: Rectangle {
width: play-row.preferred-width + 28px;
height: 36px;
border-radius: 18px;
background: play-ta.has-hover ? #ffffffd6 : Theme.accent;
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;
}
}
Text {
text: @tr("Play all");
color: #ffffff;
font-size: 14px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
play-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.play-all();
}
}
}
}
}
Rectangle { height: 28px; }
if MixState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
if !MixState.loading && MixState.tracks.length == 0: Text {
text: @tr("No tracks for this mix.");
color: Theme.text-muted;
font-size: Typography.body;
}
for track[i] in MixState.tracks: SearchTrackRow {
track: track;
index: i;
show-artwork: true;
media-action(kind, id, action) => {
root.play-track(id);
}
}
}
}
}
+9
View File
@@ -20,6 +20,7 @@ import { MusicianPageView } from "../musician/MusicianPageView.slint";
import { LabelReleasesView } from "../label/LabelReleasesView.slint";
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
import { FavoritesView } from "../favorites/FavoritesView.slint";
import { MixView } from "../mix/MixView.slint";
import { SettingsView } from "../settings/SettingsView.slint";
import { SearchResultsView } from "../search/SearchResultsView.slint";
@@ -128,6 +129,14 @@ export component AppShell inherits Rectangle {
root.open-artist(id);
}
}
if NavState.view == ContentView.mix: MixView {
play-track(id) => {
root.media-action("mix-track", id, "play");
}
play-all => {
root.media-action("mix", "", "play-all");
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
root.settings-bool(k, v);
+11 -1
View File
@@ -185,7 +185,7 @@ export global AlbumActions {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites }
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix }
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
@@ -791,6 +791,16 @@ export global FavoritesActions {
callback track-action(string /* track id */, string /* action */);
}
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened
// from the For You Qobuz Mixes tiles. Populated by crate::mix.
export global MixState {
in property <string> kind; // daily | weekly | fav | top
in property <string> title;
in property <string> subtitle;
in property <[SearchTrackItem]> tracks: [];
in property <bool> loading: false;
}
// Search results screen state. The Rust search controller populates the
// four category lists, their totals, and the most-popular hero entry.
export global SearchState {