diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs index 9f5b7fdc..cde8c815 100644 --- a/packages/oneclient_app/src/components/cluster_landscape_art.rs +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -2,7 +2,7 @@ use freya::prelude::*; use oneclient_common::VersionKey; use oneclient_common::domain::GameLoader; -use crate::components::DynamicArt; +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; use crate::theme::colors; use crate::ui::border_all_color; @@ -20,7 +20,7 @@ impl ClusterLandscapeArt { selected: bool, ) -> Self { Self { - art: DynamicArt::for_version(major, key, loader).max_edge(512), + art: DynamicArt::for_version(major, key, loader).max_edge(ART_PREVIEW_EDGE), selected, } } diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 3cd2a0dc..8a6176a7 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -9,12 +9,18 @@ use crate::AppAssets; use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; use crate::layout::HOME_BACKGROUND_ASSET; +/// The size the cluster cards ask for, and so the one variant of a version's +/// art that is usually already cached by the time anything else wants it. +/// Full-screen art stands in with it while its own download is in flight. +pub const ART_PREVIEW_EDGE: u32 = 512; + #[derive(PartialEq, Clone)] pub struct DynamicArt { major: Option, key: Option, loader: Option, max_edge: u32, + preview_edge: Option, } impl DynamicArt { @@ -24,6 +30,7 @@ impl DynamicArt { key, loader, max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, } } @@ -34,6 +41,7 @@ impl DynamicArt { key: parsed.and_then(|p| p.key()), loader: Some(cluster.mc_loader), max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, } } @@ -43,6 +51,7 @@ impl DynamicArt { key: None, loader: None, max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, } } @@ -52,8 +61,27 @@ impl DynamicArt { self } + /// Stand in with a smaller variant of the same art until the full-size one + /// has downloaded. + /// + /// A full-size background is a few hundred KB and the cluster cards have + /// usually already cached the small one, so this puts the right picture on + /// screen right away and sharpens it later, instead of sitting on the + /// built-in fallback for the length of a download. + #[must_use] + pub fn preview_edge(mut self, preview_edge: u32) -> Self { + self.preview_edge = Some(preview_edge); + self + } + pub fn use_bytes(&self) -> (String, Bytes) { - use_art_bytes(self.major, self.key, self.loader, self.max_edge) + use_art_bytes( + self.major, + self.key, + self.loader, + self.max_edge, + self.preview_edge, + ) } } @@ -62,6 +90,7 @@ pub fn use_art_bytes( key: Option, loader: Option, max_edge: u32, + preview_edge: Option, ) -> (String, Bytes) { let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()); @@ -69,13 +98,24 @@ pub fn use_art_bytes( let image_query = use_cached_image(art_url.clone(), max_edge); - match loaded_image(art_url.as_deref(), &image_query) { - Some((url, bytes)) => (format!("{max_edge}|{url}"), bytes), - None => ( + // Subscribed unconditionally to keep the hook order stable. Without a + // preview size it is the same query as the one above, so it costs nothing. + let preview_edge = preview_edge.unwrap_or(max_edge); + let preview_query = use_cached_image(art_url.clone(), preview_edge); + + let full = loaded_image(art_url.as_deref(), &image_query) + .map(|(url, bytes)| (format!("{max_edge}|{url}"), bytes)); + let preview = || { + loaded_image(art_url.as_deref(), &preview_query) + .map(|(url, bytes)| (format!("{preview_edge}|{url}"), bytes)) + }; + + full.or_else(preview).unwrap_or_else(|| { + ( format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), fallback.read().clone(), - ), - } + ) + }) } impl Component for DynamicArt { diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 63f0a138..76e17129 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -44,7 +44,7 @@ pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; pub use dropdown::Dropdown; -pub use dynamic_art::DynamicArt; +pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use file_drop::FileDropOverlay; pub use icons::{Icon, IconType}; pub use java_install_manager::JavaInstallManager; diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 9c263e8a..f26ec59a 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::clusters::Cluster; -use crate::components::{DynamicArt, Icon, IconType}; +use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType}; use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; use crate::routes::Route; use crate::theme::colors; @@ -190,7 +190,7 @@ impl Component for ClusterCard { .height(Size::fill()) .position(Position::new_absolute()) .layer(Layer::Relative(1)) - .child(DynamicArt::for_cluster(&self.cluster).max_edge(512)), + .child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)), ) .child( rect() diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index 13dee94e..fa58d5d8 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -10,11 +10,13 @@ use skia_safe::sampling_options::CubicResampler; use crate::Route; use crate::components::Button; use crate::components::{ - AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, ScrollArea, + ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, + ScrollArea, }; use crate::layout::AnimatedAppOutlet; use crate::theme; use crate::use_settings_snapshot; +use oneclient_core::clusters::Cluster; use oneclient_db::models::ClusterId; use crate::hooks::{ @@ -259,6 +261,66 @@ pub(crate) fn appshell_overlay() -> Rect { pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg"; +/// The cluster the home background is drawn from: the active one, or the first +/// in the list before anything is active. +fn home_cluster(clusters: &[Cluster], active: Option) -> Option<&Cluster> { + active + .and_then(|id| clusters.iter().find(|c| c.id == id)) + .or_else(|| clusters.first()) +} + +fn home_art(cluster: Option<&Cluster>) -> DynamicArt { + cluster + .map_or_else(DynamicArt::fallback, DynamicArt::for_cluster) + .preview_edge(ART_PREVIEW_EDGE) +} + +/// Loads the home background art as soon as the launcher is up, rather than +/// when Home mounts. +/// +/// The art is three async hops deep — the cluster list, then the version +/// manifest holding its url, then the image itself — and none of them start +/// until something subscribes. Mounted at the root, they overlap the rest of +/// startup while the splash curtain is still up; left to Home, they run after +/// it has lifted and show as the built-in fallback lingering on screen. +/// +/// The queries are global and outlive this component's subscription, so Home +/// finds them already settled and renders the real art on its first frame. +#[derive(PartialEq, Clone, Copy)] +pub struct HomeArtPrefetch; + +impl Component for HomeArtPrefetch { + fn render(&self) -> impl IntoElement { + // Before the launcher is ready every query errors out, and a settled + // error is not retried until a new subscriber mounts. Waiting means the + // inner component's hooks run once, when they can actually succeed. + if !use_launcher().ready { + return rect().into_element(); + } + HomeArtWarm.into_element() + } +} + +#[derive(PartialEq, Clone, Copy)] +struct HomeArtWarm; + +impl Component for HomeArtWarm { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let art = { + let reader = clusters_query.read(); + let state = reader.state(); + // No active cluster this early, so this is the first in the list — + // the same one `AppHomeBackground` picks. + let clusters = state.ok().map_or(&[][..], Vec::as_slice); + home_art(home_cluster(clusters, None)) + }; + let _ = art.use_bytes(); + + rect().into_element() + } +} + #[derive(PartialEq, Clone, Copy)] pub struct AppHomeBackground; @@ -290,12 +352,9 @@ impl Component for AppHomeBackground { let reader = clusters_query.read(); let state = reader.state(); let clusters = state.ok().map_or(&[][..], Vec::as_slice); - let chosen = (*active_id.read()) - .and_then(|id| clusters.iter().find(|c| c.id == id)) - .or_else(|| clusters.first()); + let chosen = home_cluster(clusters, *active_id.read()); let dep = chosen.map(|c| c.id); - let art = chosen.map_or_else(DynamicArt::fallback, DynamicArt::for_cluster); - (art, dep) + (home_art(chosen), dep) }; let (_, art_bytes) = art.use_bytes(); diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index 9342e22d..ac882c8b 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -7,7 +7,7 @@ mod settings_shell; pub use animated_outlet::AnimatedAppOutlet; pub(crate) use app_shell::gradient_overlay_radial; -pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET}; +pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; pub use cluster_shell::{ClusterShell, cluster_content}; pub use onboarding_shell::OnboardingShell; pub use root_layout::RootLayout; diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 0aeb2b59..4211a04f 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -6,6 +6,7 @@ use crate::components::{ StatusBar, Toasts, UpdatePromptOverlay, }; use crate::hooks::{SplashState, use_provide_splash}; +use crate::layout::HomeArtPrefetch; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -73,5 +74,6 @@ impl Component for RootLayout { .child(ClusterUpdatePopup) .child(StatusBar) .child(SplashCurtain) + .child(HomeArtPrefetch) } } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs index efce9c88..06625b10 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs @@ -6,7 +6,7 @@ use std::time::Duration; use oneclient_core::clusters::Cluster; -use crate::components::DynamicArt; +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; use crate::hooks::use_settings_snapshot; use crate::layout::gradient_overlay_radial; use crate::theme::colors; @@ -102,10 +102,15 @@ impl Component for LoadingBackdrop { let pan_x = cx * sw * PARALLAX_STRENGTH; let pan_y = cy * sh * PARALLAX_STRENGTH; + // Left at the default edge on purpose: this is the same full-screen art + // the home background shows next, so asking for the same variant means + // the onboarding download is the one home renders instead of it having + // to fetch the same picture again at a different size. let art = match clusters.get(current) { - Some(cluster) => DynamicArt::for_cluster(cluster).max_edge(1280), - None => DynamicArt::fallback().max_edge(1280), - }; + Some(cluster) => DynamicArt::for_cluster(cluster), + None => DynamicArt::fallback(), + } + .preview_edge(ART_PREVIEW_EDGE); let fade = use_animation_with_dependencies(¤t, |conf, _| { conf.on_creation(OnCreation::Run); diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs index 9796a390..d0b44c1a 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -2,7 +2,7 @@ use super::*; use freya::router::RouterContext; -use crate::components::{Button, DynamicArt, Icon, IconType, ScrollArea}; +use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea}; use crate::hooks::ClusterBundles; use crate::routes::Route; use crate::theme::colors; @@ -189,7 +189,7 @@ fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement { rect() .width(Size::px(1.)) .height(Size::px(1.)) - .child(DynamicArt::for_cluster(&cb.cluster).max_edge(512)) + .child(DynamicArt::for_cluster(&cb.cluster).max_edge(ART_PREVIEW_EDGE)) .into_element() })) .into_element() diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 82d80b6d..4d36dab6 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -108,7 +108,7 @@ impl LauncherState { clusters, metadata: Mutex::new(MetadataStore::new()), bundles: Arc::new(BundlesManager::new()), - versions: Arc::new(VersionsManager::new()), + versions: Arc::new(VersionsManager::from_cache().await), images: ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), discord, diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index aae8c20f..06738f00 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -19,6 +19,36 @@ impl VersionsManager { } } + /// Seeds the manifest from the last synced copy on disk. + /// + /// Without this the manifest is empty until the network answers, and + /// everything read off it — a version's art, name, description — waits on a + /// request that, for a returning user, ends in a 304 and the very same file. + /// The UI would rather render slightly stale metadata now and pick up the + /// sync when it lands. + #[must_use] + pub async fn from_cache() -> Self { + let manifest = Self::cached_manifest() + .await + .unwrap_or_else(VersionsManifest::default); + + Self { + manifest: RwLock::new(manifest), + } + } + + async fn cached_manifest() -> Option { + let path = paths::caches_dir().ok()?.join("versions.json"); + let bytes = polyio::read(&path).await.ok()?; + match serde_json::from_slice(&bytes) { + Ok(manifest) => Some(manifest), + Err(err) => { + tracing::warn!("cached versions manifest is unreadable: {err}"); + None + } + } + } + #[tracing::instrument(level = "debug", skip(self, services))] pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { let Some((manifest, changed)) = Self::fetch_manifest(services).await? else {