merge: Most Played Albums (feature/most-played-albums)
Play-count album history store (album_play_history, WAL per ADR-002) hooked into record_recent, Most Played Albums carousel on Home + For You (default off), View-all page with search + per-card play count, i18n in all 8 locales. Conflicts resolved against pre-release: kept the Slint-era Pinned section alongside the new MostPlayedAlbums everywhere (DiscoverySectionId, prefs, render kinds, nav, ContentView, HomeActions); AlbumCardItem carries both is_pinned and plays (populated via card_to_item); .po files union-merged, msgfmt clean; discover_prefs tests updated (home 15/8 ON, forYou 14/13 ON) — 10/10 green. Release build green. km
This commit is contained in:
29 files changed
+950
-18
No files matched your search
@@ -0,0 +1,332 @@
|
||||
//! Local album play history — source of truth for the "Most Played Albums"
|
||||
//! rail + its View-all page.
|
||||
//!
|
||||
//! Mirrors [`crate::play_history`] (the per-artist store): an
|
||||
//! `album_play_events` table grows one row per track-start whose album is
|
||||
//! known, plus a side `album_meta` table (id -> title/artist/artwork/quality)
|
||||
//! refreshed on each play. "Most played" = `COUNT(*) GROUP BY album_id
|
||||
//! ORDER BY plays DESC`. Counting is PER TRACK-START, like play_history, so an
|
||||
//! album listened all the way through adds one per track.
|
||||
//!
|
||||
//! The Qobuz API exposes no most-played endpoint (verified against the
|
||||
//! inferred OpenAPI), so the ranking is derived locally from our own plays.
|
||||
//!
|
||||
//! SQLite is opened lazily; every read/write swallows errors into a
|
||||
//! `log::warn!`. A fresh user (no DB yet) yields an empty list, so the rail
|
||||
//! self-hides — same default the other #566 rails land on.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
static DB: OnceLock<Mutex<Option<Connection>>> = OnceLock::new();
|
||||
|
||||
fn db_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("album_play_history.db"))
|
||||
}
|
||||
|
||||
/// Create the tables + index on a fresh connection (shared by the lazy opener
|
||||
/// and the in-memory test connections).
|
||||
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS album_play_events (
|
||||
album_id TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS album_play_events_album
|
||||
ON album_play_events(album_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS album_meta (
|
||||
album_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
artist_id TEXT NOT NULL DEFAULT '',
|
||||
artwork_url TEXT NOT NULL DEFAULT '',
|
||||
quality_tier TEXT NOT NULL DEFAULT '',
|
||||
quality_label TEXT NOT NULL DEFAULT '',
|
||||
year TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn open_db() -> Option<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("[qbz-slint] album_play_history dir create failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let conn = match Connection::open(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] album_play_history open failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// ADR-002: WAL for any SQLite store touched off the UI thread.
|
||||
if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") {
|
||||
log::warn!("[qbz-slint] album_play_history pragma failed: {e}");
|
||||
}
|
||||
if let Err(e) = init_schema(&conn) {
|
||||
log::warn!("[qbz-slint] album_play_history schema failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(conn)
|
||||
}
|
||||
|
||||
fn with_db<F, T>(f: F) -> Option<T>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Option<T>,
|
||||
{
|
||||
let cell = DB.get_or_init(|| Mutex::new(open_db()));
|
||||
let guard = cell.lock().ok()?;
|
||||
let conn = guard.as_ref()?;
|
||||
f(conn)
|
||||
}
|
||||
|
||||
/// Album metadata captured at play time (refreshed on every play, so renames
|
||||
/// and artwork refreshes converge).
|
||||
pub struct AlbumPlayMeta<'a> {
|
||||
pub album_id: &'a str,
|
||||
pub title: &'a str,
|
||||
pub artist: &'a str,
|
||||
pub artist_id: &'a str,
|
||||
pub artwork_url: &'a str,
|
||||
pub quality_tier: &'a str,
|
||||
pub quality_label: &'a str,
|
||||
pub year: &'a str,
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
/// One ranked album for the "Most Played Albums" rail / View-all page.
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
pub struct AlbumPlayRow {
|
||||
pub album_id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub artist_id: String,
|
||||
pub artwork_url: String,
|
||||
pub quality_tier: String,
|
||||
pub quality_label: String,
|
||||
pub year: String,
|
||||
pub source: String,
|
||||
pub plays: u32,
|
||||
}
|
||||
|
||||
/// Insert one play event + upsert the album meta, at an explicit timestamp.
|
||||
/// Internal so tests can drive it against an in-memory connection.
|
||||
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)",
|
||||
params![m.album_id, now],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history insert event failed: {e}");
|
||||
}
|
||||
if let Err(e) = conn.execute(
|
||||
r#"
|
||||
INSERT INTO album_meta
|
||||
(album_id, title, artist, artist_id, artwork_url,
|
||||
quality_tier, quality_label, year, source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(album_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
artist = excluded.artist,
|
||||
artist_id = excluded.artist_id,
|
||||
artwork_url = excluded.artwork_url,
|
||||
quality_tier = excluded.quality_tier,
|
||||
quality_label = excluded.quality_label,
|
||||
year = excluded.year,
|
||||
source = excluded.source,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
m.album_id,
|
||||
m.title,
|
||||
m.artist,
|
||||
m.artist_id,
|
||||
m.artwork_url,
|
||||
m.quality_tier,
|
||||
m.quality_label,
|
||||
m.year,
|
||||
m.source,
|
||||
now
|
||||
],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history upsert meta failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank albums by play count (desc), tie-broken by most-recent play so ties
|
||||
/// are stable and intuitive. `limit` caps the carousel; `None` = full list.
|
||||
fn query_on(conn: &Connection, limit: Option<u32>) -> Vec<AlbumPlayRow> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT m.album_id, m.title, m.artist, m.artist_id, m.artwork_url,
|
||||
m.quality_tier, m.quality_label, m.year, m.source, p.plays
|
||||
FROM album_meta m
|
||||
JOIN (
|
||||
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||
FROM album_play_events
|
||||
GROUP BY album_id
|
||||
) p ON p.album_id = m.album_id
|
||||
ORDER BY p.plays DESC, p.last_at DESC
|
||||
{}
|
||||
"#,
|
||||
limit.map(|n| format!("LIMIT {n}")).unwrap_or_default()
|
||||
);
|
||||
let out = (|| -> Option<Vec<AlbumPlayRow>> {
|
||||
let mut stmt = conn.prepare(&sql).ok()?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AlbumPlayRow {
|
||||
album_id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
artist: row.get(2)?,
|
||||
artist_id: row.get(3)?,
|
||||
artwork_url: row.get(4)?,
|
||||
quality_tier: row.get(5)?,
|
||||
quality_label: row.get(6)?,
|
||||
year: row.get(7)?,
|
||||
source: row.get(8)?,
|
||||
plays: row.get::<_, i64>(9)? as u32,
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
Some(rows.flatten().collect())
|
||||
})();
|
||||
out.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Record a play. Called from `playback::record_recent` when a track starts
|
||||
/// audible playback. No-op when the album id is empty (some local/Plex
|
||||
/// sources carry none — same guard as the recently-played rail).
|
||||
#[allow(dead_code)] // wired by playback::record_recent
|
||||
pub fn record_album_play(m: AlbumPlayMeta) {
|
||||
if m.album_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
with_db(|conn| {
|
||||
record_on(conn, &m, now);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// The top `limit` most-played albums (the carousel).
|
||||
#[allow(dead_code)] // wired by home/foryou
|
||||
pub fn top_albums(limit: u32) -> Vec<AlbumPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every played album, ranked (the "View all" page).
|
||||
#[allow(dead_code)] // wired by the View-all loader
|
||||
pub fn all_albums() -> Vec<AlbumPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, None))).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta<'a>(id: &'a str, title: &'a str) -> AlbumPlayMeta<'a> {
|
||||
AlbumPlayMeta {
|
||||
album_id: id,
|
||||
title,
|
||||
artist: "Artist",
|
||||
artist_id: "7",
|
||||
artwork_url: "http://art",
|
||||
quality_tier: "hires",
|
||||
quality_label: "Hi-Res",
|
||||
year: "2024",
|
||||
source: "qobuz",
|
||||
}
|
||||
}
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_by_play_count_desc() {
|
||||
let c = mem();
|
||||
// A: 12 plays, B: 20 plays, C: 36 plays (per-track-start counting).
|
||||
for i in 0..12 {
|
||||
record_on(&c, &meta("A", "Album A"), 100 + i);
|
||||
}
|
||||
for i in 0..20 {
|
||||
record_on(&c, &meta("B", "Album B"), 200 + i);
|
||||
}
|
||||
for i in 0..36 {
|
||||
record_on(&c, &meta("C", "Album C"), 300 + i);
|
||||
}
|
||||
let rows = query_on(&c, None);
|
||||
assert_eq!(
|
||||
rows.iter().map(|r| (r.album_id.as_str(), r.plays)).collect::<Vec<_>>(),
|
||||
vec![("C", 36), ("B", 20), ("A", 12)]
|
||||
);
|
||||
// Meta round-trips.
|
||||
assert_eq!(rows[0].title, "Album C");
|
||||
assert_eq!(rows[0].artist, "Artist");
|
||||
assert_eq!(rows[0].quality_tier, "hires");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_break_prefers_more_recent_play() {
|
||||
let c = mem();
|
||||
// Both albums have 2 plays; B's last play is later -> B leads.
|
||||
record_on(&c, &meta("A", "A"), 10);
|
||||
record_on(&c, &meta("A", "A"), 11);
|
||||
record_on(&c, &meta("B", "B"), 20);
|
||||
record_on(&c, &meta("B", "B"), 21);
|
||||
let rows = query_on(&c, None);
|
||||
assert_eq!(rows.iter().map(|r| r.album_id.clone()).collect::<Vec<_>>(), vec!["B", "A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_caps_the_carousel() {
|
||||
let c = mem();
|
||||
for n in 0..5 {
|
||||
let id = format!("id{n}");
|
||||
// n+1 plays so ordering is deterministic (id4 highest).
|
||||
for i in 0..=n {
|
||||
record_on(&c, &meta(&id, "t"), 100 + n as i64 * 10 + i as i64);
|
||||
}
|
||||
}
|
||||
let top = query_on(&c, Some(3));
|
||||
assert_eq!(top.len(), 3);
|
||||
assert_eq!(top.iter().map(|r| r.album_id.clone()).collect::<Vec<_>>(), vec!["id4", "id3", "id2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_empty() {
|
||||
let c = mem();
|
||||
assert!(query_on(&c, None).is_empty());
|
||||
assert!(query_on(&c, Some(20)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_upsert_refreshes_on_replay() {
|
||||
let c = mem();
|
||||
record_on(&c, &meta("A", "Old Title"), 1);
|
||||
let mut m2 = meta("A", "New Title");
|
||||
m2.artwork_url = "http://new-art";
|
||||
record_on(&c, &m2, 2);
|
||||
let rows = query_on(&c, None);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].title, "New Title");
|
||||
assert_eq!(rows[0].artwork_url, "http://new-art");
|
||||
assert_eq!(rows[0].plays, 2);
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,9 @@ impl DiscoveryTab {
|
||||
|
||||
/// The `DiscoverySectionId` union: the 19 Tauri members (`sectionPrefs.ts`)
|
||||
/// plus the Slint-era `Pinned` (user-pinned albums/artists/playlists — no
|
||||
/// Tauri counterpart). `editorPicks` is BOTH a tab and a section id (the
|
||||
/// "Albums of the Week" section).
|
||||
/// Tauri counterpart) and the local `MostPlayedAlbums` (top albums by local
|
||||
/// play count). `editorPicks` is BOTH a tab and a section id (the "Albums
|
||||
/// of the Week" section).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiscoverySectionId {
|
||||
NewReleases,
|
||||
@@ -84,6 +85,9 @@ pub enum DiscoverySectionId {
|
||||
ArtistsToFollow,
|
||||
ArtistSpotlight,
|
||||
Pinned,
|
||||
/// "Most Played Albums" — top albums by local play count
|
||||
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
|
||||
MostPlayedAlbums,
|
||||
}
|
||||
|
||||
impl DiscoverySectionId {
|
||||
@@ -110,6 +114,7 @@ impl DiscoverySectionId {
|
||||
ArtistsToFollow => "artistsToFollow",
|
||||
ArtistSpotlight => "artistSpotlight",
|
||||
Pinned => "pinned",
|
||||
MostPlayedAlbums => "mostPlayedAlbums",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +141,7 @@ impl DiscoverySectionId {
|
||||
"artistsToFollow" => ArtistsToFollow,
|
||||
"artistSpotlight" => ArtistSpotlight,
|
||||
"pinned" => Pinned,
|
||||
"mostPlayedAlbums" => MostPlayedAlbums,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -174,12 +180,13 @@ pub struct DiscoverPrefs {
|
||||
pub fn default_prefs() -> DiscoverPrefs {
|
||||
use DiscoverySectionId::*;
|
||||
DiscoverPrefs {
|
||||
// home: first 8 ON, last 6 OFF (the Tauri sectionPrefs.ts:63-77 list
|
||||
// plus the Slint-era `pinned` — enabled by default; its arm self-hides
|
||||
// while the user has no pins). All 13 Tauri ids render on Home since
|
||||
// #566 completed the port: qobuzMixes / releaseWatch / topArtists /
|
||||
// favoriteAlbums were genuine Tauri-Home sections whose Slint render
|
||||
// arms + data pipelines were missing.
|
||||
// home: first 8 ON, the rest OFF (the Tauri sectionPrefs.ts:63-77
|
||||
// list plus the Slint-era `pinned` — enabled by default; its arm
|
||||
// self-hides while the user has no pins — and the local
|
||||
// `mostPlayedAlbums` addition, default off). All 13 Tauri ids render
|
||||
// on Home since #566 completed the port: qobuzMixes / releaseWatch /
|
||||
// topArtists / favoriteAlbums were genuine Tauri-Home sections whose
|
||||
// Slint render arms + data pipelines were missing.
|
||||
home: vec![
|
||||
pref(NewReleases, true),
|
||||
pref(PressAwards, true),
|
||||
@@ -195,6 +202,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(Qobuzissimes, false),
|
||||
pref(TopArtists, false),
|
||||
pref(FavoriteAlbums, false),
|
||||
pref(MostPlayedAlbums, false),
|
||||
],
|
||||
// editorPicks: all ON.
|
||||
editor_picks: vec![
|
||||
@@ -222,6 +230,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(EssentialsByGenre, true),
|
||||
pref(ArtistsToFollow, true),
|
||||
pref(ArtistSpotlight, true),
|
||||
pref(MostPlayedAlbums, false),
|
||||
],
|
||||
show_recommendations: true,
|
||||
reco_cache_ttl_hours: 48,
|
||||
@@ -516,14 +525,15 @@ mod tests {
|
||||
#[test]
|
||||
fn defaults_match_spec_exactly() {
|
||||
let d = default_prefs();
|
||||
// home: 14 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned`).
|
||||
// home: 15 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
||||
// + the local mostPlayedAlbums, default off).
|
||||
assert_eq!(
|
||||
ids(&d.home),
|
||||
vec![
|
||||
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
|
||||
ContinueListening, IdealDiscography, MostStreamed, Pinned,
|
||||
QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists,
|
||||
FavoriteAlbums,
|
||||
FavoriteAlbums, MostPlayedAlbums,
|
||||
]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::Home), 8);
|
||||
@@ -535,10 +545,12 @@ mod tests {
|
||||
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
|
||||
// forYou: 13 entries, all ON, qobuzMixes first, pinned second.
|
||||
assert_eq!(d.for_you.len(), 13);
|
||||
// forYou: 14 entries, qobuzMixes first, pinned second; the 13
|
||||
// Tauri+Slint ones ON, mostPlayedAlbums (local addition) OFF.
|
||||
assert_eq!(d.for_you.len(), 14);
|
||||
assert_eq!(d.for_you[0].id, QobuzMixes);
|
||||
assert_eq!(d.for_you[1].id, Pinned);
|
||||
assert_eq!(d.for_you[13].id, MostPlayedAlbums);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 13);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod local_favorites;
|
||||
pub mod pinned_items;
|
||||
pub mod playback;
|
||||
pub mod plex;
|
||||
pub mod album_play_history;
|
||||
pub mod reco_store;
|
||||
pub mod remote_control;
|
||||
pub mod scrobblers;
|
||||
|
||||
@@ -4771,3 +4771,10 @@ msgstr "+{} weitere Künstler"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Weniger anzeigen"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Meistgespielte Alben"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} Wiedergaben"
|
||||
@@ -564,3 +564,10 @@ msgstr "+{} more artists"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Show less"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Most Played Albums"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} plays"
|
||||
@@ -4801,3 +4801,10 @@ msgstr "+{} artistas más"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Ver menos"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Álbumes más escuchados"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} reproducciones"
|
||||
@@ -4771,3 +4771,10 @@ msgstr "+{} artistes de plus"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Afficher moins"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Albums les plus écoutés"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} lectures"
|
||||
@@ -4759,3 +4759,10 @@ msgstr "他 {} 人のアーティスト"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "表示を減らす"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "最も再生したアルバム"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} 回再生"
|
||||
@@ -4783,3 +4783,10 @@ msgstr "+{} artiesten meer"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Minder tonen"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Meest afgespeelde albums"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} keer afgespeeld"
|
||||
@@ -4771,3 +4771,10 @@ msgstr "+{} artistas mais"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Mostrar menos"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Álbuns mais tocados"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} reproduções"
|
||||
@@ -4785,3 +4785,10 @@ msgstr "+ ещё {} исполнителей"
|
||||
|
||||
msgid "Show less"
|
||||
msgstr "Показать меньше"
|
||||
# Most Played Albums rail title
|
||||
msgid "Most Played Albums"
|
||||
msgstr "Самые прослушиваемые альбомы"
|
||||
|
||||
# Most Played Albums View-all — per-card play count
|
||||
msgid "{} plays"
|
||||
msgstr "{} воспроизведений"
|
||||
@@ -31,7 +31,7 @@ export { ThemeState } from "foundation/theme-state.slint";
|
||||
export { Typography } from "foundation/typography.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, PlaylistBrowseState, PlaylistBrowseActions, ForYouState, PinnedItem, PinnedState, PinnedActions, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, LibraryFeedItem, LibraryAllState, LibraryAllActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, SandboxState, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, 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, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint";
|
||||
export { HomeState, HomeActions, RecentAlbumsState, MostPlayedAlbumsState, MostPlayedAlbumsActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, PlaylistBrowseState, PlaylistBrowseActions, ForYouState, PinnedItem, PinnedState, PinnedActions, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, LibraryFeedItem, LibraryAllState, LibraryAllActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, SandboxState, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, 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, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
|
||||
@@ -497,6 +497,15 @@ export component AlbumCard inherits Rectangle {
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
// Play count — shown ONLY in the Most Played Albums View-all
|
||||
// (plays is 0 everywhere else, so nothing draws here).
|
||||
if root.album.plays > 0: Text {
|
||||
text: @tr("{} plays", root.album.plays);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon-only quality badge with an exact-quality hover tooltip.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { ForYouState, RadioStationItem, DiscoverState, PinnedState } from "../state.slint";
|
||||
import { ForYouState, RadioStationItem, DiscoverState, PinnedState, HomeActions } from "../state.slint";
|
||||
import { Carousel } from "Carousel.slint";
|
||||
import { SlimCarousel } from "SlimCarousel.slint";
|
||||
import { ArtistCarousel } from "ArtistCarousel.slint";
|
||||
@@ -147,6 +147,21 @@ export component ForYouView inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
|
||||
// mostPlayedAlbums — album carousel ranked by local play count.
|
||||
if desc.id == "mostPlayedAlbums" && ForYouState.most-played-albums.albums.length > 0: Carousel {
|
||||
section: ForYouState.most-played-albums;
|
||||
show-view-all: true;
|
||||
view-all-clicked => {
|
||||
HomeActions.open-most-played-albums();
|
||||
}
|
||||
album-clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// similarAlbums — "More From Your Library" album carousel.
|
||||
if desc.id == "similarAlbums" && ForYouState.more-from-library.albums.length > 0: Carousel {
|
||||
section: ForYouState.more-from-library;
|
||||
|
||||
@@ -498,6 +498,23 @@ export component HomeView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Most Played Albums — album carousel ranked by local play
|
||||
// count. Self-hides while empty (default-off section).
|
||||
if desc.id == "mostPlayedAlbums"
|
||||
&& HomeState.most-played-albums.albums.length > 0: Carousel {
|
||||
section: HomeState.most-played-albums;
|
||||
show-view-all: true;
|
||||
view-all-clicked => {
|
||||
HomeActions.open-most-played-albums();
|
||||
}
|
||||
album-clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// Qobuz Mixes (#566) — static navigation tiles, ALWAYS
|
||||
// rendered when enabled (no data gate; 1:1 with the For
|
||||
// You arm and Tauri Home).
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Most Played Albums — the full-page listing opened from the rail's "View
|
||||
// all". Renders the local play-count store (album_play_history), ranked by
|
||||
// plays desc, as a flat grid. Each card shows its play count (AlbumCardItem
|
||||
// carries `plays`, non-zero only here). Structure mirrors RecentAlbumsView
|
||||
// (fixed header + AlbumCollectionView in a Flickable + the shared
|
||||
// ListScrollbar); the search box is the one addition.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { MostPlayedAlbumsState, MostPlayedAlbumsActions, NavState, ShellState } from "../state.slint";
|
||||
import { AlbumCollectionView } from "AlbumCollectionView.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
|
||||
export component MostPlayedAlbumsView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
property <length> album-w: 200px;
|
||||
property <length> album-h: 286px;
|
||||
property <length> gap: 24px;
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// --- Fixed header: NavButtons + title (left), search (right) -------
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: Theme.surface-main;
|
||||
|
||||
HorizontalLayout {
|
||||
x: 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButtons { }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("Most Played Albums");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search box, anchored right (filters the grid by title/artist).
|
||||
search-box := Rectangle {
|
||||
x: parent.width - self.width - 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: 240px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
background: Theme.surface-elevated;
|
||||
HorizontalLayout {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
input := TextInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
single-line: true;
|
||||
text <=> MostPlayedAlbumsState.search;
|
||||
// Client filter runs in Rust (re-set model + re-fire
|
||||
// artwork for the matches, like the playlists filter).
|
||||
changed text => { MostPlayedAlbumsActions.filter(self.text); }
|
||||
}
|
||||
Text {
|
||||
visible: input.text == "";
|
||||
text: @tr("Search…");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scrolling grid ----------------------------------------------
|
||||
flick := Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
property <string> sr-armed: NavState.restore-scope;
|
||||
function sr-restore() {
|
||||
if (NavState.restore-scope == "most-played-albums" && self.viewport-height > self.height) {
|
||||
self.viewport-y = NavState.scroll-restore;
|
||||
NavState.restore-scope = "";
|
||||
}
|
||||
}
|
||||
init => { self.sr-restore(); }
|
||||
changed sr-armed => { self.sr-restore(); }
|
||||
changed viewport-height => { self.sr-restore(); }
|
||||
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
||||
|
||||
page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
if MostPlayedAlbumsState.loading: HorizontalLayout {
|
||||
alignment: center;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
Text {
|
||||
text: @tr("Loading…");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !MostPlayedAlbumsState.loading
|
||||
&& MostPlayedAlbumsState.albums.length == 0: HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("Albums you play will appear here.");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
if !MostPlayedAlbumsState.loading: AlbumCollectionView {
|
||||
albums: MostPlayedAlbumsState.albums;
|
||||
view-mode: "grid";
|
||||
is-grouped: false;
|
||||
show-source: false;
|
||||
card-width: root.album-w;
|
||||
card-height: root.album-h;
|
||||
card-gap: root.gap;
|
||||
windowed: true;
|
||||
visible-top: -flick.viewport-y;
|
||||
visible-height: flick.height;
|
||||
content-offset: 8px;
|
||||
open-album(id) => { root.open-album(id); }
|
||||
open-artist(id) => { root.open-artist(id); }
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
y: 56px;
|
||||
width: 14px;
|
||||
height: parent.height - 56px;
|
||||
viewport-height: flick.viewport-height;
|
||||
visible-height: flick.height;
|
||||
viewport-y <=> flick.viewport-y;
|
||||
auto-hide: true;
|
||||
hover-active: ShellState.pointer-in-window
|
||||
&& ShellState.pointer-x >= flick.absolute-position.x
|
||||
&& ShellState.pointer-x <= flick.absolute-position.x + flick.width
|
||||
&& ShellState.pointer-y >= flick.absolute-position.y
|
||||
&& ShellState.pointer-y <= flick.absolute-position.y + flick.height;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import { HomeView } from "../discover/HomeView.slint";
|
||||
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
|
||||
import { PlaylistBrowseView } from "../discover/PlaylistBrowseView.slint";
|
||||
import { RecentAlbumsView } from "../discover/RecentAlbumsView.slint";
|
||||
import { MostPlayedAlbumsView } from "../discover/MostPlayedAlbumsView.slint";
|
||||
import { AlbumPageView } from "../album/AlbumPageView.slint";
|
||||
import { LocalAlbumView } from "../album/LocalAlbumView.slint";
|
||||
import { ArtistPageView } from "../artist/ArtistPageView.slint";
|
||||
@@ -396,6 +397,17 @@ export component AppShell inherits Rectangle {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.most-played-albums: MostPlayedAlbumsView {
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.album && !OfflineState.offline: AlbumPageView {
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
|
||||
@@ -35,6 +35,8 @@ export struct AlbumCardItem {
|
||||
quality-detail: string,// "24-bit / 96 kHz" — bare detail for QualityBadgeFull
|
||||
track-count: string, // "12" — TRACKS column
|
||||
plain-year: string, // "1973" — bare year for the list-row YEAR column
|
||||
plays: int, // play count — shown ONLY in the Most Played Albums
|
||||
// View-all (0 elsewhere, so no count is drawn).
|
||||
}
|
||||
|
||||
// One entry in an A-Z jump strip (favorites tracks name-group / albums
|
||||
@@ -223,6 +225,9 @@ export global HomeState {
|
||||
// reorders HomeState.favorite-albums in place when this changes.
|
||||
in-out property <int> library-albums-sort: 0;
|
||||
in property <DiscoverSection> release-watch;
|
||||
// Most Played Albums rail — top albums by local play count (title set by
|
||||
// Rust from `album_play_history::top_albums`). Self-hides while empty.
|
||||
in property <DiscoverSection> most-played-albums;
|
||||
in property <[SlimItem]> top-artists: [];
|
||||
// Qobuz Playlists row (Home + Editor's Picks). Single-cover cards; the
|
||||
// active tab's set is swapped in by crate::home::select_tab.
|
||||
@@ -254,6 +259,9 @@ export global HomeActions {
|
||||
// "View all" on the Qobuz Playlists rail -> the full-page playlist
|
||||
// browse (PlaylistBrowseView); a fresh open resets its tag to All.
|
||||
callback open-playlist-browse();
|
||||
// Opens the full "Most Played Albums" page (MostPlayedAlbumsView), fed
|
||||
// from the local play-count store.
|
||||
callback open-most-played-albums();
|
||||
// Fired by HomeView's init on every (re)mount. Rust re-reads the LOCAL
|
||||
// recently-played store into the two rails IF a play was recorded while
|
||||
// Home was off-screen (dirty-flag gated in Rust — a no-op otherwise, so
|
||||
@@ -276,6 +284,21 @@ export global RecentAlbumsState {
|
||||
in property <bool> loading: false;
|
||||
}
|
||||
|
||||
// MostPlayedAlbumsView — the full "Most Played Albums" page opened from the
|
||||
// rail's "View all". Ranked by local play count; each item carries its
|
||||
// `plays`. `search` filters title/artist client-side (visibility only).
|
||||
export global MostPlayedAlbumsState {
|
||||
in property <[AlbumCardItem]> albums: [];
|
||||
in property <bool> loading: false;
|
||||
in-out property <string> search: "";
|
||||
}
|
||||
|
||||
export global MostPlayedAlbumsActions {
|
||||
// Filter the loaded page by title/artist. Rust re-sets `albums` to the
|
||||
// matches and re-fires their artwork (mirrors the Qobuz-playlists filter).
|
||||
callback filter(string);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discover section configurator (Slice 5) — prefs-driven render loop + modal.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -430,6 +453,9 @@ export global ForYouState {
|
||||
in property <DiscoverSection> recent-albums;
|
||||
// Favorite Albums — the user's favorited albums (For You #6).
|
||||
in property <DiscoverSection> favorite-albums;
|
||||
// Most Played Albums — top albums by local play count (same data as
|
||||
// HomeState.most-played-albums; local, not tab-specific).
|
||||
in property <DiscoverSection> most-played-albums;
|
||||
in property <[SlimItem]> recent-tracks: [];
|
||||
in property <[SlimItem]> top-artists: [];
|
||||
in property <[SlimItem]> artists-to-follow: [];
|
||||
@@ -1083,7 +1109,7 @@ export global DragActions {
|
||||
}
|
||||
|
||||
// Which content view the shell shows.
|
||||
export enum ContentView { home, album, artist, artist-releases, settings, search, musician, label, label-releases, award, award-albums, location, favorites, mix, playlist, discover-browse, playlist-browse, recent-albums, playlist-manager, offline-manager, blacklist-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder, purchases, purchase-album, now-playing }
|
||||
export enum ContentView { home, album, artist, artist-releases, settings, search, musician, label, label-releases, award, award-albums, location, favorites, mix, playlist, discover-browse, playlist-browse, recent-albums, most-played-albums, playlist-manager, offline-manager, blacklist-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder, purchases, purchase-album, now-playing }
|
||||
|
||||
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
||||
// a single toast at a time (a new one replaces the current), auto-hidden
|
||||
|
||||
@@ -204,6 +204,7 @@ pub fn classify_release_type(track_count: Option<u32>) -> &'static str {
|
||||
/// (single-source Qobuz context — hide the column with `show-source: false`).
|
||||
pub fn to_item(card: AlbumCard) -> AlbumCardItem {
|
||||
AlbumCardItem {
|
||||
plays: 0,
|
||||
// Favorite heart state. Local/Plex albums read the local-favorites
|
||||
// store (their composite keys never match a Qobuz favorite id);
|
||||
// Qobuz albums read the login-seeded fav cache so every card surface
|
||||
|
||||
@@ -674,6 +674,7 @@ pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
// so re-route year through that field instead of changing the
|
||||
// shared card primitive.
|
||||
AlbumCardItem {
|
||||
plays: 0,
|
||||
// Favorite heart state from the login-seeded cache (kept live by
|
||||
// main::set_album_row_favorite when a favorite toggles anywhere).
|
||||
is_favorite: crate::fav_cache::is_album_favorite(&card.id),
|
||||
|
||||
@@ -70,10 +70,16 @@ pub enum ArtworkTarget {
|
||||
/// model has its own lifecycle, separate from the rail's (same split as
|
||||
/// HomeFavoriteAlbum vs ForYouFavoriteAlbum).
|
||||
RecentAlbumsPage { idx: usize },
|
||||
/// A card in `MostPlayedAlbumsState.albums[idx]` — the "Most Played Albums"
|
||||
/// View-all page.
|
||||
MostPlayedAlbumsPage { idx: usize },
|
||||
/// A card in `HomeState.favorite-albums.albums[idx]` — the Home tab's
|
||||
/// "Library Albums" rail (#566). Separate from `ForYouFavoriteAlbum`:
|
||||
/// the two rails share the data pipeline but not the model lifecycle.
|
||||
HomeFavoriteAlbum { idx: usize },
|
||||
/// A card in `HomeState.most-played-albums.albums[idx]` — the Home tab's
|
||||
/// "Most Played Albums" rail (local play-count ranking).
|
||||
HomeMostPlayedAlbum { idx: usize },
|
||||
/// A card in `HomeState.release-watch.albums[idx]` — the Home tab's
|
||||
/// "Release Watch" rail (#566; ForYouReleaseWatch's Home twin).
|
||||
HomeReleaseWatchAlbum { idx: usize },
|
||||
@@ -245,6 +251,8 @@ pub enum ArtworkTarget {
|
||||
ExtRecoWeeklyJams { index: usize },
|
||||
/// A card in ForYouState.favorite-albums.albums[index].
|
||||
ForYouFavoriteAlbum { index: usize },
|
||||
/// A card in ForYouState.most-played-albums.albums[index].
|
||||
ForYouMostPlayedAlbum { index: usize },
|
||||
/// The Spotlight artist portrait.
|
||||
ForYouSpotlightArtist,
|
||||
/// A card in ForYouState.spotlight-albums[index].
|
||||
@@ -996,6 +1004,14 @@ fn apply_artwork(
|
||||
item.artwork = image;
|
||||
albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::MostPlayedAlbumsPage { idx } => {
|
||||
let albums = window.global::<crate::MostPlayedAlbumsState>().get_albums();
|
||||
let Some(mut item) = albums.row_data(idx) else {
|
||||
return;
|
||||
};
|
||||
item.artwork = image;
|
||||
albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::RecentAlbumsPage { idx } => {
|
||||
let albums = window.global::<crate::RecentAlbumsState>().get_albums();
|
||||
let Some(mut item) = albums.row_data(idx) else {
|
||||
@@ -1012,6 +1028,14 @@ fn apply_artwork(
|
||||
item.artwork = image;
|
||||
section.albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::HomeMostPlayedAlbum { idx } => {
|
||||
let section = home.get_most_played_albums();
|
||||
let Some(mut item) = section.albums.row_data(idx) else {
|
||||
return;
|
||||
};
|
||||
item.artwork = image;
|
||||
section.albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::HomeReleaseWatchAlbum { idx } => {
|
||||
let section = home.get_release_watch();
|
||||
let Some(mut item) = section.albums.row_data(idx) else {
|
||||
@@ -1667,6 +1691,13 @@ fn apply_artwork(
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::ForYouMostPlayedAlbum { index } => {
|
||||
let model = window.global::<crate::ForYouState>().get_most_played_albums().albums;
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.artwork = image;
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::ForYouSpotlightArtist => {
|
||||
window
|
||||
.global::<crate::ForYouState>()
|
||||
|
||||
@@ -152,9 +152,8 @@ pub fn render_kind(id: DiscoverySectionId) -> &'static str {
|
||||
TopArtists | ArtistsToFollow => "artistCarousel",
|
||||
ArtistSpotlight => "spotlight",
|
||||
Pinned => "pinnedCarousel",
|
||||
ReleaseWatch | FavoriteAlbums | SimilarAlbums | RediscoverLibrary | EssentialsByGenre => {
|
||||
"albumCarousel"
|
||||
}
|
||||
ReleaseWatch | FavoriteAlbums | MostPlayedAlbums | SimilarAlbums | RediscoverLibrary
|
||||
| EssentialsByGenre => "albumCarousel",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +190,7 @@ pub fn label_for(id: DiscoverySectionId) -> &'static str {
|
||||
ArtistsToFollow => qbz_i18n::mark("Artists to Follow"), // discovery.artistsToFollow
|
||||
ArtistSpotlight => qbz_i18n::mark("Artist Spotlight"), // discovery.artistSpotlight
|
||||
Pinned => qbz_i18n::mark("Pinned"), // Slint-era section, no Tauri key
|
||||
MostPlayedAlbums => qbz_i18n::mark("Most Played Albums"), // local: most-played rail
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -619,6 +619,7 @@ fn slim_from_track(t: &TrackReco) -> SlimItem {
|
||||
}
|
||||
pub(crate) fn album_card(a: &AlbumReco) -> AlbumCardItem {
|
||||
AlbumCardItem {
|
||||
plays: 0,
|
||||
// Favorite heart state from the login-seeded cache (kept live by
|
||||
// main::set_album_row_favorite when a favorite toggles anywhere).
|
||||
is_favorite: crate::fav_cache::is_album_favorite(&a.qobuz_album_id),
|
||||
|
||||
@@ -492,6 +492,25 @@ where
|
||||
build_favorite_albums(&fav_albums)
|
||||
}
|
||||
|
||||
/// Cards for the "Most Played Albums" rail — top 20 albums by local play
|
||||
/// count (`album_play_history`). Local (no network); the SAME set feeds Home
|
||||
/// and For You. Ranked rows map 1:1 onto `AlbumCard`.
|
||||
pub(crate) fn most_played_album_cards() -> Vec<AlbumCard> {
|
||||
qbz_app::settings::album_play_history::top_albums(20)
|
||||
.into_iter()
|
||||
.map(|r| AlbumCard {
|
||||
id: r.album_id,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
artist_id: r.artist_id,
|
||||
year: r.year,
|
||||
quality_tier: r.quality_tier,
|
||||
quality_label: r.quality_label,
|
||||
artwork_url: r.artwork_url,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Radio Stations — album-seeded tiles from recent + favorite albums,
|
||||
/// deduped, capped at 12.
|
||||
fn build_radio(
|
||||
@@ -543,6 +562,7 @@ fn album_items(cards: &[AlbumCard]) -> Vec<AlbumCardItem> {
|
||||
cards
|
||||
.iter()
|
||||
.map(|c| AlbumCardItem {
|
||||
plays: 0,
|
||||
// Favorite heart state from the login-seeded cache (kept live by
|
||||
// main::set_album_row_favorite when a favorite toggles anywhere).
|
||||
is_favorite: crate::fav_cache::is_album_favorite(&c.id),
|
||||
@@ -764,6 +784,16 @@ fn apply_favorite_albums(weak: &slint::Weak<AppWindow>, cache: &ImageCache, card
|
||||
crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone());
|
||||
}
|
||||
|
||||
fn apply_most_played_albums(weak: &slint::Weak<AppWindow>, cache: &ImageCache, cards: Vec<AlbumCard>) {
|
||||
let jobs = album_jobs(&cards, |i| ArtworkTarget::ForYouMostPlayedAlbum { index: i });
|
||||
let w = weak.clone();
|
||||
let _ = w.upgrade_in_event_loop(move |w| {
|
||||
w.global::<ForYouState>()
|
||||
.set_most_played_albums(section(&qbz_i18n::t("Most Played Albums"), &cards));
|
||||
});
|
||||
crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone());
|
||||
}
|
||||
|
||||
fn apply_more_from_library(
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
cache: &ImageCache,
|
||||
@@ -941,6 +971,9 @@ pub fn spawn_for_you<A>(
|
||||
let scored_fav = crate::reco::scored_favorite_album_ids(80);
|
||||
let fav_albums = order_by_score(fav_albums, scored_fav.as_deref());
|
||||
apply_favorite_albums(&weak, &cache, build_favorite_albums(&fav_albums));
|
||||
// Most Played Albums — local play-count rail (no fetch); rides
|
||||
// this branch since it shares the cache + weak already cloned.
|
||||
apply_most_played_albums(&weak, &cache, most_played_album_cards());
|
||||
// reco: backfill genres for the resolved favorite albums so the
|
||||
// engine's top-genres has data (plays alone carry no genre).
|
||||
let genre_entries: Vec<(String, u64, String)> = fav_albums
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct HomeData {
|
||||
/// fourth ported Tauri-Home section, is static navigation tiles — no
|
||||
/// data field needed.)
|
||||
pub top_artists: Vec<crate::foryou::ArtistSlim>,
|
||||
/// "Most Played Albums" rail — top albums by local play count
|
||||
/// (`album_play_history`). Local, so built inline (no fetch). Feeds
|
||||
/// `HomeState.most-played-albums`; self-hides while empty.
|
||||
pub most_played_albums: Vec<crate::foryou::AlbumCard>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -325,6 +329,7 @@ where
|
||||
favorite_albums,
|
||||
release_watch,
|
||||
top_artists,
|
||||
most_played_albums: crate::foryou::most_played_album_cards(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -668,6 +673,7 @@ pub fn apply_recent_rails(window: &AppWindow, recent: Vec<SlimData>, albums: Vec
|
||||
/// Convert one `CardData` into the Slint `AlbumCardItem`.
|
||||
pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
AlbumCardItem {
|
||||
plays: 0,
|
||||
// Favorite heart state from the login-seeded cache (kept live by
|
||||
// main::set_album_row_favorite when a favorite toggles anywhere).
|
||||
is_favorite: crate::fav_cache::is_album_favorite(&card.id),
|
||||
@@ -798,6 +804,7 @@ const HOME_RENDERABLE: &[DiscoverySectionId] = &[
|
||||
DiscoverySectionId::ReleaseWatch,
|
||||
DiscoverySectionId::TopArtists,
|
||||
DiscoverySectionId::Pinned,
|
||||
DiscoverySectionId::MostPlayedAlbums,
|
||||
];
|
||||
|
||||
/// Build one tab's ordered ENABLED descriptor list from `prefs` + the cached
|
||||
@@ -1117,6 +1124,10 @@ pub fn apply_home(window: &AppWindow, data: HomeData) {
|
||||
&qbz_i18n::t("Release Watch"),
|
||||
&data.release_watch,
|
||||
));
|
||||
state.set_most_played_albums(crate::foryou::section(
|
||||
&qbz_i18n::t("Most Played Albums"),
|
||||
&data.most_played_albums,
|
||||
));
|
||||
state.set_top_artists(ModelRc::new(VecModel::from(crate::foryou::artist_items(
|
||||
&data.top_artists,
|
||||
))));
|
||||
|
||||
@@ -853,6 +853,12 @@ async fn reload_home(
|
||||
url: card.artwork_url.clone(),
|
||||
})
|
||||
}));
|
||||
jobs.extend(data.most_played_albums.iter().enumerate().filter_map(|(idx, card)| {
|
||||
(!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::HomeMostPlayedAlbum { idx },
|
||||
url: card.artwork_url.clone(),
|
||||
})
|
||||
}));
|
||||
jobs.extend(data.release_watch.iter().enumerate().filter_map(|(idx, card)| {
|
||||
(!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::HomeReleaseWatchAlbum { idx },
|
||||
@@ -3007,6 +3013,7 @@ fn scope_for(entry: &nav::NavEntry) -> String {
|
||||
nav::NavEntry::DiscoverBrowse { .. } => "discover-browse".into(),
|
||||
nav::NavEntry::PlaylistBrowse => "playlist-browse".into(),
|
||||
nav::NavEntry::RecentAlbums => "recent-albums".into(),
|
||||
nav::NavEntry::MostPlayedAlbums => "most-played-albums".into(),
|
||||
nav::NavEntry::Mix { .. } => "mix".into(),
|
||||
nav::NavEntry::Playlist(_) => "playlist".into(),
|
||||
nav::NavEntry::PlaylistManager => "playlist-manager".into(),
|
||||
@@ -3203,6 +3210,9 @@ fn apply_entry(
|
||||
nav::NavEntry::RecentAlbums => {
|
||||
navigate_recent_albums(weak.clone(), handle, image_cache.clone());
|
||||
}
|
||||
nav::NavEntry::MostPlayedAlbums => {
|
||||
navigate_most_played_albums(weak.clone(), handle, image_cache.clone());
|
||||
}
|
||||
nav::NavEntry::Mix { kind } => {
|
||||
navigate_mix(runtime.clone(), weak.clone(), handle, image_cache.clone(), kind);
|
||||
}
|
||||
@@ -3706,6 +3716,125 @@ fn navigate_recent_albums(
|
||||
});
|
||||
}
|
||||
|
||||
/// Full ranked list for the Most Played Albums View-all, cached so the search
|
||||
/// box re-filters without re-querying SQLite (mirrors the Qobuz-playlists
|
||||
/// filter). Written by the navigation, read by the filter.
|
||||
static MOST_PLAYED_ROWS: std::sync::Mutex<
|
||||
Vec<qbz_app::settings::album_play_history::AlbumPlayRow>,
|
||||
> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Map a ranked row to a grid card, carrying its play count (drawn only here;
|
||||
/// `plays` is 0 on every other AlbumCardItem).
|
||||
fn most_played_item(row: &qbz_app::settings::album_play_history::AlbumPlayRow) -> AlbumCardItem {
|
||||
let mut item = home::card_to_item(crate::home::CardData {
|
||||
id: row.album_id.clone(),
|
||||
title: row.title.clone(),
|
||||
artist: row.artist.clone(),
|
||||
artist_id: row.artist_id.clone(),
|
||||
year: row.year.clone(),
|
||||
quality_tier: row.quality_tier.clone(),
|
||||
quality_label: row.quality_label.clone(),
|
||||
artwork_url: row.artwork_url.clone(),
|
||||
source: row.source.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
item.plays = row.plays as i32;
|
||||
item
|
||||
}
|
||||
|
||||
/// Push ranked rows onto the Most Played Albums page + fire their artwork
|
||||
/// (Qobuz plain loader vs Plex/local source-aware funnel, like the recent
|
||||
/// page). Shared by the initial load and the search filter.
|
||||
fn apply_most_played_page(
|
||||
weak: &slint::Weak<AppWindow>,
|
||||
image_cache: artwork::ImageCache,
|
||||
rows: Vec<qbz_app::settings::album_play_history::AlbumPlayRow>,
|
||||
) {
|
||||
let mut jobs: Vec<artwork::ArtworkJob> = Vec::new();
|
||||
let mut plex_jobs: Vec<artwork::ArtworkJob> = Vec::new();
|
||||
for (idx, row) in rows.iter().enumerate() {
|
||||
if row.artwork_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let job = artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::MostPlayedAlbumsPage { idx },
|
||||
url: row.artwork_url.clone(),
|
||||
};
|
||||
if row.source == "plex" || row.source == "local" {
|
||||
plex_jobs.push(job);
|
||||
} else {
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
let weak_for_plex = weak.clone();
|
||||
let image_cache_plex = image_cache.clone();
|
||||
let _ = weak.clone().upgrade_in_event_loop(move |w| {
|
||||
let items: Vec<AlbumCardItem> = rows.iter().map(most_played_item).collect();
|
||||
let s = w.global::<MostPlayedAlbumsState>();
|
||||
s.set_albums(slint::ModelRc::new(slint::VecModel::from(items)));
|
||||
s.set_loading(false);
|
||||
});
|
||||
artwork::spawn_loads(jobs, weak.clone(), image_cache);
|
||||
if !plex_jobs.is_empty() {
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_local_or_plex_loads(
|
||||
plex_jobs,
|
||||
plex.base_url,
|
||||
plex.token,
|
||||
weak_for_plex,
|
||||
image_cache_plex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the full "Most Played Albums" page: load the ranked list, cache it,
|
||||
/// render it. Local SQLite read, so no runtime + no error branch.
|
||||
fn navigate_most_played_albums(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: artwork::ImageCache,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let s = w.global::<MostPlayedAlbumsState>();
|
||||
s.set_loading(true);
|
||||
s.set_search("".into());
|
||||
s.set_albums(slint::ModelRc::new(slint::VecModel::from(
|
||||
Vec::<AlbumCardItem>::new(),
|
||||
)));
|
||||
w.global::<NavState>()
|
||||
.set_view(ContentView::MostPlayedAlbums);
|
||||
});
|
||||
let rows = qbz_app::settings::album_play_history::all_albums();
|
||||
*MOST_PLAYED_ROWS.lock().unwrap() = rows.clone();
|
||||
apply_most_played_page(&weak, image_cache, rows);
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-filter the cached Most Played list by title/artist and re-render.
|
||||
fn filter_most_played(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
image_cache: artwork::ImageCache,
|
||||
query: String,
|
||||
) {
|
||||
let q = query.trim().to_lowercase();
|
||||
let rows: Vec<_> = MOST_PLAYED_ROWS
|
||||
.lock()
|
||||
.map(|guard| {
|
||||
guard
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
q.is_empty()
|
||||
|| r.title.to_lowercase().contains(&q)
|
||||
|| r.artist.to_lowercase().contains(&q)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
apply_most_played_page(&weak, image_cache, rows);
|
||||
}
|
||||
|
||||
/// Set when a play lands in the recently-played store while the Home view is
|
||||
/// not showing, so the next Home mount (`HomeActions.home-mounted`) re-reads
|
||||
/// the LOCAL store into the rails. Cleared by `refresh_recent_rails`.
|
||||
@@ -16716,6 +16845,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<HomeActions>()
|
||||
.on_open_most_played_albums(move || {
|
||||
nav::record(nav::NavEntry::MostPlayedAlbums);
|
||||
navigate_most_played_albums(weak.clone(), &handle, image_cache.clone());
|
||||
if let Some(w) = weak.upgrade() {
|
||||
update_nav_flags(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<MostPlayedAlbumsActions>()
|
||||
.on_filter(move |q| {
|
||||
filter_most_played(weak.clone(), image_cache.clone(), q.to_string());
|
||||
});
|
||||
}
|
||||
|
||||
// Qobuz Playlists rail "View all" -> the full-page playlist browse
|
||||
// (server-side tag + genre filtering). A fresh open resets the
|
||||
|
||||
@@ -59,6 +59,9 @@ pub enum NavEntry {
|
||||
/// reached from the Home / Editor's Picks rail. Tag / search / view-mode
|
||||
/// are session state in PlaylistBrowseState, so no payload.
|
||||
PlaylistBrowse,
|
||||
/// MostPlayedAlbumsView — the full "Most Played Albums" listing, ranked by
|
||||
/// local play count (album_play_history), reloaded on every navigation.
|
||||
MostPlayedAlbums,
|
||||
/// A Qobuz mix detail page ("daily" | "weekly" | "fav" | "top").
|
||||
Mix {
|
||||
kind: String,
|
||||
|
||||
@@ -2540,6 +2540,26 @@ async fn record_recent(runtime: &Runtime) {
|
||||
} else {
|
||||
track_label
|
||||
};
|
||||
// Per-album play count — feeds the "Most Played Albums" rail (top-20
|
||||
// COUNT(*) GROUP BY album_id). Per-track-start, like the artist store
|
||||
// below; no-op when album_id is empty. Same album identity `recently`
|
||||
// uses, so the two rails agree.
|
||||
{
|
||||
let artist_id_str = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
qbz_app::settings::album_play_history::record_album_play(
|
||||
qbz_app::settings::album_play_history::AlbumPlayMeta {
|
||||
album_id: &album_id,
|
||||
title: &track.album,
|
||||
artist: &track.artist,
|
||||
artist_id: &artist_id_str,
|
||||
artwork_url: &artwork,
|
||||
quality_tier: &quality_tier,
|
||||
quality_label: &quality_label,
|
||||
year: meta.release_date.get(0..4).unwrap_or(""),
|
||||
source: track.source.as_deref().unwrap_or("qobuz"),
|
||||
},
|
||||
);
|
||||
}
|
||||
crate::recently::record(crate::recently::RecentTrack {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
|
||||
@@ -1200,6 +1200,7 @@ where
|
||||
|
||||
fn album_item(row: AlbumRow) -> AlbumCardItem {
|
||||
AlbumCardItem {
|
||||
plays: 0,
|
||||
// Favorite heart state from the login-seeded cache (kept live by
|
||||
// main::set_album_row_favorite when a favorite toggles anywhere).
|
||||
is_favorite: crate::fav_cache::is_album_favorite(&row.id),
|
||||
|
||||
Reference in new issue
Block a user