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:
vicrodh committed 2026-07-22 17:46:09 -06:00
commit 6c6eaf2b36
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);
}
}
+24 -12
View File
@@ -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);
}
+1
View File
@@ -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 "{} воспроизведений"
+1 -1
View File
@@ -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.
+16 -1
View File
@@ -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;
+17
View File
@@ -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;
}
}
+12
View File
@@ -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);
+27 -1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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),
+31
View File
@@ -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>()
+3 -3
View File
@@ -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
}
}
+1
View File
@@ -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),
+33
View File
@@ -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
+11
View File
@@ -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,
))));
+152
View File
@@ -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
+3
View File
@@ -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,
+20
View File
@@ -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(),
+1
View File
@@ -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),