feat(myqbz): Discography Builder — artist_collection creation flow
Phase-2 Slice 11. The third MyQBZ kind (artist_collection / artist_discography), 1:1 with spec 13 (the 1445-line DiscographyBuilderView): - ui/myqbz/DiscographyBuilderView.slint: header (artist avatar + name + eyebrow), name input, 3-segment order-by, the 7-col grouped release table (28 56 72 1fr 72 60 156) with tri-state select-all, primary + alternate rows (↳ connector, source indicator, QualityBadge, per-row type-override popover), footer (count + Create). Exact §0 dark colors + type tints. - src/myqbz_builder.rs: candidate/group model + normalize/classify/quality-score; THREE sources fully wired (Qobuz get_artist_page + local + Plex via the unified metadata query); build_groups dedupes/buckets/quality-sorts (Qobuz wins ties); persisted release-type override sidecar. Save = create_collection (ArtistCollection, ArtistDiscography, source_ref=artist_id) + add_item per checked candidate in sort order (Plex->local) -> navigate to the new collection's detail. - Entry: the artist page "Build Artist Collection" button (was unwired). Known polish nits (S12): view-wide uppercase eyebrow/labels; Create should disable on whitespace-only name. Toast uses real strings (toast.collectionCreated/Failed are absent from en.json; DQ4). Backend crates untouched. cargo check -p qbz-slint green. cc
This commit is contained in:
7 files changed
+2192
-2
No files matched your search
@@ -37,6 +37,7 @@ mod mix;
|
|||||||
mod musician;
|
mod musician;
|
||||||
mod myqbz;
|
mod myqbz;
|
||||||
mod myqbz_add;
|
mod myqbz_add;
|
||||||
|
mod myqbz_builder;
|
||||||
mod myqbz_cover;
|
mod myqbz_cover;
|
||||||
mod myqbz_detail;
|
mod myqbz_detail;
|
||||||
mod myqbz_edit;
|
mod myqbz_edit;
|
||||||
@@ -712,6 +713,85 @@ fn navigate_artist(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the Discography Builder for `artist_id` (spec 13). Fetches the artist's
|
||||||
|
/// releases from Qobuz (sets name + avatar), then local + Plex by that name
|
||||||
|
/// (sequential — parallelizing drops local matches against an empty name),
|
||||||
|
/// dedupes into groups, installs the default selection, and decodes the avatar.
|
||||||
|
/// Plex gets a single 2-second cold-start retry when enabled and the first
|
||||||
|
/// fetch returns nothing.
|
||||||
|
fn navigate_discography_builder(
|
||||||
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
handle: &tokio::runtime::Handle,
|
||||||
|
image_cache: artwork::ImageCache,
|
||||||
|
artist_id: String,
|
||||||
|
) {
|
||||||
|
let id_for_reset = artist_id.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
myqbz_builder::reset(&w, &id_for_reset);
|
||||||
|
w.global::<NavState>().set_view(ContentView::DiscographyBuilder);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Qobuz first — sets artist name + avatar URL (side effect).
|
||||||
|
match myqbz_builder::fetch_qobuz(&runtime, &artist_id).await {
|
||||||
|
Ok((qobuz, artist_name, avatar_url)) => {
|
||||||
|
// 2. Local + Plex by the resolved name (sequential, mandatory).
|
||||||
|
let name_for_local = artist_name.clone();
|
||||||
|
let mut local = tokio::task::spawn_blocking(move || {
|
||||||
|
myqbz_builder::fetch_local_and_plex(&name_for_local)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 2b. Plex cold-start retry: if Plex is enabled and we got
|
||||||
|
// nothing from the Plex source, wait 2s and refetch once.
|
||||||
|
let plex_enabled = crate::plex_settings::get().enabled;
|
||||||
|
let got_plex = local.iter().any(|c| c.source == "plex");
|
||||||
|
if plex_enabled && !got_plex {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
let name_retry = artist_name.clone();
|
||||||
|
let retried = tokio::task::spawn_blocking(move || {
|
||||||
|
myqbz_builder::fetch_local_and_plex(&name_retry)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if retried.iter().any(|c| c.source == "plex") {
|
||||||
|
local = retried;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge + group (Qobuz first so it wins primary ties).
|
||||||
|
let mut all = qobuz;
|
||||||
|
all.extend(local);
|
||||||
|
let groups = myqbz_builder::build_groups(all);
|
||||||
|
|
||||||
|
let avatar_for_fetch = avatar_url.clone();
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
myqbz_builder::install(&w, artist_name, avatar_url, groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Decode the avatar (72px circle).
|
||||||
|
if !avatar_for_fetch.is_empty() {
|
||||||
|
if let Some((pixels, width, height)) =
|
||||||
|
artwork::fetch_and_decode(&avatar_for_fetch, &image_cache, 144).await
|
||||||
|
{
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
myqbz_builder::apply_avatar(&w, &pixels, width, height);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[qbz-slint] discography builder load failed: {e}");
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
myqbz_builder::fail(&w, e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// Debounce timer for the header live search — restarted on every
|
/// Debounce timer for the header live search — restarted on every
|
||||||
/// keystroke, fires the search 300 ms after typing stops.
|
/// keystroke, fires the search 300 ms after typing stops.
|
||||||
@@ -779,6 +859,7 @@ fn scope_for(entry: &nav::NavEntry) -> String {
|
|||||||
nav::NavEntry::Mixtapes => "mixtapes".into(),
|
nav::NavEntry::Mixtapes => "mixtapes".into(),
|
||||||
nav::NavEntry::Collections => "collections".into(),
|
nav::NavEntry::Collections => "collections".into(),
|
||||||
nav::NavEntry::MixtapeDetail(_) => "mixtape-detail".into(),
|
nav::NavEntry::MixtapeDetail(_) => "mixtape-detail".into(),
|
||||||
|
nav::NavEntry::DiscographyBuilder(_) => "discography-builder".into(),
|
||||||
nav::NavEntry::Album(_) => "album".into(),
|
nav::NavEntry::Album(_) => "album".into(),
|
||||||
nav::NavEntry::LocalAlbum(_) => "local-album".into(),
|
nav::NavEntry::LocalAlbum(_) => "local-album".into(),
|
||||||
nav::NavEntry::Artist(_) => "artist".into(),
|
nav::NavEntry::Artist(_) => "artist".into(),
|
||||||
@@ -943,6 +1024,15 @@ fn apply_entry(
|
|||||||
nav::NavEntry::MixtapeDetail(id) => {
|
nav::NavEntry::MixtapeDetail(id) => {
|
||||||
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
|
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
|
||||||
}
|
}
|
||||||
|
nav::NavEntry::DiscographyBuilder(artist_id) => {
|
||||||
|
navigate_discography_builder(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
artist_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
nav::NavEntry::Location {
|
nav::NavEntry::Location {
|
||||||
mbid,
|
mbid,
|
||||||
area_id,
|
area_id,
|
||||||
@@ -2769,6 +2859,156 @@ fn wire_myqbz_detail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire the Discography Builder (spec 13). Back -> browser-back (returns to the
|
||||||
|
/// artist page). Name / order / checkbox / select-all / type-override drive the
|
||||||
|
/// `crate::myqbz_builder` session re-renders. Open-album routes through the
|
||||||
|
/// top-level `open-album` (Qobuz album view vs local-album by id). Create runs
|
||||||
|
/// the save flow on a blocking worker then navigates to the new collection.
|
||||||
|
fn wire_disco_builder(
|
||||||
|
window: &AppWindow,
|
||||||
|
tokio_rt: &tokio::runtime::Runtime,
|
||||||
|
image_cache: &artwork::ImageCache,
|
||||||
|
) {
|
||||||
|
use DiscoBuilderActions as Act;
|
||||||
|
|
||||||
|
// Back / Cancel -> browser-back to the artist page.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.global::<Act>().on_back(move || {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.global::<NavState>().invoke_request_back();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Collection-name input.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.global::<Act>().on_name_changed(move |name| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
myqbz_builder::name_changed(&w, name.as_str());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Order segmented control.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.global::<Act>().on_set_order(move |order| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
myqbz_builder::set_order(&w, order.as_str());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Per-row checkbox.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<Act>()
|
||||||
|
.on_toggle_checked(move |group_key, cand_key| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
myqbz_builder::toggle_checked(&w, group_key.as_str(), cand_key.as_str());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Header select-all.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.global::<Act>().on_toggle_all(move || {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
myqbz_builder::toggle_all(&w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Open an album (Qobuz album view, or local-album by group key).
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<Act>()
|
||||||
|
.on_open_album(move |_source, source_item_id| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
if !source_item_id.trim().is_empty() {
|
||||||
|
w.invoke_open_album(source_item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Release-type override set / reset.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<Act>()
|
||||||
|
.on_set_type_override(move |source, id, choice| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
myqbz_builder::set_type_override(
|
||||||
|
&w,
|
||||||
|
source.as_str(),
|
||||||
|
id.as_str(),
|
||||||
|
choice.as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<Act>()
|
||||||
|
.on_reset_type_override(move |source, id| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
myqbz_builder::reset_type_override(&w, source.as_str(), id.as_str());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Create — save the artist_collection + bulk-add, then navigate to detail.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
|
window.global::<Act>().on_create(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// Snapshot the selection in current sort order (UI thread).
|
||||||
|
let Some(payload) = myqbz_builder::save_payload(&w) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if w.global::<DiscoBuilderState>().get_creating() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
myqbz_builder::set_creating(&w, true);
|
||||||
|
|
||||||
|
let weak = weak.clone();
|
||||||
|
let handle = handle.clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
|
handle.clone().spawn(async move {
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
myqbz_builder::create_collection(&payload)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
myqbz_builder::set_creating(&w, false);
|
||||||
|
match result {
|
||||||
|
Some(collection_id) => {
|
||||||
|
myqbz_builder::toast_created(&w);
|
||||||
|
// Navigate to the new collection's detail.
|
||||||
|
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
|
||||||
|
myqbz_detail::navigate(
|
||||||
|
w.as_weak(),
|
||||||
|
handle.clone(),
|
||||||
|
image_cache.clone(),
|
||||||
|
collection_id,
|
||||||
|
);
|
||||||
|
update_nav_flags(&w);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
myqbz_builder::toast_failed(&w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a MusicianPageView for `name + role`. Routes to the artist
|
/// Open a MusicianPageView for `name + role`. Routes to the artist
|
||||||
/// page instead when the resolved musician has a Confirmed Qobuz
|
/// page instead when the resolved musician has a Confirmed Qobuz
|
||||||
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
|
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
|
||||||
@@ -3845,6 +4085,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
id.clone(),
|
id.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Build Artist Collection — open the Discography Builder for the
|
||||||
|
// current artist (the button passes an empty id, so resolve it
|
||||||
|
// from ArtistState). Records a history entry then routes.
|
||||||
|
("artist", "build-collection") => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
let artist_id = if id.is_empty() {
|
||||||
|
w.global::<ArtistState>().get_id().to_string()
|
||||||
|
} else {
|
||||||
|
id.clone()
|
||||||
|
};
|
||||||
|
if !artist_id.is_empty() {
|
||||||
|
nav::record(nav::NavEntry::DiscographyBuilder(artist_id.clone()));
|
||||||
|
navigate_discography_builder(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
&handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
artist_id,
|
||||||
|
);
|
||||||
|
update_nav_flags(&w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
("artist", "play-top") => playback::play_artist_top_tracks(
|
("artist", "play-top") => playback::play_artist_top_tracks(
|
||||||
runtime.clone(),
|
runtime.clone(),
|
||||||
weak.clone(),
|
weak.clone(),
|
||||||
@@ -7429,6 +7692,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
|
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||||
wire_myqbz(&window, &tokio_rt, &image_cache);
|
wire_myqbz(&window, &tokio_rt, &image_cache);
|
||||||
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
|
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
|
||||||
|
wire_disco_builder(&window, &tokio_rt, &image_cache);
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
window
|
window
|
||||||
|
|||||||
@@ -0,0 +1,922 @@
|
|||||||
|
//! Discography Builder controller (spec 13) — the Rust side of
|
||||||
|
//! `DiscographyBuilderView`. It fetches an artist's complete discography from
|
||||||
|
//! THREE sources (Qobuz artist page + local library + Plex cache), dedupes the
|
||||||
|
//! releases into GROUPS (one logical album = a chosen "primary" + "alternates"),
|
||||||
|
//! and persists the user-selected groups as a `kind='artist_collection'`
|
||||||
|
//! collection (`source_type='artist_discography'`, `source_ref=<artist_id>`).
|
||||||
|
//!
|
||||||
|
//! `artist_discography` is a MARKER, not a sync feature (spec 40 §8): the builder
|
||||||
|
//! resolves the artist's albums and bulk-adds them as ordinary album items via
|
||||||
|
//! `qbz_mixtape::repo` — there is NO sync command. On save it collapses Plex
|
||||||
|
//! candidates to `source='local'` (the LocalLibrary resolver re-detects Plex at
|
||||||
|
//! enqueue time) and navigates to the new collection's detail.
|
||||||
|
//!
|
||||||
|
//! Sourcing notes (degradation):
|
||||||
|
//! - Qobuz: `runtime.core().get_artist_page` — the full path, sets artist name +
|
||||||
|
//! avatar.
|
||||||
|
//! - Local + Plex: ONE unified `get_albums_metadata_page(…, plex_cache_db_path)`
|
||||||
|
//! query (the same one the Local Library Albums tab uses). Plex rows arrive in
|
||||||
|
//! that set with `source == "plex"` when the Plex master toggle is ON; with it
|
||||||
|
//! OFF the query runs local-only. Rows are filtered to the artist by a
|
||||||
|
//! case-insensitive match on `artist` OR any comma-split entry in `all_artists`
|
||||||
|
//! (mirrors the PSD `matchesArtist`). If the DB is unavailable the local/plex
|
||||||
|
//! contribution degrades to empty (logged) — the Qobuz path still fully works.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, LazyLock, Mutex};
|
||||||
|
|
||||||
|
use qbz_app::shell::AppRuntime;
|
||||||
|
use qbz_core::FrontendAdapter;
|
||||||
|
use qbz_models::mixtape::{CollectionKind, CollectionSourceType};
|
||||||
|
|
||||||
|
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
AppWindow, DiscoBuilderState, DiscoCandidate, DiscoGroup, ToastKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ──────────────────────────── candidate model ─────────────────────────────
|
||||||
|
|
||||||
|
/// One release candidate (a primary or an alternate). Plain `Send` data built
|
||||||
|
/// on the worker thread; the UI thread maps it into the Slint `DiscoCandidate`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Candidate {
|
||||||
|
pub group_key: String,
|
||||||
|
/// "qobuz" | "local" | "plex".
|
||||||
|
pub source: String,
|
||||||
|
pub source_item_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub artwork_url: Option<String>,
|
||||||
|
pub track_count: Option<i32>,
|
||||||
|
pub max_bit_depth: Option<u32>,
|
||||||
|
/// kHz.
|
||||||
|
pub max_sample_rate: Option<f64>,
|
||||||
|
pub format: String,
|
||||||
|
pub is_compilation: bool,
|
||||||
|
/// The AUTO-classified release type (before any user override).
|
||||||
|
pub release_type: String,
|
||||||
|
pub quality_score: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Candidate {
|
||||||
|
/// `${source}|${source_item_id}` — the dedupe + selection key.
|
||||||
|
fn key(&self) -> String {
|
||||||
|
format!("{}|{}", self.source, self.source_item_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One dedupe group: a primary plus its alternates.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Group {
|
||||||
|
pub key: String,
|
||||||
|
pub title: String,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub primary: Candidate,
|
||||||
|
pub alternates: Vec<Candidate>,
|
||||||
|
pub is_compilation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full builder session state, held process-global on the UI thread side
|
||||||
|
/// (the controller mutates it; `apply` renders it). Mirrors the Svelte `$state`.
|
||||||
|
struct Session {
|
||||||
|
artist_id: String,
|
||||||
|
artist_name: String,
|
||||||
|
artist_avatar_url: String,
|
||||||
|
groups: Vec<Group>,
|
||||||
|
/// `Record<groupKey, Set<candidateKey>>`.
|
||||||
|
checked: HashMap<String, Vec<String>>,
|
||||||
|
order_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Session {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
artist_id: String::new(),
|
||||||
|
artist_name: String::new(),
|
||||||
|
artist_avatar_url: String::new(),
|
||||||
|
groups: Vec::new(),
|
||||||
|
checked: HashMap::new(),
|
||||||
|
order_by: "release_date".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static SESSION: LazyLock<Mutex<Session>> = LazyLock::new(|| Mutex::new(Session::default()));
|
||||||
|
|
||||||
|
fn session() -> std::sync::MutexGuard<'static, Session> {
|
||||||
|
SESSION.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────── release-type override store ──────────────────────
|
||||||
|
//
|
||||||
|
// The PSD writes overrides to a per-user localStorage sidecar shared with the
|
||||||
|
// Collection detail view. Here it's a process-global map persisted to a JSON
|
||||||
|
// sidecar under the user data dir so a chosen override survives the session and
|
||||||
|
// (when the Slint Collection-detail override UI lands) can be read back. Keyed
|
||||||
|
// `${source}|${source_item_id}` -> one of album|ep|single|live|compilation.
|
||||||
|
|
||||||
|
static OVERRIDES: LazyLock<Mutex<HashMap<String, String>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(load_overrides()));
|
||||||
|
|
||||||
|
fn overrides_path() -> Option<std::path::PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| {
|
||||||
|
d.join("qbz")
|
||||||
|
.join("discography-release-type-overrides.json")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_overrides() -> HashMap<String, String> {
|
||||||
|
let Some(path) = overrides_path() else {
|
||||||
|
return HashMap::new();
|
||||||
|
};
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return HashMap::new();
|
||||||
|
};
|
||||||
|
serde_json::from_slice(&bytes).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_overrides(map: &HashMap<String, String>) {
|
||||||
|
let Some(path) = overrides_path() else { return };
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(map) {
|
||||||
|
let _ = std::fs::write(&path, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn override_for(source: &str, source_item_id: &str) -> Option<String> {
|
||||||
|
let key = format!("{source}|{source_item_id}");
|
||||||
|
OVERRIDES.lock().ok().and_then(|m| m.get(&key).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The EFFECTIVE release type after applying the user override (if any).
|
||||||
|
fn effective_type(c: &Candidate) -> String {
|
||||||
|
override_for(&c.source, &c.source_item_id).unwrap_or_else(|| c.release_type.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_overridden(c: &Candidate) -> bool {
|
||||||
|
override_for(&c.source, &c.source_item_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── classification ──────────────────────────────
|
||||||
|
|
||||||
|
/// `normalizeTitle`: lowercase, strip parenthetical/bracket edition suffixes,
|
||||||
|
/// collapse whitespace, trim. Mirrors the PSD regex.
|
||||||
|
fn normalize_title(title: &str) -> String {
|
||||||
|
let lower = title.to_lowercase();
|
||||||
|
// Edition keywords that mark a re-issue suffix.
|
||||||
|
const KEYWORDS: [&str; 14] = [
|
||||||
|
"deluxe",
|
||||||
|
"remaster",
|
||||||
|
"expanded",
|
||||||
|
"anniversary",
|
||||||
|
"collector",
|
||||||
|
"special",
|
||||||
|
"bonus",
|
||||||
|
"extended",
|
||||||
|
"definitive",
|
||||||
|
"20th",
|
||||||
|
"25th",
|
||||||
|
"30th",
|
||||||
|
"40th",
|
||||||
|
"50th",
|
||||||
|
];
|
||||||
|
// Find the first '(' or '[' whose inner text starts with an edition
|
||||||
|
// keyword (or an Nth marker) and strip from there to the end.
|
||||||
|
let bytes = lower.as_bytes();
|
||||||
|
let mut cut = lower.len();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let ch = bytes[i] as char;
|
||||||
|
if ch == '(' || ch == '[' {
|
||||||
|
let inner = lower[i + 1..].trim_start();
|
||||||
|
let starts_kw = KEYWORDS.iter().any(|k| inner.starts_with(k))
|
||||||
|
|| starts_with_nth(inner);
|
||||||
|
if starts_kw {
|
||||||
|
cut = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let trimmed = &lower[..cut];
|
||||||
|
// Collapse whitespace + trim.
|
||||||
|
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `s` begins with an `<digits>th` token (e.g. "10th anniversary").
|
||||||
|
fn starts_with_nth(s: &str) -> bool {
|
||||||
|
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
!digits.is_empty() && s[digits.len()..].starts_with("th")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `isCompilation(title)` — best-of / greatest-hits markers.
|
||||||
|
fn title_is_compilation(title: &str) -> bool {
|
||||||
|
let l = title.to_lowercase();
|
||||||
|
const M: [&str; 6] = [
|
||||||
|
"best of",
|
||||||
|
"greatest hits",
|
||||||
|
"anthology",
|
||||||
|
"the very best",
|
||||||
|
"essential",
|
||||||
|
"collection",
|
||||||
|
];
|
||||||
|
M.iter().any(|m| l.contains(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `classifyRelease` — precedence: compilation → live → ep → single → album →
|
||||||
|
/// track-count heuristic. `qobuz_release_type` / `qobuz_group_type` are the
|
||||||
|
/// per-item and the group's release_type from /artist/page (None for local/plex).
|
||||||
|
fn classify_release(
|
||||||
|
title: &str,
|
||||||
|
track_count: Option<i32>,
|
||||||
|
qobuz_release_type: Option<&str>,
|
||||||
|
qobuz_group_type: Option<&str>,
|
||||||
|
title_is_comp: bool,
|
||||||
|
) -> String {
|
||||||
|
let l = title.to_lowercase();
|
||||||
|
let rt = qobuz_release_type.unwrap_or("");
|
||||||
|
let gt = qobuz_group_type.unwrap_or("");
|
||||||
|
let type_is = |needle: &str| rt.eq_ignore_ascii_case(needle) || gt.eq_ignore_ascii_case(needle);
|
||||||
|
|
||||||
|
if rt.to_lowercase().contains("compilation")
|
||||||
|
|| gt.to_lowercase().contains("compilation")
|
||||||
|
|| title_is_comp
|
||||||
|
{
|
||||||
|
return "compilation".to_string();
|
||||||
|
}
|
||||||
|
let word = |w: &str| {
|
||||||
|
// crude word-boundary contains
|
||||||
|
l.split(|c: char| !c.is_alphanumeric()).any(|tok| tok == w)
|
||||||
|
};
|
||||||
|
if type_is("live") || word("live") || word("concert") || word("unplugged") {
|
||||||
|
return "live".to_string();
|
||||||
|
}
|
||||||
|
if type_is("ep") || word("ep") {
|
||||||
|
return "ep".to_string();
|
||||||
|
}
|
||||||
|
if type_is("single") {
|
||||||
|
return "single".to_string();
|
||||||
|
}
|
||||||
|
if type_is("album") {
|
||||||
|
return "album".to_string();
|
||||||
|
}
|
||||||
|
match track_count {
|
||||||
|
Some(n) if n <= 3 => "single".to_string(),
|
||||||
|
Some(n) if n <= 6 => "ep".to_string(),
|
||||||
|
_ => "album".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `qualityScore`: bit*10_000_000 + rateHz + fmtBonus.
|
||||||
|
fn quality_score(
|
||||||
|
max_bit_depth: Option<u32>,
|
||||||
|
max_sample_rate_khz: Option<f64>,
|
||||||
|
format: &str,
|
||||||
|
) -> i64 {
|
||||||
|
let bit = max_bit_depth.unwrap_or(16) as i64;
|
||||||
|
let rate_hz = ((max_sample_rate_khz.unwrap_or(44.1)) * 1000.0).round() as i64;
|
||||||
|
let fmt = format.to_lowercase();
|
||||||
|
let fmt_bonus = if fmt.contains("flac") || fmt.contains("alac") {
|
||||||
|
1000
|
||||||
|
} else if fmt.contains("mp3") || fmt.contains("aac") {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
500
|
||||||
|
};
|
||||||
|
bit * 10_000_000 + rate_hz + fmt_bonus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── source fetchers ─────────────────────────────
|
||||||
|
|
||||||
|
/// Fetch the artist's Qobuz releases. Returns `(candidates, artist_name,
|
||||||
|
/// avatar_url)`. Mirrors `fetchQobuzAlbums` — name + avatar are a side effect.
|
||||||
|
pub async fn fetch_qobuz<A>(
|
||||||
|
runtime: &Arc<AppRuntime<A>>,
|
||||||
|
artist_id: &str,
|
||||||
|
) -> Result<(Vec<Candidate>, String, String), String>
|
||||||
|
where
|
||||||
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let id: u64 = artist_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
|
||||||
|
let page = runtime
|
||||||
|
.core()
|
||||||
|
.get_artist_page(id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let artist_name = page.name.display.clone();
|
||||||
|
let avatar_url = page
|
||||||
|
.images
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|imgs| imgs.portrait.as_ref())
|
||||||
|
.map(|p| {
|
||||||
|
format!(
|
||||||
|
"https://static.qobuz.com/images/artists/covers/medium/{}.{}",
|
||||||
|
p.hash, p.format
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut out: Vec<Candidate> = Vec::new();
|
||||||
|
for group in page.releases.into_iter().flatten() {
|
||||||
|
let group_type = group.release_type.clone();
|
||||||
|
for r in group.items.into_iter() {
|
||||||
|
let year = r
|
||||||
|
.dates
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.original.as_deref())
|
||||||
|
.and_then(|s| s.get(..4))
|
||||||
|
.and_then(|y| y.parse::<i32>().ok());
|
||||||
|
let artwork = r
|
||||||
|
.image
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|img| img.large.clone().or_else(|| img.best().cloned()));
|
||||||
|
let track_count = r.tracks_count.map(|n| n as i32);
|
||||||
|
let bit = r.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
|
||||||
|
let rate = r.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
|
||||||
|
let title = r.title.clone();
|
||||||
|
let title_comp = title_is_compilation(&title);
|
||||||
|
let release_type = classify_release(
|
||||||
|
&title,
|
||||||
|
track_count,
|
||||||
|
r.release_type.as_deref(),
|
||||||
|
Some(&group_type),
|
||||||
|
title_comp,
|
||||||
|
);
|
||||||
|
let is_comp = release_type == "compilation";
|
||||||
|
let group_key = format!(
|
||||||
|
"{}|{}",
|
||||||
|
normalize_title(&title),
|
||||||
|
year.map(|y| y.to_string()).unwrap_or_default()
|
||||||
|
);
|
||||||
|
out.push(Candidate {
|
||||||
|
group_key,
|
||||||
|
source: "qobuz".to_string(),
|
||||||
|
source_item_id: r.id.clone(),
|
||||||
|
title,
|
||||||
|
artist: artist_name.clone(),
|
||||||
|
year,
|
||||||
|
artwork_url: artwork,
|
||||||
|
track_count,
|
||||||
|
max_bit_depth: bit,
|
||||||
|
max_sample_rate: rate,
|
||||||
|
format: "FLAC".to_string(),
|
||||||
|
is_compilation: is_comp,
|
||||||
|
release_type,
|
||||||
|
quality_score: quality_score(bit, rate, "FLAC"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((out, artist_name, avatar_url))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plex_cache_db_path() -> Option<std::path::PathBuf> {
|
||||||
|
if !crate::plex_settings::get().enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a local album's `artist` / `all_artists` matches `artist_name`
|
||||||
|
/// (case-insensitive exact match on artist OR any comma-split all_artists entry).
|
||||||
|
fn matches_artist(artist: &str, all_artists: &str, artist_name: &str) -> bool {
|
||||||
|
let needle = artist_name.trim().to_lowercase();
|
||||||
|
if needle.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if artist.trim().to_lowercase() == needle {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
all_artists
|
||||||
|
.split(',')
|
||||||
|
.any(|a| a.trim().to_lowercase() == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch local + Plex albums by the artist via the unified metadata-grouped
|
||||||
|
/// page (Plex union included when enabled). Blocking (DB). Degrades to empty on
|
||||||
|
/// DB failure (logged by `with_db`). `artist_name` MUST be resolved first (the
|
||||||
|
/// Qobuz fetch sets it) — an empty name drops every match (mirrors the PSD).
|
||||||
|
pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
|
||||||
|
if artist_name.trim().is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let plex_path = plex_cache_db_path();
|
||||||
|
let albums = crate::library_db::with_db(|db| {
|
||||||
|
let page = db.get_albums_metadata_page(
|
||||||
|
0,
|
||||||
|
1_000_000,
|
||||||
|
None,
|
||||||
|
"artist",
|
||||||
|
"asc",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
plex_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
Ok(page.albums)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
albums
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| matches_artist(&a.artist, &a.all_artists, artist_name))
|
||||||
|
.map(|a| {
|
||||||
|
let source = if a.source == "plex" { "plex" } else { "local" }.to_string();
|
||||||
|
let year = a.year.map(|y| y as i32);
|
||||||
|
let track_count = if a.track_count > 0 {
|
||||||
|
Some(a.track_count as i32)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let bit = a.bit_depth;
|
||||||
|
// LocalAlbum.sample_rate is Hz; the candidate carries kHz.
|
||||||
|
let rate_khz = if a.sample_rate >= 1000.0 {
|
||||||
|
Some(a.sample_rate / 1000.0)
|
||||||
|
} else if a.sample_rate > 0.0 {
|
||||||
|
Some(a.sample_rate)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let format = a.format.to_string();
|
||||||
|
let title = a.title.clone();
|
||||||
|
let title_comp = title_is_compilation(&title);
|
||||||
|
let release_type = classify_release(&title, track_count, None, None, title_comp);
|
||||||
|
let is_comp = release_type == "compilation";
|
||||||
|
let group_key = format!(
|
||||||
|
"{}|{}",
|
||||||
|
normalize_title(&title),
|
||||||
|
year.map(|y| y.to_string()).unwrap_or_default()
|
||||||
|
);
|
||||||
|
Candidate {
|
||||||
|
group_key,
|
||||||
|
source,
|
||||||
|
source_item_id: a.id,
|
||||||
|
title,
|
||||||
|
artist: a.artist,
|
||||||
|
year,
|
||||||
|
artwork_url: None,
|
||||||
|
track_count,
|
||||||
|
max_bit_depth: bit,
|
||||||
|
max_sample_rate: rate_khz,
|
||||||
|
format,
|
||||||
|
is_compilation: is_comp,
|
||||||
|
release_type,
|
||||||
|
quality_score: quality_score(bit, rate_khz, &a.format.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── buildGroups ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Dedupe incoming candidates by `${source}|${source_item_id}`, bucket by
|
||||||
|
/// `group_key`, sort each bucket by quality (Qobuz wins ties for the primary
|
||||||
|
/// slot), and produce groups.
|
||||||
|
pub fn build_groups(candidates: Vec<Candidate>) -> Vec<Group> {
|
||||||
|
// 1. Dedupe by candidate key, preserving first-seen order.
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let mut deduped: Vec<Candidate> = Vec::new();
|
||||||
|
for c in candidates {
|
||||||
|
if seen.insert(c.key()) {
|
||||||
|
deduped.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bucket by group_key, preserving first-seen bucket order (Map order).
|
||||||
|
let mut order: Vec<String> = Vec::new();
|
||||||
|
let mut buckets: HashMap<String, Vec<Candidate>> = HashMap::new();
|
||||||
|
for c in deduped {
|
||||||
|
if !buckets.contains_key(&c.group_key) {
|
||||||
|
order.push(c.group_key.clone());
|
||||||
|
}
|
||||||
|
buckets.entry(c.group_key.clone()).or_default().push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3+4. Within each bucket sort by quality_score desc, Qobuz-preferred on
|
||||||
|
// ties; primary = sorted[0], alternates = rest.
|
||||||
|
let mut groups: Vec<Group> = Vec::new();
|
||||||
|
for key in order {
|
||||||
|
let mut bucket = buckets.remove(&key).unwrap_or_default();
|
||||||
|
bucket.sort_by(|a, b| {
|
||||||
|
b.quality_score.cmp(&a.quality_score).then_with(|| {
|
||||||
|
// equal quality → qobuz first.
|
||||||
|
let aq = a.source == "qobuz";
|
||||||
|
let bq = b.source == "qobuz";
|
||||||
|
bq.cmp(&aq)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let is_compilation = bucket.iter().all(|c| c.is_compilation);
|
||||||
|
let primary = bucket.remove(0);
|
||||||
|
let title = primary.title.clone();
|
||||||
|
let year = primary.year;
|
||||||
|
groups.push(Group {
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
primary,
|
||||||
|
alternates: bucket,
|
||||||
|
is_compilation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── sort (orderedGroups) ─────────────────────────
|
||||||
|
|
||||||
|
/// Return the groups in the active sort order (clones into a fresh Vec).
|
||||||
|
fn ordered_groups(groups: &[Group], order_by: &str) -> Vec<Group> {
|
||||||
|
let mut g = groups.to_vec();
|
||||||
|
match order_by {
|
||||||
|
"title" => g.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
||||||
|
"manual" => {} // insertion order — unchanged.
|
||||||
|
_ => {
|
||||||
|
// release_date: year asc, nulls last, tiebreak by title.
|
||||||
|
g.sort_by(|a, b| match (a.year, b.year) {
|
||||||
|
(None, None) => a.title.to_lowercase().cmp(&b.title.to_lowercase()),
|
||||||
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
|
(Some(_), None) => std::cmp::Ordering::Less,
|
||||||
|
(Some(ya), Some(yb)) => ya.cmp(&yb),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── selection helpers ────────────────────────────
|
||||||
|
|
||||||
|
fn candidate_key(c: &Candidate) -> String {
|
||||||
|
c.key()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_checked(s: &Session, group_key: &str, cand_key: &str) -> bool {
|
||||||
|
s.checked
|
||||||
|
.get(group_key)
|
||||||
|
.map(|set| set.iter().any(|k| k == cand_key))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `allPrimariesChecked` — every non-compilation group has its primary checked
|
||||||
|
/// (compilations are treated as always-satisfied).
|
||||||
|
fn all_primaries_checked(s: &Session) -> bool {
|
||||||
|
!s.groups.is_empty()
|
||||||
|
&& s.groups.iter().all(|g| {
|
||||||
|
g.is_compilation || is_checked(s, &g.key, &candidate_key(&g.primary))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn some_primaries_checked(s: &Session) -> bool {
|
||||||
|
!all_primaries_checked(s)
|
||||||
|
&& s.groups
|
||||||
|
.iter()
|
||||||
|
.any(|g| !g.is_compilation && is_checked(s, &g.key, &candidate_key(&g.primary)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total checked candidate count across all groups (primaries + alternates).
|
||||||
|
fn selected_count(s: &Session) -> usize {
|
||||||
|
s.checked.values().map(|set| set.len()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── apply / render ──────────────────────────────
|
||||||
|
|
||||||
|
fn map_candidate(s: &Session, c: &Candidate) -> DiscoCandidate {
|
||||||
|
let eff = effective_type(c);
|
||||||
|
let (tier, detail, _) = crate::quality::badge(&c.format, c.max_bit_depth, c.max_sample_rate);
|
||||||
|
DiscoCandidate {
|
||||||
|
key: candidate_key(c).into(),
|
||||||
|
source: c.source.clone().into(),
|
||||||
|
source_item_id: c.source_item_id.clone().into(),
|
||||||
|
title: c.title.clone().into(),
|
||||||
|
artist: c.artist.clone().into(),
|
||||||
|
year: c
|
||||||
|
.year
|
||||||
|
.map(|y| y.to_string())
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
.into(),
|
||||||
|
tracks: c
|
||||||
|
.track_count
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
.into(),
|
||||||
|
artwork_url: c.artwork_url.clone().unwrap_or_default().into(),
|
||||||
|
release_type: eff.into(),
|
||||||
|
is_overridden: is_overridden(c),
|
||||||
|
quality_tier: tier.into(),
|
||||||
|
quality_detail: detail.into(),
|
||||||
|
checked: is_checked(s, &c.group_key, &candidate_key(c)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the full state global from the session. UI thread only.
|
||||||
|
pub fn apply(window: &AppWindow) {
|
||||||
|
let s = session();
|
||||||
|
let state = window.global::<DiscoBuilderState>();
|
||||||
|
|
||||||
|
let groups = ordered_groups(&s.groups, &s.order_by);
|
||||||
|
let rows: Vec<DiscoGroup> = groups
|
||||||
|
.iter()
|
||||||
|
.map(|g| {
|
||||||
|
let alternates: Vec<DiscoCandidate> =
|
||||||
|
g.alternates.iter().map(|c| map_candidate(&s, c)).collect();
|
||||||
|
DiscoGroup {
|
||||||
|
key: g.key.clone().into(),
|
||||||
|
title: g.title.clone().into(),
|
||||||
|
year: g
|
||||||
|
.year
|
||||||
|
.map(|y| y.to_string())
|
||||||
|
.unwrap_or_else(|| "—".to_string())
|
||||||
|
.into(),
|
||||||
|
primary: map_candidate(&s, &g.primary),
|
||||||
|
alternates: ModelRc::new(VecModel::from(alternates)),
|
||||||
|
is_compilation: g.is_compilation,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
state.set_groups(ModelRc::new(VecModel::from(rows)));
|
||||||
|
state.set_all_checked(all_primaries_checked(&s));
|
||||||
|
state.set_some_checked(some_primaries_checked(&s));
|
||||||
|
state.set_selected_count(selected_count(&s) as i32);
|
||||||
|
state.set_group_count(s.groups.len() as i32);
|
||||||
|
state.set_artist_name(s.artist_name.clone().into());
|
||||||
|
state.set_artist_id(s.artist_id.clone().into());
|
||||||
|
state.set_artist_avatar_url(s.artist_avatar_url.clone().into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the builder state before a fresh load.
|
||||||
|
pub fn reset(window: &AppWindow, artist_id: &str) {
|
||||||
|
{
|
||||||
|
let mut s = session();
|
||||||
|
*s = Session {
|
||||||
|
artist_id: artist_id.to_string(),
|
||||||
|
order_by: "release_date".to_string(),
|
||||||
|
..Session::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let state = window.global::<DiscoBuilderState>();
|
||||||
|
state.set_loading(true);
|
||||||
|
state.set_load_error("".into());
|
||||||
|
state.set_creating(false);
|
||||||
|
state.set_open_type_menu_key("".into());
|
||||||
|
state.set_collection_name("".into());
|
||||||
|
state.set_order_by("release_date".into());
|
||||||
|
state.set_artist_id(artist_id.into());
|
||||||
|
state.set_artist_name("".into());
|
||||||
|
state.set_artist_avatar_url("".into());
|
||||||
|
state.set_artist_avatar(slint::Image::default());
|
||||||
|
state.set_groups(ModelRc::new(VecModel::from(Vec::<DiscoGroup>::new())));
|
||||||
|
state.set_selected_count(0);
|
||||||
|
state.set_group_count(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the fetched + grouped data into the session and seed the default
|
||||||
|
/// selection (each non-compilation primary pre-checked). UI thread.
|
||||||
|
pub fn install(
|
||||||
|
window: &AppWindow,
|
||||||
|
artist_name: String,
|
||||||
|
artist_avatar_url: String,
|
||||||
|
groups: Vec<Group>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let mut s = session();
|
||||||
|
s.artist_name = artist_name.clone();
|
||||||
|
s.artist_avatar_url = artist_avatar_url.clone();
|
||||||
|
s.groups = groups;
|
||||||
|
// Default selection: non-compilation primaries pre-checked.
|
||||||
|
s.checked.clear();
|
||||||
|
let group_snapshot: Vec<(String, bool, String)> = s
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|g| (g.key.clone(), g.is_compilation, candidate_key(&g.primary)))
|
||||||
|
.collect();
|
||||||
|
for (key, is_comp, primary_key) in group_snapshot {
|
||||||
|
let mut set = Vec::new();
|
||||||
|
if !is_comp {
|
||||||
|
set.push(primary_key);
|
||||||
|
}
|
||||||
|
s.checked.insert(key, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = window.global::<DiscoBuilderState>();
|
||||||
|
// Default collection name if still empty.
|
||||||
|
if state.get_collection_name().is_empty() {
|
||||||
|
let base = if artist_name.is_empty() {
|
||||||
|
"Artist".to_string()
|
||||||
|
} else {
|
||||||
|
artist_name.clone()
|
||||||
|
};
|
||||||
|
state.set_collection_name(format!("{base} — Complete Discography").into());
|
||||||
|
}
|
||||||
|
state.set_loading(false);
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the load failed with a raw error message (shown verbatim).
|
||||||
|
pub fn fail(window: &AppWindow, message: String) {
|
||||||
|
let state = window.global::<DiscoBuilderState>();
|
||||||
|
state.set_loading(false);
|
||||||
|
state.set_load_error(message.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── interactions ────────────────────────────────
|
||||||
|
|
||||||
|
/// Toggle one candidate's checked state. UI thread; re-renders.
|
||||||
|
pub fn toggle_checked(window: &AppWindow, group_key: &str, cand_key: &str) {
|
||||||
|
{
|
||||||
|
let mut s = session();
|
||||||
|
let set = s.checked.entry(group_key.to_string()).or_default();
|
||||||
|
if let Some(pos) = set.iter().position(|k| k == cand_key) {
|
||||||
|
set.remove(pos);
|
||||||
|
} else {
|
||||||
|
set.push(cand_key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header tri-state toggle — only ever touches non-compilation PRIMARIES;
|
||||||
|
/// alternates + compilations are left exactly as the user had them.
|
||||||
|
pub fn toggle_all(window: &AppWindow) {
|
||||||
|
{
|
||||||
|
let mut s = session();
|
||||||
|
let target = !all_primaries_checked(&s);
|
||||||
|
let prims: Vec<(String, String)> = s
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter(|g| !g.is_compilation)
|
||||||
|
.map(|g| (g.key.clone(), candidate_key(&g.primary)))
|
||||||
|
.collect();
|
||||||
|
for (gkey, pkey) in prims {
|
||||||
|
let set = s.checked.entry(gkey).or_default();
|
||||||
|
let has = set.iter().any(|k| k == &pkey);
|
||||||
|
if target && !has {
|
||||||
|
set.push(pkey);
|
||||||
|
} else if !target && has {
|
||||||
|
set.retain(|k| k != &pkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the sort order.
|
||||||
|
pub fn set_order(window: &AppWindow, order_by: &str) {
|
||||||
|
{
|
||||||
|
let mut s = session();
|
||||||
|
s.order_by = order_by.to_string();
|
||||||
|
}
|
||||||
|
window.global::<DiscoBuilderState>().set_order_by(order_by.into());
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync the collection-name into the state (the input is bound but we mirror
|
||||||
|
/// it so the save flow reads a single source).
|
||||||
|
pub fn name_changed(window: &AppWindow, name: &str) {
|
||||||
|
window
|
||||||
|
.global::<DiscoBuilderState>()
|
||||||
|
.set_collection_name(name.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a release-type override (persisted) + re-render.
|
||||||
|
pub fn set_type_override(window: &AppWindow, source: &str, source_item_id: &str, choice: &str) {
|
||||||
|
{
|
||||||
|
if let Ok(mut m) = OVERRIDES.lock() {
|
||||||
|
m.insert(format!("{source}|{source_item_id}"), choice.to_string());
|
||||||
|
save_overrides(&m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear a release-type override + re-render.
|
||||||
|
pub fn reset_type_override(window: &AppWindow, source: &str, source_item_id: &str) {
|
||||||
|
{
|
||||||
|
if let Ok(mut m) = OVERRIDES.lock() {
|
||||||
|
m.remove(&format!("{source}|{source_item_id}"));
|
||||||
|
save_overrides(&m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── save flow ───────────────────────────────────
|
||||||
|
|
||||||
|
/// Build the ordered list of `(candidate, checked)` for the save flow, in the
|
||||||
|
/// CURRENT sort order, each group emitting `[primary, ...alternates]`.
|
||||||
|
pub struct SavePayload {
|
||||||
|
pub name: String,
|
||||||
|
pub artist_id: String,
|
||||||
|
/// Checked candidates, in save order.
|
||||||
|
pub items: Vec<Candidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current selection into a `SavePayload` (UI thread).
|
||||||
|
pub fn save_payload(window: &AppWindow) -> Option<SavePayload> {
|
||||||
|
let s = session();
|
||||||
|
let name_raw = window.global::<DiscoBuilderState>().get_collection_name().to_string();
|
||||||
|
let name = {
|
||||||
|
let t = name_raw.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
"Artist Collection".to_string()
|
||||||
|
} else {
|
||||||
|
t.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let groups = ordered_groups(&s.groups, &s.order_by);
|
||||||
|
let mut items: Vec<Candidate> = Vec::new();
|
||||||
|
for g in &groups {
|
||||||
|
let mut ordered = vec![g.primary.clone()];
|
||||||
|
ordered.extend(g.alternates.iter().cloned());
|
||||||
|
for c in ordered {
|
||||||
|
if is_checked(&s, &c.group_key, &candidate_key(&c)) {
|
||||||
|
items.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(SavePayload {
|
||||||
|
name,
|
||||||
|
artist_id: s.artist_id.clone(),
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the artist_collection + bulk-add the checked candidates. Blocking
|
||||||
|
/// (DB). Returns the new collection id on success. Plex candidates are stored
|
||||||
|
/// as `source='local'` (resolver re-detects at enqueue). Mirrors `handleCreate`.
|
||||||
|
pub fn create_collection(payload: &SavePayload) -> Option<String> {
|
||||||
|
crate::library_db::with_db(|db| {
|
||||||
|
Ok(db.with_connection(|conn| {
|
||||||
|
let col = qbz_mixtape::repo::create_collection(
|
||||||
|
conn,
|
||||||
|
CollectionKind::ArtistCollection,
|
||||||
|
&payload.name,
|
||||||
|
None,
|
||||||
|
CollectionSourceType::ArtistDiscography,
|
||||||
|
Some(&payload.artist_id),
|
||||||
|
)?;
|
||||||
|
for c in &payload.items {
|
||||||
|
// Source collapse on save: qobuz -> Qobuz; local OR plex ->
|
||||||
|
// Local (the LocalLibrary resolver re-detects Plex at enqueue).
|
||||||
|
let src = if c.source == "qobuz" {
|
||||||
|
qbz_models::mixtape::AlbumSource::Qobuz
|
||||||
|
} else {
|
||||||
|
qbz_models::mixtape::AlbumSource::Local
|
||||||
|
};
|
||||||
|
if let Err(e) = qbz_mixtape::repo::add_item(
|
||||||
|
conn,
|
||||||
|
&col.id,
|
||||||
|
qbz_models::mixtape::ItemType::Album,
|
||||||
|
src,
|
||||||
|
&c.source_item_id,
|
||||||
|
&c.title,
|
||||||
|
Some(c.artist.as_str()),
|
||||||
|
c.artwork_url.as_deref(),
|
||||||
|
c.year,
|
||||||
|
c.track_count,
|
||||||
|
) {
|
||||||
|
log::warn!("[qbz-slint] disco builder add_item failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<String, rusqlite::Error>(col.id)
|
||||||
|
})
|
||||||
|
.map_err(|e| {
|
||||||
|
qbz_library::LibraryError::Database(format!("disco builder create failed: {e}"))
|
||||||
|
})?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the creating flag (UI thread).
|
||||||
|
pub fn set_creating(window: &AppWindow, creating: bool) {
|
||||||
|
window.global::<DiscoBuilderState>().set_creating(creating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Success toast (spec note: the PSD keys are missing; use a real string).
|
||||||
|
pub fn toast_created(window: &AppWindow) {
|
||||||
|
crate::toast::show(window, "Artist Collection created", ToastKind::Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure toast.
|
||||||
|
pub fn toast_failed(window: &AppWindow) {
|
||||||
|
crate::toast::show(window, "Failed to create collection", ToastKind::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the decoded artist avatar to the state. UI thread.
|
||||||
|
pub fn apply_avatar(window: &AppWindow, pixels: &[u8], width: u32, height: u32) {
|
||||||
|
let mut buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||||||
|
let dst = buffer.make_mut_bytes();
|
||||||
|
if dst.len() != pixels.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dst.copy_from_slice(pixels);
|
||||||
|
window
|
||||||
|
.global::<DiscoBuilderState>()
|
||||||
|
.set_artist_avatar(slint::Image::from_rgba8(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -72,6 +72,13 @@ pub enum NavEntry {
|
|||||||
/// view-mode) is session-scoped in the controller, so the entry carries
|
/// view-mode) is session-scoped in the controller, so the entry carries
|
||||||
/// only the id.
|
/// only the id.
|
||||||
MixtapeDetail(String),
|
MixtapeDetail(String),
|
||||||
|
/// The Discography Builder full-page flow (spec 13). The string is the
|
||||||
|
/// artist id; the builder fetches that artist's releases from Qobuz +
|
||||||
|
/// local + Plex, dedupes into groups, and saves a `kind='artist_collection'`
|
||||||
|
/// collection. Mirrors `MixtapeDetail` / `Artist` — session-scoped builder
|
||||||
|
/// state (selection / order / name) lives in the controller, so the entry
|
||||||
|
/// carries only the id.
|
||||||
|
DiscographyBuilder(String),
|
||||||
Album(String),
|
Album(String),
|
||||||
/// A Local Library album detail page (dedicated view, separate from the
|
/// A Local Library album detail page (dedicated view, separate from the
|
||||||
/// Qobuz Album view); the string is the metadata group key.
|
/// Qobuz Album view); the string is the metadata group key.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
|||||||
import { Toast } from "primitives/Toast.slint";
|
import { Toast } from "primitives/Toast.slint";
|
||||||
|
|
||||||
// Re-export the state globals so the Rust layer can populate them.
|
// Re-export the state globals so the Rust layer can populate them.
|
||||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||||
|
|
||||||
// Which top-level screen is shown. The app starts on `splash` while it
|
// Which top-level screen is shown. The app starts on `splash` while it
|
||||||
// restores a saved session, then resolves to `shell` or `login`.
|
// restores a saved session, then resolves to `shell` or `login`.
|
||||||
|
|||||||
@@ -0,0 +1,886 @@
|
|||||||
|
// Discography Builder — the full-page flow that assembles an artist's
|
||||||
|
// complete discography into a saved `kind='artist_collection'` collection
|
||||||
|
// (spec 13). A 1:1 port of the Tauri DiscographyBuilderView.svelte.
|
||||||
|
//
|
||||||
|
// Two-part layout: `.builder-scroll` holds everything that scrolls; the
|
||||||
|
// footer is a fixed-height flex sibling pinned at the bottom (NOT
|
||||||
|
// sticky-inside-scroll) so last rows never peek past the footer.
|
||||||
|
//
|
||||||
|
// CSS variable values are resolved to the concrete dark-theme values from
|
||||||
|
// spec §0 (this view hardcodes them rather than going through the Theme
|
||||||
|
// global so the port stays pixel-exact to the quoted PSD).
|
||||||
|
|
||||||
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint";
|
||||||
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
||||||
|
|
||||||
|
// ── Concrete dark-theme values (spec §0) ──────────────────────────────────
|
||||||
|
// Resolved here as global consts so the whole view shares one source.
|
||||||
|
global D {
|
||||||
|
out property <color> bg-primary: #0f0f0f; // footer bg
|
||||||
|
out property <color> bg-secondary: #1a1a1a; // input/segment/menu/secondary-btn
|
||||||
|
out property <color> bg-tertiary: #2a2a2a; // avatar placeholder, hairlines
|
||||||
|
out property <color> bg-hover: #1f1f1f; // row/segment/menu-item hover
|
||||||
|
out property <color> text-primary: #ffffff;
|
||||||
|
out property <color> text-secondary: #cccccc;
|
||||||
|
out property <color> text-muted: #888888;
|
||||||
|
out property <color> accent: #4285F4;
|
||||||
|
out property <color> btn-primary-text: #ffffff;
|
||||||
|
out property <color> alpha-6: #ffffff0f; // rgba(255,255,255,0.06)
|
||||||
|
out property <color> error: #e57373;
|
||||||
|
// Type-label tints (hardcoded non-var colors, spec §0).
|
||||||
|
out property <color> tint-compilation: #f59e0b; // amber
|
||||||
|
out property <color> tint-live: #e91e63; // pink
|
||||||
|
out property <color> tint-ep: #60a5fa; // blue
|
||||||
|
out property <color> tint-single: #a78bfa; // violet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a release-type to its tint (plain "album" = muted, no tint).
|
||||||
|
component TypeColor {
|
||||||
|
in property <string> kind;
|
||||||
|
out property <color> color:
|
||||||
|
root.kind == "compilation" ? D.tint-compilation
|
||||||
|
: root.kind == "live" ? D.tint-live
|
||||||
|
: root.kind == "ep" ? D.tint-ep
|
||||||
|
: root.kind == "single" ? D.tint-single
|
||||||
|
: D.text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source indicator (Qobuz / Plex / Local accent-tinted silhouette) ──────
|
||||||
|
component SourceIndicator inherits Rectangle {
|
||||||
|
in property <string> source;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
opacity: 0.9;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
if root.source == "plex": QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/plex-logo.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: D.accent;
|
||||||
|
}
|
||||||
|
if root.source == "qobuz": QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/qobuz-logo-filled.svg");
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
tint: D.accent;
|
||||||
|
}
|
||||||
|
if root.source != "plex" && root.source != "qobuz": QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/hard-drive.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: D.accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-row release-type override button + popover (spec §7.1) ─────────────
|
||||||
|
component TypeOverride inherits Rectangle {
|
||||||
|
in property <DiscoCandidate> candidate;
|
||||||
|
// 11px primary / 10px alternate.
|
||||||
|
in property <length> font-size: 11px;
|
||||||
|
|
||||||
|
property <string> menu-key: root.candidate.source + "|" + root.candidate.source-item-id;
|
||||||
|
property <bool> menu-open: DiscoBuilderState.open-type-menu-key == root.menu-key;
|
||||||
|
|
||||||
|
// The wrapper anchors the absolute popover; it sizes to the button.
|
||||||
|
width: type-btn.preferred-width;
|
||||||
|
height: type-btn.preferred-height;
|
||||||
|
|
||||||
|
type-btn := Rectangle {
|
||||||
|
// .type-btn negative margin (0 -4px) is replicated by a -4px touch
|
||||||
|
// bleed; the visual glyph stays put.
|
||||||
|
background: btn-ta.has-hover ? D.alpha-6 : transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
label := Text {
|
||||||
|
text: root.candidate.release-type;
|
||||||
|
color: type-color.color;
|
||||||
|
font-size: root.font-size;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type-color := TypeColor { kind: root.candidate.release-type; }
|
||||||
|
// Dotted-underline marker for an active override (Slint has no
|
||||||
|
// text-decoration; draw a 1px dotted rule under the label).
|
||||||
|
if root.candidate.is-overridden: Rectangle {
|
||||||
|
x: label.x;
|
||||||
|
y: parent.height - 3px;
|
||||||
|
width: label.width;
|
||||||
|
height: 1px;
|
||||||
|
background: type-color.color;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
btn-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderState.open-type-menu-key =
|
||||||
|
root.menu-open ? "" : root.menu-key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdrop + menu (rendered above siblings; z-order is paint order).
|
||||||
|
if root.menu-open: Rectangle {
|
||||||
|
// Backdrop click-catcher — full-row local catch; closes the menu.
|
||||||
|
// (A true full-viewport backdrop isn't expressible here without a
|
||||||
|
// global overlay; the menu's close-on-outside is handled by the
|
||||||
|
// backdrop TouchArea plus re-clicking the button.)
|
||||||
|
TouchArea {
|
||||||
|
x: -2000px;
|
||||||
|
y: -2000px;
|
||||||
|
width: 4000px;
|
||||||
|
height: 4000px;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderState.open-type-menu-key = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu := Rectangle {
|
||||||
|
x: 0;
|
||||||
|
y: parent.height + 4px;
|
||||||
|
width: 160px;
|
||||||
|
background: D.bg-secondary;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: D.bg-tertiary;
|
||||||
|
border-radius: 6px;
|
||||||
|
drop-shadow-blur: 12px;
|
||||||
|
drop-shadow-color: #00000059; // rgba(0,0,0,0.35)
|
||||||
|
drop-shadow-offset-y: 4px;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 4px;
|
||||||
|
// Album, EP, Single, Live, Compilation (spec order).
|
||||||
|
for choice in [
|
||||||
|
{ id: "album", lbl: "Album" },
|
||||||
|
{ id: "ep", lbl: "EP" },
|
||||||
|
{ id: "single", lbl: "Single" },
|
||||||
|
{ id: "live", lbl: "Live" },
|
||||||
|
{ id: "compilation", lbl: "Compilation" },
|
||||||
|
]: Rectangle {
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: item-ta.has-hover ? D.bg-hover : transparent;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
spacing: 8px;
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
// Check glyph if this is the selected type; else a
|
||||||
|
// 12px placeholder so labels stay aligned.
|
||||||
|
if root.candidate.release-type == choice.id: QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/check.svg");
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
tint: D.accent;
|
||||||
|
}
|
||||||
|
if root.candidate.release-type != choice.id: Rectangle {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: choice.lbl;
|
||||||
|
color: root.candidate.release-type == choice.id
|
||||||
|
? D.accent : D.text-primary;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.set-type-override(
|
||||||
|
root.candidate.source,
|
||||||
|
root.candidate.source-item-id,
|
||||||
|
choice.id);
|
||||||
|
DiscoBuilderState.open-type-menu-key = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset row — only when an override is active.
|
||||||
|
if root.candidate.is-overridden: Rectangle {
|
||||||
|
height: 9px;
|
||||||
|
Rectangle {
|
||||||
|
y: 4px;
|
||||||
|
height: 1px;
|
||||||
|
background: D.bg-tertiary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.candidate.is-overridden: Rectangle {
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: reset-ta.has-hover ? D.bg-hover : transparent;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
spacing: 8px;
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/rotate-ccw.svg");
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
tint: D.text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Use auto-detected");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reset-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.reset-type-override(
|
||||||
|
root.candidate.source,
|
||||||
|
root.candidate.source-item-id);
|
||||||
|
DiscoBuilderState.open-type-menu-key = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7-column row grid — the alignment backbone (spec §2.9):
|
||||||
|
// grid-template-columns: 28px 56px 72px minmax(0,1fr) 72px 60px 156px, gap 10px.
|
||||||
|
// Each row below inlines this template (header/primary/alternate share it).
|
||||||
|
//
|
||||||
|
// One candidate row (primary or alternate). `alt` dims it + swaps the col[0]
|
||||||
|
// checkbox for a connector + checkbox and shrinks/mutes the text.
|
||||||
|
component CandidateRow inherits Rectangle {
|
||||||
|
in property <DiscoCandidate> candidate;
|
||||||
|
in property <string> group-key;
|
||||||
|
in property <bool> alt: false;
|
||||||
|
// Whether the OWNING group is a compilation — drives the primary row's
|
||||||
|
// "compilation" tag (spec §2.10: tag is `{#if grp.is_compilation}`, the
|
||||||
|
// group flag, not the candidate's effective type).
|
||||||
|
in property <bool> group-is-compilation: false;
|
||||||
|
// Persistent dim (compilation primary 0.65 / alternate 0.72); lifts to 1
|
||||||
|
// on hover. The parent passes the resting opacity.
|
||||||
|
in property <float> rest-opacity: 1.0;
|
||||||
|
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: row-ta.has-hover ? D.bg-hover : transparent;
|
||||||
|
opacity: row-ta.has-hover ? 1.0 : root.rest-opacity;
|
||||||
|
|
||||||
|
row-ta := TouchArea { mouse-cursor: default; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
spacing: 10px;
|
||||||
|
// [0] 28px — checkbox cell (alt: connector + checkbox).
|
||||||
|
Rectangle {
|
||||||
|
width: 28px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: start;
|
||||||
|
spacing: 3px;
|
||||||
|
if root.alt: Text {
|
||||||
|
text: "↳";
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: root.candidate.checked ? D.accent : transparent;
|
||||||
|
border-width: root.candidate.checked ? 0px : 1.5px;
|
||||||
|
border-color: D.text-muted;
|
||||||
|
if root.candidate.checked: QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/check.svg");
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: D.btn-primary-text;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.toggle-checked(
|
||||||
|
root.group-key, root.candidate.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [1] 56px — year.
|
||||||
|
Rectangle {
|
||||||
|
width: 56px;
|
||||||
|
Text {
|
||||||
|
text: root.candidate.year;
|
||||||
|
color: root.alt ? D.text-muted : D.text-secondary;
|
||||||
|
font-size: root.alt ? 12px : 13px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [2] 72px — type override button + popover.
|
||||||
|
Rectangle {
|
||||||
|
width: 72px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
TypeOverride {
|
||||||
|
candidate: root.candidate;
|
||||||
|
font-size: root.alt ? 10px : 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [3] 1fr — title (flexes) + tag.
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: start;
|
||||||
|
spacing: 8px;
|
||||||
|
title-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.open-album(
|
||||||
|
root.candidate.source, root.candidate.source-item-id);
|
||||||
|
}
|
||||||
|
title := Text {
|
||||||
|
text: root.candidate.title;
|
||||||
|
color: title-ta.has-hover
|
||||||
|
? D.accent
|
||||||
|
: (root.alt ? D.text-muted : D.text-primary);
|
||||||
|
font-size: root.alt ? 13px : 14px;
|
||||||
|
overflow: elide;
|
||||||
|
vertical-alignment: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// alternates always show the "alternate" tag; primaries show
|
||||||
|
// "compilation" only when the group is a compilation (passed
|
||||||
|
// by the parent baking it into the candidate's title? No —
|
||||||
|
// the parent sets `tag` separately). Here we infer from the
|
||||||
|
// row kind + the candidate's own type.
|
||||||
|
if root.alt: Text {
|
||||||
|
text: @tr("alternate");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-italic: true;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
if !root.alt && root.group-is-compilation: Text {
|
||||||
|
text: @tr("compilation");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-italic: true;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [4] 72px — tracks (centered, tabular).
|
||||||
|
Rectangle {
|
||||||
|
width: 72px;
|
||||||
|
Text {
|
||||||
|
text: root.candidate.tracks;
|
||||||
|
color: root.alt ? D.text-muted : D.text-secondary;
|
||||||
|
font-size: root.alt ? 12px : 13px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [5] 60px — source indicator.
|
||||||
|
Rectangle {
|
||||||
|
width: 60px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
SourceIndicator { source: root.candidate.source; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [6] 156px — quality badge.
|
||||||
|
Rectangle {
|
||||||
|
width: 156px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QualityBadgeFull {
|
||||||
|
tier: root.candidate.quality-tier;
|
||||||
|
detail: root.candidate.quality-detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component DiscographyBuilderView inherits Rectangle {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
// ── .builder-view (flex column, full size) ──
|
||||||
|
VerticalLayout {
|
||||||
|
// ── .builder-scroll (scrolls everything) ──
|
||||||
|
scroll := Flickable {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
viewport-width: self.width;
|
||||||
|
viewport-height: content.preferred-height;
|
||||||
|
|
||||||
|
content := VerticalLayout {
|
||||||
|
width: scroll.width;
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
spacing: 0px;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
// ── .back-btn ──
|
||||||
|
Rectangle {
|
||||||
|
height: 20px;
|
||||||
|
horizontal-stretch: 0;
|
||||||
|
back-ta := TouchArea {
|
||||||
|
width: back-row.preferred-width;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.back();
|
||||||
|
}
|
||||||
|
back-row := HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/arrow-left.svg");
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
tint: back-ta.has-hover ? D.text-secondary : D.text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Back");
|
||||||
|
color: back-ta.has-hover ? D.text-secondary : D.text-muted;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: 24px; }
|
||||||
|
|
||||||
|
// ── header.builder-header ──
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 20px;
|
||||||
|
alignment: start;
|
||||||
|
// Avatar (img or placeholder) — 72px circle.
|
||||||
|
Rectangle {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 36px;
|
||||||
|
background: D.bg-tertiary;
|
||||||
|
clip: true;
|
||||||
|
if DiscoBuilderState.artist-avatar-url != "": Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: DiscoBuilderState.artist-avatar;
|
||||||
|
image-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Header text — eyebrow + title.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 4px;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: @tr("Build Artist Collection");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
// uppercase via the literal (en already caps the eyebrow).
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: DiscoBuilderState.artist-name == ""
|
||||||
|
? "—" : DiscoBuilderState.artist-name;
|
||||||
|
color: D.text-primary;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: 24px; }
|
||||||
|
|
||||||
|
// ── label.field (collection-name input) ──
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
alignment: start;
|
||||||
|
Text {
|
||||||
|
text: @tr("NAME");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 540px;
|
||||||
|
height: 40px;
|
||||||
|
background: D.bg-secondary;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: name-input.has-focus ? D.accent : D.bg-tertiary;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
name-input := TextInput {
|
||||||
|
color: D.text-primary;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
single-line: true;
|
||||||
|
text: DiscoBuilderState.collection-name;
|
||||||
|
edited => {
|
||||||
|
DiscoBuilderActions.name-changed(self.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: 16px; }
|
||||||
|
|
||||||
|
// ── .order-row (segmented control) ──
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: @tr("Order by");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// .segmented — 8px corners, NO pills.
|
||||||
|
Rectangle {
|
||||||
|
width: seg.preferred-width;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: D.bg-tertiary;
|
||||||
|
clip: true;
|
||||||
|
seg := HorizontalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
for s[i] in [
|
||||||
|
{ id: "release_date", lbl: "Release date" },
|
||||||
|
{ id: "title", lbl: "Title" },
|
||||||
|
{ id: "manual", lbl: "Manual" },
|
||||||
|
]: seg-rect := Rectangle {
|
||||||
|
property <bool> active: DiscoBuilderState.order-by == s.id;
|
||||||
|
background: seg-rect.active
|
||||||
|
? D.accent
|
||||||
|
: (seg-ta.has-hover ? D.bg-hover : D.bg-secondary);
|
||||||
|
// Divider between segments.
|
||||||
|
if i > 0: Rectangle {
|
||||||
|
x: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background: D.bg-tertiary;
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
padding-top: 7px;
|
||||||
|
padding-bottom: 7px;
|
||||||
|
Text {
|
||||||
|
text: s.lbl;
|
||||||
|
color: seg-rect.active ? D.btn-primary-text
|
||||||
|
: (seg-ta.has-hover ? D.text-primary : D.text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seg-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.set-order(s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: 20px; }
|
||||||
|
|
||||||
|
// ── {state branch} ──
|
||||||
|
// loading
|
||||||
|
if DiscoBuilderState.loading: Rectangle {
|
||||||
|
height: 96px;
|
||||||
|
Text {
|
||||||
|
text: @tr("Loading...");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 14px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// error
|
||||||
|
if !DiscoBuilderState.loading && DiscoBuilderState.load-error != "": Rectangle {
|
||||||
|
height: 96px;
|
||||||
|
Text {
|
||||||
|
text: DiscoBuilderState.load-error;
|
||||||
|
color: D.error;
|
||||||
|
font-size: 14px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// empty
|
||||||
|
if !DiscoBuilderState.loading
|
||||||
|
&& DiscoBuilderState.load-error == ""
|
||||||
|
&& DiscoBuilderState.groups.length == 0: Rectangle {
|
||||||
|
height: 96px;
|
||||||
|
Text {
|
||||||
|
text: @tr("No results found");
|
||||||
|
color: D.text-muted;
|
||||||
|
font-size: 14px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// populated — .groups table
|
||||||
|
if !DiscoBuilderState.loading
|
||||||
|
&& DiscoBuilderState.load-error == ""
|
||||||
|
&& DiscoBuilderState.groups.length > 0: VerticalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
// border-top + 4px pad-top.
|
||||||
|
Rectangle { height: 1px; background: D.bg-tertiary; }
|
||||||
|
Rectangle { height: 4px; }
|
||||||
|
|
||||||
|
// ── header row (.row.header-row) ──
|
||||||
|
Rectangle {
|
||||||
|
height: 36px;
|
||||||
|
Rectangle {
|
||||||
|
y: parent.height - 1px;
|
||||||
|
height: 1px;
|
||||||
|
background: D.bg-tertiary;
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
spacing: 10px;
|
||||||
|
// [0] 28px — select-all checkbox (tri-state).
|
||||||
|
Rectangle {
|
||||||
|
width: 28px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: start;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: DiscoBuilderState.all-checked
|
||||||
|
? D.accent : transparent;
|
||||||
|
border-width: DiscoBuilderState.all-checked ? 0px : 1.5px;
|
||||||
|
border-color: D.text-muted;
|
||||||
|
if DiscoBuilderState.all-checked: QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/check.svg");
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: D.btn-primary-text;
|
||||||
|
}
|
||||||
|
// Indeterminate dash.
|
||||||
|
if !DiscoBuilderState.all-checked
|
||||||
|
&& DiscoBuilderState.some-checked: Rectangle {
|
||||||
|
width: 9px;
|
||||||
|
height: 2px;
|
||||||
|
background: D.accent;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.toggle-all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [1..6] column labels.
|
||||||
|
Rectangle {
|
||||||
|
width: 56px;
|
||||||
|
Text { text: @tr("Year"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 72px;
|
||||||
|
Text { text: @tr("Type"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text { text: @tr("Album"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 72px;
|
||||||
|
Text { text: @tr("Tracks"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 60px;
|
||||||
|
Text { text: @tr("Source"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 156px;
|
||||||
|
Text { text: @tr("Quality"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per group ──
|
||||||
|
for grp in DiscoBuilderState.groups: VerticalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
// primary row (dimmed when the group is a compilation).
|
||||||
|
CandidateRow {
|
||||||
|
candidate: grp.primary;
|
||||||
|
group-key: grp.key;
|
||||||
|
alt: false;
|
||||||
|
group-is-compilation: grp.is-compilation;
|
||||||
|
rest-opacity: grp.is-compilation ? 0.65 : 1.0;
|
||||||
|
}
|
||||||
|
// alternate rows.
|
||||||
|
for alt in grp.alternates: CandidateRow {
|
||||||
|
candidate: alt;
|
||||||
|
group-key: grp.key;
|
||||||
|
alt: true;
|
||||||
|
rest-opacity: 0.72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── footer.builder-footer (fixed-height flex sibling) ──
|
||||||
|
Rectangle {
|
||||||
|
vertical-stretch: 0;
|
||||||
|
height: 60px;
|
||||||
|
background: D.bg-primary;
|
||||||
|
Rectangle {
|
||||||
|
y: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: D.bg-tertiary;
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: 88px; // asymmetric — clears the back-to-top FAB.
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
spacing: 16px;
|
||||||
|
// .footer-count.
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: DiscoBuilderState.selected-count + " of "
|
||||||
|
+ DiscoBuilderState.group-count + " albums selected";
|
||||||
|
color: D.text-secondary;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// .footer-actions.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
alignment: end;
|
||||||
|
// Cancel (secondary-btn).
|
||||||
|
Rectangle {
|
||||||
|
width: cancel-row.preferred-width + 32px;
|
||||||
|
height: 36px;
|
||||||
|
background: cancel-ta.has-hover ? D.bg-hover : D.bg-secondary;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: D.bg-tertiary;
|
||||||
|
cancel-row := HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: @tr("Cancel");
|
||||||
|
color: D.text-primary;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
DiscoBuilderActions.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create (primary-btn).
|
||||||
|
create-btn := Rectangle {
|
||||||
|
property <bool> disabled:
|
||||||
|
DiscoBuilderState.creating
|
||||||
|
|| DiscoBuilderState.selected-count == 0
|
||||||
|
|| DiscoBuilderState.collection-name == "";
|
||||||
|
width: create-row.preferred-width + 40px;
|
||||||
|
height: 36px;
|
||||||
|
background: D.accent;
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: create-btn.disabled ? 0.45 : 1.0;
|
||||||
|
create-row := HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: DiscoBuilderState.creating
|
||||||
|
? @tr("Loading...") : @tr("Create Collection");
|
||||||
|
color: D.btn-primary-text;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
mouse-cursor: create-btn.disabled ? default : pointer;
|
||||||
|
clicked => {
|
||||||
|
if (!create-btn.disabled) {
|
||||||
|
DiscoBuilderActions.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
|
|||||||
import { MixtapesView } from "../myqbz/MixtapesView.slint";
|
import { MixtapesView } from "../myqbz/MixtapesView.slint";
|
||||||
import { CollectionsView } from "../myqbz/CollectionsView.slint";
|
import { CollectionsView } from "../myqbz/CollectionsView.slint";
|
||||||
import { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint";
|
import { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint";
|
||||||
|
import { DiscographyBuilderView } from "../myqbz/DiscographyBuilderView.slint";
|
||||||
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
|
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
|
||||||
import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint";
|
import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint";
|
||||||
import { SettingsView } from "../settings/SettingsView.slint";
|
import { SettingsView } from "../settings/SettingsView.slint";
|
||||||
@@ -239,6 +240,7 @@ export component AppShell inherits Rectangle {
|
|||||||
if NavState.view == ContentView.mixtapes: MixtapesView { }
|
if NavState.view == ContentView.mixtapes: MixtapesView { }
|
||||||
if NavState.view == ContentView.collections: CollectionsView { }
|
if NavState.view == ContentView.collections: CollectionsView { }
|
||||||
if NavState.view == ContentView.mixtape-detail: MixtapeDetailView { }
|
if NavState.view == ContentView.mixtape-detail: MixtapeDetailView { }
|
||||||
|
if NavState.view == ContentView.discography-builder: DiscographyBuilderView { }
|
||||||
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
|
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
|
||||||
if NavState.view == ContentView.local-library: LocalLibraryView { }
|
if NavState.view == ContentView.local-library: LocalLibraryView { }
|
||||||
if NavState.view == ContentView.mix: MixView {
|
if NavState.view == ContentView.mix: MixView {
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export global DragActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Which content view the shell shows.
|
// Which content view the shell shows.
|
||||||
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album, mixtapes, collections, mixtape-detail }
|
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder }
|
||||||
|
|
||||||
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
||||||
// a single toast at a time (a new one replaces the current), auto-hidden
|
// a single toast at a time (a new one replaces the current), auto-hidden
|
||||||
@@ -1703,6 +1703,115 @@ export global MyQbzDetailActions {
|
|||||||
callback bulk-action(string /* id */);
|
callback bulk-action(string /* id */);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────── Discography Builder (spec 13) ───────────────────
|
||||||
|
// The full-page flow that assembles an artist's complete discography into a
|
||||||
|
// saved `kind='artist_collection'` collection. It fetches every release for
|
||||||
|
// the artist from three sources (Qobuz artist page + local library + Plex
|
||||||
|
// cache), dedupes them into GROUPS (one logical album = a chosen "primary" +
|
||||||
|
// "alternates"), lets the user check which to include, reorder (by sort),
|
||||||
|
// rename, and correct release-type mislabels, then persists the result.
|
||||||
|
//
|
||||||
|
// One candidate row (a primary OR an alternate). Every display string is
|
||||||
|
// precomputed in Rust (crate::myqbz_builder) so the view does no lookups.
|
||||||
|
export struct DiscoCandidate {
|
||||||
|
// `${source}|${source_item_id}` — the per-candidate selection key.
|
||||||
|
key: string,
|
||||||
|
// "qobuz" | "local" | "plex" — drives the source indicator icon.
|
||||||
|
source: string,
|
||||||
|
source-item-id: string,
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
// "2019" or "—" (em-dash placeholder).
|
||||||
|
year: string,
|
||||||
|
// "12" or "—".
|
||||||
|
tracks: string,
|
||||||
|
artwork-url: string,
|
||||||
|
// Effective release type AFTER the user's override is applied:
|
||||||
|
// "album" | "ep" | "single" | "live" | "compilation".
|
||||||
|
release-type: string,
|
||||||
|
// True when a user override is active for this candidate (drives the
|
||||||
|
// dotted-underline marker on the type button).
|
||||||
|
is-overridden: bool,
|
||||||
|
// QualityBadgeFull inputs.
|
||||||
|
quality-tier: string,
|
||||||
|
quality-detail: string,
|
||||||
|
// Whether THIS candidate is currently checked (included on save).
|
||||||
|
checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// One dedupe GROUP: a primary candidate plus zero or more alternates.
|
||||||
|
export struct DiscoGroup {
|
||||||
|
// group_key (`${normalizedTitle}|${year}`) — stable row key.
|
||||||
|
key: string,
|
||||||
|
title: string,
|
||||||
|
// "2019" / "—".
|
||||||
|
year: string,
|
||||||
|
primary: DiscoCandidate,
|
||||||
|
alternates: [DiscoCandidate],
|
||||||
|
// ALL candidates are compilations → the group is treated as a comp
|
||||||
|
// (dimmed primary row, ignored by select-all).
|
||||||
|
is-compilation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
export global DiscoBuilderState {
|
||||||
|
// The artist being built (id + resolved display name + portrait URL).
|
||||||
|
in property <string> artist-id;
|
||||||
|
in property <string> artist-name;
|
||||||
|
in property <string> artist-avatar-url;
|
||||||
|
in property <image> artist-avatar;
|
||||||
|
|
||||||
|
// Render states (spec 13 §4). `loading` true initially + during load;
|
||||||
|
// `load-error` non-empty shows the raw error message; otherwise the
|
||||||
|
// groups table renders (empty groups -> "No results found").
|
||||||
|
in property <bool> loading: true;
|
||||||
|
in property <string> load-error: "";
|
||||||
|
|
||||||
|
// The dedupe groups in CURRENT sort order (Rust re-sorts on order change).
|
||||||
|
in property <[DiscoGroup]> groups: [];
|
||||||
|
|
||||||
|
// The collection-name input (defaulted to "<artist> — Complete Discography").
|
||||||
|
in-out property <string> collection-name: "";
|
||||||
|
|
||||||
|
// Order segmented control: "release_date" | "title" | "manual".
|
||||||
|
in-out property <string> order-by: "release_date";
|
||||||
|
|
||||||
|
// Select-all header checkbox state (tri-state via the two flags).
|
||||||
|
in property <bool> all-checked: false;
|
||||||
|
in property <bool> some-checked: false;
|
||||||
|
|
||||||
|
// Footer: total checked candidate count + total group count.
|
||||||
|
in property <int> selected-count: 0;
|
||||||
|
in property <int> group-count: 0;
|
||||||
|
|
||||||
|
// Disables the Create button while a save is in flight.
|
||||||
|
in property <bool> creating: false;
|
||||||
|
|
||||||
|
// The per-row type-override popover open key (`${source}|${source_item_id}`),
|
||||||
|
// or "" when none is open. One menu open at a time.
|
||||||
|
in-out property <string> open-type-menu-key: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export global DiscoBuilderActions {
|
||||||
|
// Back to the artist page (clears builder state).
|
||||||
|
callback back();
|
||||||
|
// Collection-name input edited.
|
||||||
|
callback name-changed(string);
|
||||||
|
// Order segmented control: "release_date" | "title" | "manual".
|
||||||
|
callback set-order(string);
|
||||||
|
// Toggle one candidate's checked state (group-key + candidate-key).
|
||||||
|
callback toggle-checked(string /* group-key */, string /* candidate-key */);
|
||||||
|
// Header tri-state select-all toggle (non-compilation primaries only).
|
||||||
|
callback toggle-all();
|
||||||
|
// Open an album (Qobuz album view, or local/Plex local-album view).
|
||||||
|
callback open-album(string /* source */, string /* source-item-id */);
|
||||||
|
// Apply a release-type override to a candidate (writes the sidecar).
|
||||||
|
callback set-type-override(string /* source */, string /* source-item-id */, string /* type */);
|
||||||
|
// Clear a candidate's override (revert to auto-detected).
|
||||||
|
callback reset-type-override(string /* source */, string /* source-item-id */);
|
||||||
|
// Save: create the artist_collection + bulk-add the checked groups.
|
||||||
|
callback create();
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────── DJ-mix modal ────────────────────────────
|
// ──────────────────────────── DJ-mix modal ────────────────────────────
|
||||||
// The "Random queue" sampler (TrackMixModal, spec 21 §C / spec 12 §12).
|
// The "Random queue" sampler (TrackMixModal, spec 21 §C / spec 12 §12).
|
||||||
// Opened from the hero DJ-mix CTA. On open the Rust controller resolves the
|
// Opened from the hero DJ-mix CTA. On open the Rust controller resolves the
|
||||||
|
|||||||
Reference in new issue
Block a user