fix(ui): version art gets properly prefetched

This commit is contained in:
LynithDev 2026-07-29 00:34:40 +02:00
parent 4aaff39abc
commit e31d799e86
11 changed files with 161 additions and 25 deletions

View file

@ -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,
}
}

View file

@ -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<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
preview_edge: Option<u32>,
}
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<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
preview_edge: Option<u32>,
) -> (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 {

View file

@ -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;

View file

@ -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()

View file

@ -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<ClusterId>) -> 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();

View file

@ -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;

View file

@ -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)
}
}

View file

@ -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(&current, |conf, _| {
conf.on_creation(OnCreation::Run);

View file

@ -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()

View file

@ -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,

View file

@ -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<VersionsManifest> {
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<bool> {
let Some((manifest, changed)) = Self::fetch_manifest(services).await? else {