feat(home): auto-refresh recently-played rails + a row refresh button
Home's recently-played albums/tracks only populated on a full home load, so tracks played mid-session never appeared until restart. Re-read the LOCAL store into the rails when a track is recorded while Home is visible, and on Home re-mount if it went dirty — event-driven, no polling, cheap (small JSON read + cached artwork). Also add a manual refresh button in each recently- played row header next to the page chevrons (moved off the top toolbar, where it crowded the discovery tab bar). cc
This commit is contained in:
7 files changed
+232
-27
No files matched your search
@@ -69,9 +69,12 @@ component ViewAllLink inherits Rectangle {
|
||||
export component Carousel inherits VerticalLayout {
|
||||
in property <DiscoverSection> section;
|
||||
in property <bool> show-view-all: false;
|
||||
// Optional refresh affordance in the row header (recently-played rows).
|
||||
in property <bool> show-refresh: false;
|
||||
callback album-clicked(string);
|
||||
callback media-action(string, string, string);
|
||||
callback view-all-clicked();
|
||||
callback refresh-clicked();
|
||||
|
||||
property <length> card-width: 200px;
|
||||
// Overridable so a host view (Discover Recommendations) can give the row a
|
||||
@@ -189,6 +192,15 @@ export component Carousel inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.show-refresh: VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||
clicked => {
|
||||
root.refresh-clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { HomeState, HomeActions, DiscoverState, DiscoverActions, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState } from "../state.slint";
|
||||
import { HomeState, HomeActions, DiscoverState, DiscoverActions, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState, TooltipState } from "../state.slint";
|
||||
import { Carousel } from "Carousel.slint";
|
||||
import { SlimCarousel } from "SlimCarousel.slint";
|
||||
import { ArtistCarousel } from "ArtistCarousel.slint";
|
||||
@@ -121,6 +121,9 @@ component GenreButton inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Manual refresh for the recently-played rails — sits in the toolbar next
|
||||
// to the nav cluster. Same 32px icon-button form as GearButton, plus the
|
||||
// global hover tooltip (TooltipState — the TransportControls pattern).
|
||||
component GearButton inherits Rectangle {
|
||||
callback clicked();
|
||||
|
||||
@@ -157,6 +160,14 @@ export component HomeView inherits Rectangle {
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
// The view remounts on every navigation to Home (AppShell's `if` mount),
|
||||
// so this is the "Home regained focus" hook: Rust re-reads the LOCAL
|
||||
// recently-played rails only when a play was recorded while Home was
|
||||
// off-screen (dirty-flag gated — otherwise a no-op).
|
||||
init => {
|
||||
HomeActions.home-mounted();
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
@@ -390,6 +401,13 @@ export component HomeView inherits Rectangle {
|
||||
// play-history, not a /discover endpoint — so it goes
|
||||
// through its own action, not discover-view-all).
|
||||
show-view-all: true;
|
||||
// Manual refresh, in the row header next to the page
|
||||
// chevrons (the recently-played rows are the only ones
|
||||
// that go stale between full home loads).
|
||||
show-refresh: true;
|
||||
refresh-clicked => {
|
||||
HomeActions.refresh-recent();
|
||||
}
|
||||
album-clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
@@ -410,6 +428,10 @@ export component HomeView inherits Rectangle {
|
||||
if desc.id == "continueListening" && HomeState.recent.length > 0: SlimCarousel {
|
||||
title: @tr("Recently Played Tracks");
|
||||
items: HomeState.recent;
|
||||
show-refresh: true;
|
||||
refresh-clicked => {
|
||||
HomeActions.refresh-recent();
|
||||
}
|
||||
item-clicked(id) => {
|
||||
root.media-action("track", id, "play");
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ export component SlimCarousel inherits VerticalLayout {
|
||||
// (recent, popular) are unchanged; only the Weekly rows opt in.
|
||||
in property <bool> show-list-actions: false;
|
||||
callback list-action(string);
|
||||
// Optional refresh affordance in the row header (recently-played rows).
|
||||
// Shown regardless of page count, unlike the chevrons.
|
||||
in property <bool> show-refresh: false;
|
||||
callback refresh-clicked();
|
||||
|
||||
property <int> columns: 4;
|
||||
property <int> rows: 3;
|
||||
@@ -99,6 +103,18 @@ export component SlimCarousel inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.show-refresh: HorizontalLayout {
|
||||
spacing: 4px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||
clicked => {
|
||||
root.refresh-clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only when there is a second page.
|
||||
if root.total > root.per-page: HorizontalLayout {
|
||||
spacing: 4px;
|
||||
|
||||
@@ -204,6 +204,14 @@ export global HomeActions {
|
||||
// "View all" on the Recently Played Albums rail -> the full-page listing
|
||||
// (RecentAlbumsView), fed from the local play-history store.
|
||||
callback open-recent-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
|
||||
// mounting Home stays free).
|
||||
callback home-mounted();
|
||||
// Manual refresh (the toolbar button next to the nav cluster):
|
||||
// unconditionally re-read the local recently-played rails.
|
||||
callback refresh-recent();
|
||||
}
|
||||
|
||||
// RecentAlbumsView — the full "Recently Played Albums" page opened from the
|
||||
|
||||
+48
-26
@@ -279,18 +279,7 @@ where
|
||||
|
||||
// Recently played comes from the local play-history store, not the
|
||||
// discover index. Empty until the playback session records plays.
|
||||
// Capped at 24 (two carousel pages of 12).
|
||||
let recent = crate::recently::load()
|
||||
.into_iter()
|
||||
.take(24)
|
||||
.map(|track| SlimData {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
subtitle: track.subtitle,
|
||||
rank: String::new(),
|
||||
artwork_url: track.artwork_url,
|
||||
})
|
||||
.collect();
|
||||
let recent = recent_track_slims();
|
||||
let recent_albums = recent_album_cards();
|
||||
|
||||
// Qobuz Playlists row — both the Home and Editor's Picks tabs draw from
|
||||
@@ -333,6 +322,24 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// The recently-played TRACK window mapped to slim-card data, newest first,
|
||||
/// capped at 24 (two carousel pages of 12 — the slim carousel does not show
|
||||
/// beyond that). Local file read — cheap. Shared by [`load_home`] and the
|
||||
/// targeted recent-rails refresh (`main::refresh_recent_rails`).
|
||||
pub fn recent_track_slims() -> Vec<SlimData> {
|
||||
crate::recently::load()
|
||||
.into_iter()
|
||||
.take(24)
|
||||
.map(|track| SlimData {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
subtitle: track.subtitle,
|
||||
rank: String::new(),
|
||||
artwork_url: track.artwork_url,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The recently-played album history mapped to card data, newest first.
|
||||
/// Shared by the Home "Recently Played Albums" rail (via [`load_home`]) and
|
||||
/// the full "View all" page (`main::navigate_recent_albums`), so both apply
|
||||
@@ -622,6 +629,33 @@ fn format_rate(rate: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one `SlimData` into the Slint `SlimItem` (shared by `apply_home`
|
||||
/// and `apply_recent_rails`).
|
||||
pub(crate) fn slim_to_item(slim: SlimData) -> SlimItem {
|
||||
SlimItem {
|
||||
id: slim.id.into(),
|
||||
title: slim.title.into(),
|
||||
subtitle: slim.subtitle.into(),
|
||||
rank: slim.rank.into(),
|
||||
artwork_url: slim.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
following: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push ONLY the two recently-played rails onto `HomeState` — the targeted
|
||||
/// auto/manual refresh path. Everything else on Home is left untouched: no
|
||||
/// discover-index fetch, no descriptor rebuild, no section-cache write. Must
|
||||
/// run on the Slint event loop (`card_to_item` seeds is-favorite from the
|
||||
/// login cache, same as `apply_home`).
|
||||
pub fn apply_recent_rails(window: &AppWindow, recent: Vec<SlimData>, albums: Vec<CardData>) {
|
||||
let recent: Vec<SlimItem> = recent.into_iter().map(slim_to_item).collect();
|
||||
let albums: Vec<AlbumCardItem> = albums.into_iter().map(card_to_item).collect();
|
||||
let state = window.global::<HomeState>();
|
||||
state.set_recent(ModelRc::new(VecModel::from(recent)));
|
||||
state.set_recent_albums(ModelRc::new(VecModel::from(albums)));
|
||||
}
|
||||
|
||||
/// Convert one `CardData` into the Slint `AlbumCardItem`.
|
||||
pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
AlbumCardItem {
|
||||
@@ -1018,20 +1052,8 @@ pub fn apply_home(window: &AppWindow, data: HomeData) {
|
||||
};
|
||||
});
|
||||
|
||||
let to_slim_items = |items: Vec<SlimData>| -> Vec<SlimItem> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|slim| SlimItem {
|
||||
id: slim.id.into(),
|
||||
title: slim.title.into(),
|
||||
subtitle: slim.subtitle.into(),
|
||||
rank: slim.rank.into(),
|
||||
artwork_url: slim.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
following: false,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let to_slim_items =
|
||||
|items: Vec<SlimData>| -> Vec<SlimItem> { items.into_iter().map(slim_to_item).collect() };
|
||||
let popular = to_slim_items(data.popular);
|
||||
let recent = to_slim_items(data.recent);
|
||||
let recent_albums: Vec<AlbumCardItem> =
|
||||
|
||||
@@ -2939,6 +2939,93 @@ fn navigate_recent_albums(
|
||||
});
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
static RECENT_RAILS_DIRTY: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Re-read the LOCAL recently-played store and re-push the Home "Recently
|
||||
/// Played Tracks" / "Recently Played Albums" rails + their artwork. The rails
|
||||
/// were previously read only inside the full discover load (`load_home`), so
|
||||
/// plays during a session never surfaced until a restart — this is the
|
||||
/// targeted refresh: a small local JSON read plus mostly cache-served artwork,
|
||||
/// NO discover-index fetch. Mirrors `navigate_recent_albums`' off-UI-thread
|
||||
/// read and its Qobuz vs Plex/local artwork split.
|
||||
fn refresh_recent_rails(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: artwork::ImageCache,
|
||||
) {
|
||||
RECENT_RAILS_DIRTY.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
handle.spawn(async move {
|
||||
// Local file reads + blacklist filter — cheap, but keep them off the
|
||||
// UI thread like the sibling loaders.
|
||||
let recent = home::recent_track_slims();
|
||||
let cards = home::recent_album_cards();
|
||||
let mut jobs: Vec<artwork::ArtworkJob> = Vec::new();
|
||||
let mut plex_jobs: Vec<artwork::ArtworkJob> = Vec::new();
|
||||
jobs.extend(recent.iter().enumerate().filter_map(|(idx, slim)| {
|
||||
(!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::Recent { idx },
|
||||
url: slim.artwork_url.clone(),
|
||||
})
|
||||
}));
|
||||
for (idx, card) in cards.iter().enumerate() {
|
||||
if card.artwork_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let job = artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::RecentAlbum { idx },
|
||||
url: card.artwork_url.clone(),
|
||||
};
|
||||
if card.source == "plex" || card.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| {
|
||||
home::apply_recent_rails(&w, recent, cards);
|
||||
});
|
||||
artwork::spawn_loads(jobs, weak, 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Playback hook: a play was just recorded in the recently-played store
|
||||
/// (`playback::record_recent`). Marks the Home rails stale and — when the
|
||||
/// Home view is currently showing — refreshes them immediately, so the rails
|
||||
/// track the session live instead of only filling on restart. Off-Home
|
||||
/// nothing is read; the dirty flag makes the next Home mount refresh. Event-
|
||||
/// driven only — no timer, no polling.
|
||||
pub(crate) fn note_recent_store_changed(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
) {
|
||||
RECENT_RAILS_DIRTY.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some(image_cache) = artwork::shared_cache() else {
|
||||
return;
|
||||
};
|
||||
// The visible-view check must read NavState on the UI thread.
|
||||
let _ = weak.clone().upgrade_in_event_loop(move |w| {
|
||||
if w.global::<NavState>().get_view() == ContentView::Home {
|
||||
refresh_recent_rails(weak, &handle, image_cache);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the My-Purchases surface and lazy-load the active tab. Mirrors
|
||||
/// `navigate_favorites`: the view mounts immediately (spinner), then the active
|
||||
/// tab's data + the metadata (dlIds + per-type totals) load and project onto
|
||||
@@ -15110,6 +15197,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Recently-played rails refresh. `home-mounted` fires on every HomeView
|
||||
// (re)mount: re-read the LOCAL store into the rails IF a play was recorded
|
||||
// while Home was off-screen (dirty flag — a no-op otherwise, so mounting
|
||||
// Home stays free). While Home IS showing, playback refreshes the rails
|
||||
// directly (note_recent_store_changed). No polling anywhere.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<HomeActions>().on_home_mounted(move || {
|
||||
if RECENT_RAILS_DIRTY.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
refresh_recent_rails(weak.clone(), &handle, image_cache.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
// Manual refresh (the toolbar button next to the nav cluster): an
|
||||
// unconditional local re-read of the recently-played rails on demand.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<HomeActions>().on_refresh_recent(move || {
|
||||
refresh_recent_rails(weak.clone(), &handle, image_cache.clone());
|
||||
});
|
||||
}
|
||||
|
||||
// Qobuz Playlists category filter (multi-select, client-side). Toggling /
|
||||
// clearing a tag re-filters the cached playlists row and re-fires the
|
||||
// artwork for the new (filtered) positions — no re-fetch.
|
||||
|
||||
@@ -2133,6 +2133,18 @@ async fn record_recent(runtime: &Runtime) {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::reco::log_play_gated(rid, ralb, rart, rsrc.as_deref());
|
||||
});
|
||||
// Home rails auto-refresh: the recently-played store just changed, so
|
||||
// notify the UI layer — it re-reads the LOCAL store into the Home rails
|
||||
// NOW if the Home view is showing (small JSON read, cached artwork), or
|
||||
// leaves them dirty for the next Home mount. Reaches the window through
|
||||
// the global queue controller, like apply_plex_quality_to_queue
|
||||
// (record_recent does not carry a weak).
|
||||
if let Some(controller) = QUEUE_CONTROLLER.get() {
|
||||
crate::note_recent_store_changed(
|
||||
controller.weak().clone(),
|
||||
controller.handle().clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// THE single queue-drop predicate for an already-built `QueueTrack` (Task 7).
|
||||
|
||||
Reference in new issue
Block a user