perf(app,core): bounded image cache, shrunk poster cache, in-place watch updates

- ImageCache drops the picture asked for longest ago past 256 MiB and
  uploads at most six textures a frame
- posters and thumbnails larger than a card are stored scaled down, so
  a cached grid no longer decodes and resizes full backdrops every launch
- a saved watch state patches the library in place instead of rebuilding
  it from the catalog; the offline index reuses the library just built
- download byte progress sends one row, not a sorted copy of every row
- the CJK fallback font is found and read off the UI thread
- the watch-state upsert uses a cached statement
This commit is contained in:
nvrl committed 2026-10-02 19:34:45 +02:00
1 parent 4027ab5fb3
commit ff362d0d08
5 files changed
+391 -66

No files matched your search

+22 -6
View File
@@ -565,9 +565,9 @@ impl App {
self.crawling = true; self.crawling = true;
self.crawl_progress = Some((share, found)); self.crawl_progress = Some((share, found));
} }
Event::Thumbnail { key, image } => self.thumbs.insert(ctx, key, image), Event::Thumbnail { key, image } => self.thumbs.insert(key, image),
Event::ThumbnailMissing { key } => self.thumbs.mark_missing(key), Event::ThumbnailMissing { key } => self.thumbs.mark_missing(key),
Event::Poster { key, image } => self.posters.insert(ctx, key, image), Event::Poster { key, image } => self.posters.insert(key, image),
Event::PosterMissing { key } => self.posters.mark_missing(key), Event::PosterMissing { key } => self.posters.mark_missing(key),
Event::Metadata(records) => { Event::Metadata(records) => {
// Artwork keyed by a title whose record changed may now // Artwork keyed by a title whose record changed may now
@@ -695,9 +695,15 @@ impl App {
}; };
} }
} }
// The position that was just saved is what the library }
// should now show. Event::Watched {
self.engine.load_library(); share_id,
link_id,
state,
} => {
if self.library.set_watch(&share_id, &link_id, state) {
self.view.stale = true;
}
} }
Event::Error(text) => { Event::Error(text) => {
self.opening = None; self.opening = None;
@@ -705,9 +711,20 @@ impl App {
} }
Event::Status(text) => self.note(ctx, text, false), Event::Status(text) => self.note(ctx, text, false),
Event::Downloads(downloads) => self.downloads = downloads, Event::Downloads(downloads) => self.downloads = downloads,
Event::DownloadProgress(item) => {
if let Some(row) = self.downloads.iter_mut().find(|row| row.key == item.key) {
*row = *item;
}
}
Event::OfflineFiles(files) => self.offline_files = files, Event::OfflineFiles(files) => self.offline_files = files,
} }
} }
// Pictures become textures a few per frame; the rest wait for the next.
let thumbs_waiting = self.thumbs.upload(ctx);
let posters_waiting = self.posters.upload(ctx);
if thumbs_waiting || posters_waiting {
ctx.request_repaint();
}
} }
fn share_name(&self, id: &str) -> String { fn share_name(&self, id: &str) -> String {
@@ -1075,7 +1092,6 @@ impl App {
link_id, link_id,
watch_state(position, duration, watched), watch_state(position, duration, watched),
); );
self.engine.load_library();
} }
Action::Player(command) => { Action::Player(command) => {
if let crate::playback::Command::SeekBy(seconds) = command { if let crate::playback::Command::SeekBy(seconds) = command {
+311 -56
View File
@@ -18,7 +18,8 @@
//! lands in the channel and sits there until the viewer happens to move the //! lands in the channel and sits there until the viewer happens to move the
//! mouse. //! mouse.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
@@ -237,6 +238,12 @@ pub enum Event {
}, },
/// That player is gone: its window was closed, or playback ended. /// That player is gone: its window was closed, or playback ended.
PlayerStopped { id: u64 }, PlayerStopped { id: u64 },
/// One file's watch state was saved, for the library to patch in place.
Watched {
share_id: String,
link_id: String,
state: WatchState,
},
/// Something the viewer should see, phrased for them. /// Something the viewer should see, phrased for them.
Error(String), Error(String),
/// Something the viewer might like to see, briefly. /// Something the viewer might like to see, briefly.
@@ -244,6 +251,10 @@ pub enum Event {
/// Full download-manager snapshot. Replacing rather than patching avoids /// Full download-manager snapshot. Replacing rather than patching avoids
/// stale rows when a task is removed. /// stale rows when a task is removed.
Downloads(Vec<DownloadItem>), Downloads(Vec<DownloadItem>),
/// One running download moved on. Sent instead of a snapshot for byte
/// progress, which arrives several times a second per download, while the
/// list it would copy and sort holds every file ever kept offline.
DownloadProgress(Box<DownloadItem>),
/// Completed local copies, for badges and online-only actions. /// Completed local copies, for badges and online-only actions.
OfflineFiles(HashSet<DownloadKey>), OfflineFiles(HashSet<DownloadKey>),
} }
@@ -685,8 +696,10 @@ impl Engine {
}; };
match result { match result {
Ok((files, watch)) => { Ok((files, watch)) => {
engine.emit(Event::LibraryLoaded(Library::build(files, &watch))); let library = Library::build(files, &watch);
engine.load_offline_files(); let targets = download_targets(&library);
engine.emit(Event::LibraryLoaded(library));
engine.refresh_offline(Some(targets));
} }
Err(error) => engine.fail("read the catalog", error), Err(error) => engine.fail("read the catalog", error),
} }
@@ -696,24 +709,29 @@ impl Engine {
/// Refresh the lightweight offline index used by title badges and actions. /// Refresh the lightweight offline index used by title badges and actions.
pub fn load_offline_files(&self) { pub fn load_offline_files(&self) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn_blocking(move || { self.runtime
.spawn_blocking(move || engine.refresh_offline(None));
}
/// The offline index, against `targets` when the caller has just built a
/// library to take them from — otherwise one is built here, which on a
/// large catalog is most of what this costs.
fn refresh_offline(&self, targets: Option<HashMap<DownloadKey, PlaybackTarget>>) {
let result = (|| { let result = (|| {
let catalog = engine.catalog.lock(); let catalog = self.catalog.lock();
let files = catalog.all_offline_files()?; let files = catalog.all_offline_files()?;
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?); let targets = match targets {
let mut targets = HashMap::new(); Some(targets) => targets,
for title in &library.titles { None => download_targets(&Library::build(
for episode in title.episodes() { catalog.all_files()?,
let target = PlaybackTarget::new(title, episode); &catalog.all_watch_states()?,
targets.insert(DownloadKey::from(&target), target); )),
} };
}
let mut valid = HashSet::new(); let mut valid = HashSet::new();
let mut hydrated = Vec::new(); let mut hydrated = Vec::new();
for ((share_id, link_id), file) in files { for ((share_id, link_id), file) in files {
let key = DownloadKey { share_id, link_id }; let key = DownloadKey { share_id, link_id };
let path = let path = self
engine
.dirs .dirs
.offline_file(&key.share_id, &key.link_id, &file.revision_id); .offline_file(&key.share_id, &key.link_id, &file.revision_id);
let Some(expected) = file let Some(expected) = file
@@ -748,18 +766,17 @@ impl Engine {
match result { match result {
Ok((files, hydrated)) => { Ok((files, hydrated)) => {
{ {
let mut downloads = engine.downloads.lock(); let mut downloads = self.downloads.lock();
for item in hydrated { for item in hydrated {
downloads.items.entry(item.key.clone()).or_insert(item); downloads.items.entry(item.key.clone()).or_insert(item);
} }
} }
engine.hydrate_partial_downloads(); self.hydrate_partial_downloads();
engine.emit_downloads(); self.emit_downloads();
engine.emit(Event::OfflineFiles(files)); self.emit(Event::OfflineFiles(files));
} }
Err(error) => engine.fail("read offline downloads", error), Err(error) => self.fail("read offline downloads", error),
} }
});
} }
/// Crawl one share, or all of them, and reload the library. /// Crawl one share, or all of them, and reload the library.
@@ -917,7 +934,13 @@ impl Engine {
.set_watch_state(share_id, link_id, state) .set_watch_state(share_id, link_id, state)
{ {
tracing::warn!("save watch state for {link_id}: {error}"); tracing::warn!("save watch state for {link_id}: {error}");
return;
} }
self.emit(Event::Watched {
share_id: share_id.to_owned(),
link_id: link_id.to_owned(),
state: *state,
});
} }
pub fn title_track_prefs(&self, key: &str) -> Option<TitleTrackPrefs> { pub fn title_track_prefs(&self, key: &str) -> Option<TitleTrackPrefs> {
@@ -1259,6 +1282,20 @@ impl Engine {
self.emit_downloads(); self.emit_downloads();
} }
/// [`Self::update_download`] for a change to one row only: the row goes
/// to the UI on its own rather than in a fresh snapshot of every row.
fn progress_download(&self, key: &DownloadKey, update: impl FnOnce(&mut DownloadItem)) {
let item = {
let mut downloads = self.downloads.lock();
let Some(item) = downloads.items.get_mut(key) else {
return;
};
update(item);
item.clone()
};
self.emit(Event::DownloadProgress(Box::new(item)));
}
fn partial_paths(&self, key: &DownloadKey) -> (std::path::PathBuf, std::path::PathBuf) { fn partial_paths(&self, key: &DownloadKey) -> (std::path::PathBuf, std::path::PathBuf) {
let partial = self let partial = self
.dirs .dirs
@@ -1457,7 +1494,7 @@ impl Engine {
{ {
let seconds = last_snapshot.elapsed().as_secs_f64(); let seconds = last_snapshot.elapsed().as_secs_f64();
let instant = (offset - snapshot_bytes) as f64 / seconds.max(f64::EPSILON); let instant = (offset - snapshot_bytes) as f64 / seconds.max(f64::EPSILON);
self.update_download(&key, |item| { self.progress_download(&key, |item| {
item.state = DownloadState::Running; item.state = DownloadState::Running;
item.downloaded = offset; item.downloaded = offset;
// Smoothed, or the time left jumps about with every block // Smoothed, or the time left jumps about with every block
@@ -1536,7 +1573,17 @@ impl Engine {
self.runtime.spawn(async move { self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await { if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_thumbnail(&bytes) { match decode_thumbnail(&bytes) {
Ok(image) => return engine.emit(Event::Thumbnail { key, image }), Ok(decoded) => {
// A cache written before entries were stored shrunk
// holds the full preview; shrink it once, on its way out.
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
}
return engine.emit(Event::Thumbnail {
key,
image: decoded.image,
});
}
// A truncated or corrupt cache entry is not worth reporting; // A truncated or corrupt cache entry is not worth reporting;
// fall through and fetch it again. // fall through and fetch it again.
Err(error) => tracing::debug!("cached thumbnail {key}: {error}"), Err(error) => tracing::debug!("cached thumbnail {key}: {error}"),
@@ -1579,13 +1626,14 @@ impl Engine {
return engine.emit(Event::ThumbnailMissing { key }); return engine.emit(Event::ThumbnailMissing { key });
}; };
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(&path, &bytes).await;
match decode_thumbnail(&bytes) { match decode_thumbnail(&bytes) {
Ok(image) => engine.emit(Event::Thumbnail { key, image }), Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Thumbnail {
key,
image: decoded.image,
})
}
Err(error) => { Err(error) => {
tracing::debug!("decode thumbnail {key}: {error}"); tracing::debug!("decode thumbnail {key}: {error}");
engine.emit(Event::ThumbnailMissing { key }); engine.emit(Event::ThumbnailMissing { key });
@@ -1937,10 +1985,13 @@ impl Engine {
self.runtime.spawn(async move { self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await { if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_thumbnail(&bytes) { match decode_thumbnail(&bytes) {
Ok(image) => { Ok(decoded) => {
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
}
return engine.emit(Event::Poster { return engine.emit(Event::Poster {
key: title_key, key: title_key,
image, image: decoded.image,
}); });
} }
Err(error) => tracing::debug!("cached poster {title_key}: {error}"), Err(error) => tracing::debug!("cached poster {title_key}: {error}"),
@@ -1960,16 +2011,14 @@ impl Engine {
} }
}; };
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(&path, &bytes).await;
match decode_thumbnail(&bytes) { match decode_thumbnail(&bytes) {
Ok(image) => engine.emit(Event::Poster { Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster {
key: title_key, key: title_key,
image, image: decoded.image,
}), })
}
Err(error) => { Err(error) => {
tracing::debug!("decode poster for {title_key}: {error}"); tracing::debug!("decode poster for {title_key}: {error}");
engine.emit(Event::PosterMissing { key: title_key }) engine.emit(Event::PosterMissing { key: title_key })
@@ -1979,6 +2028,18 @@ impl Engine {
} }
} }
/// Every file in `library`, by the key its download is filed under.
fn download_targets(library: &Library) -> HashMap<DownloadKey, PlaybackTarget> {
let mut targets = HashMap::new();
for title in &library.titles {
for episode in title.episodes() {
let target = PlaybackTarget::new(title, episode);
targets.insert(DownloadKey::from(&target), target);
}
}
targets
}
/// A short, filesystem-safe name for a URL. /// A short, filesystem-safe name for a URL.
fn digest(url: &str) -> String { fn digest(url: &str) -> String {
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
@@ -2117,24 +2178,61 @@ async fn write_marker_atomically(
Ok(()) Ok(())
} }
/// A picture ready to upload, and — when it had to be scaled down — the
/// scaled copy encoded again, for the disk cache to keep instead.
struct Decoded {
image: egui::ColorImage,
smaller: Option<Vec<u8>>,
}
/// Decode image bytes into something egui can upload, scaled down to /// Decode image bytes into something egui can upload, scaled down to
/// [`THUMBNAIL_MAX_EDGE`]. /// [`THUMBNAIL_MAX_EDGE`].
/// ///
/// Runs on the runtime rather than the UI thread: a JPEG decode per tile during /// Runs on the runtime rather than the UI thread: a JPEG decode per tile during
/// the first paint of a large library is visible as dropped frames. /// the first paint of a large library is visible as dropped frames.
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<egui::ColorImage> { ///
/// A provider's backdrop is often 1920 px or more, and decoding and resizing
/// that every launch was most of what painting a cached grid cost. The scaled
/// copy is handed back so the cache holds that instead, and the next launch
/// decodes a picture already the size it is drawn at.
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
let decoded = image::load_from_memory(bytes)?; let decoded = image::load_from_memory(bytes)?;
let decoded = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE { let (decoded, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE {
decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE) let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE);
let encoded = encode_for_cache(&scaled);
(scaled, encoded)
} else { } else {
decoded (decoded, None)
}; };
let rgba = decoded.to_rgba8(); let rgba = decoded.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize]; let size = [rgba.width() as usize, rgba.height() as usize];
Ok(egui::ColorImage::from_rgba_unmultiplied( Ok(Decoded {
size, image: egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_flat_samples().as_slice()),
rgba.as_flat_samples().as_slice(), smaller,
)) })
}
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
/// holding the original — slower to load, never wrong.
fn encode_for_cache(image: &image::DynamicImage) -> Option<Vec<u8>> {
let mut bytes = std::io::Cursor::new(Vec::new());
let encoded = if image.color().has_alpha() {
image.write_to(&mut bytes, image::ImageFormat::Png)
} else {
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 88)
.encode_image(&image.to_rgb8())
};
encoded.ok().map(|()| bytes.into_inner())
}
/// Write a cached picture. Best effort: the cache is a convenience, and a
/// write that fails only means the next launch fetches it again.
async fn store_image(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let _ = tokio::fs::write(path, bytes).await;
} }
/// Watch state as of now, for a position in a file. /// Watch state as of now, for a position in a file.
@@ -2168,14 +2266,58 @@ pub fn describe_failures(failures: &[(String, String)]) -> Option<String> {
/// must be remembered as not existing. Without that second half, every render /// must be remembered as not existing. Without that second half, every render
/// pays a round-trip per unmatched tile, which is exactly what /// pays a round-trip per unmatched tile, which is exactly what
/// `proton-drive-linux`'s photo grid had to learn. /// `proton-drive-linux`'s photo grid had to learn.
#[derive(Default)] ///
/// It is also bounded. Scrolling a large library once used to keep a texture
/// for every title in it for the rest of the session; past [`IMAGE_BUDGET`]
/// bytes the picture asked for longest ago goes, and comes back from the disk
/// cache if it is asked for again.
pub struct ImageCache { pub struct ImageCache {
textures: HashMap<String, egui::TextureHandle>, textures: HashMap<String, Entry>,
requested: std::collections::HashSet<String>, requested: HashSet<String>,
missing: std::collections::HashSet<String>, missing: HashSet<String>,
/// Decoded, waiting for a frame with upload budget left.
pending: VecDeque<(String, egui::ColorImage)>,
/// A logical clock: one tick per lookup, so "asked for longest ago" needs
/// no wall time.
clock: u64,
bytes: usize,
budget: usize,
}
struct Entry {
texture: egui::TextureHandle,
used: u64,
bytes: usize,
}
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
/// a megabyte, so this is a few hundred tiles — several screens of grid.
const IMAGE_BUDGET: usize = 256 << 20;
/// Textures uploaded per frame. A cached grid answers dozens of pictures at
/// once, and uploading them all in one frame is a visible hitch; spread over a
/// few frames they arrive faster than the eye follows.
const UPLOADS_PER_FRAME: usize = 6;
impl Default for ImageCache {
fn default() -> Self {
Self::with_budget(IMAGE_BUDGET)
}
} }
impl ImageCache { impl ImageCache {
fn with_budget(budget: usize) -> Self {
Self {
textures: HashMap::new(),
requested: HashSet::new(),
missing: HashSet::new(),
pending: VecDeque::new(),
clock: 0,
bytes: 0,
budget,
}
}
/// The texture for `key`, calling `fetch` the first time it is asked for. /// The texture for `key`, calling `fetch` the first time it is asked for.
/// ///
/// `None` while a fetch is in flight, and forever for a picture that turned /// `None` while a fetch is in flight, and forever for a picture that turned
@@ -2184,8 +2326,10 @@ impl ImageCache {
/// Borrows the key: this is asked once per tile per frame, and a hit — the /// Borrows the key: this is asked once per tile per frame, and a hit — the
/// usual answer — should not cost an allocation. /// usual answer — should not cost an allocation.
pub fn texture(&mut self, key: &str, fetch: impl FnOnce()) -> Option<egui::TextureHandle> { pub fn texture(&mut self, key: &str, fetch: impl FnOnce()) -> Option<egui::TextureHandle> {
if let Some(texture) = self.textures.get(key) { self.clock += 1;
return Some(texture.clone()); if let Some(entry) = self.textures.get_mut(key) {
entry.used = self.clock;
return Some(entry.texture.clone());
} }
if !self.missing.contains(key) && !self.requested.contains(key) { if !self.missing.contains(key) && !self.requested.contains(key) {
self.requested.insert(key.to_owned()); self.requested.insert(key.to_owned());
@@ -2194,9 +2338,56 @@ impl ImageCache {
None None
} }
pub fn insert(&mut self, ctx: &egui::Context, key: String, image: egui::ColorImage) { /// Queue a decoded picture. It becomes a texture in [`Self::upload`].
pub fn insert(&mut self, key: String, image: egui::ColorImage) {
// Forgotten while it was being fetched: the answer is for a question
// nobody is asking any more.
if self.requested.contains(&key) {
self.pending.push_back((key, image));
}
}
/// Upload up to this frame's share of queued pictures. Returns whether any
/// are still waiting, so the caller can ask for another frame.
pub fn upload(&mut self, ctx: &egui::Context) -> bool {
for _ in 0..UPLOADS_PER_FRAME {
let Some((key, image)) = self.pending.pop_front() else {
break;
};
let bytes = image.pixels.len() * 4;
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR); let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.textures.insert(key, texture); self.clock += 1;
let entry = Entry {
texture,
used: self.clock,
bytes,
};
if let Some(old) = self.textures.insert(key, entry) {
self.bytes -= old.bytes;
}
self.bytes += bytes;
self.evict();
}
!self.pending.is_empty()
}
/// Drop the pictures asked for longest ago until the rest fit the budget.
fn evict(&mut self) {
while self.bytes > self.budget && self.textures.len() > 1 {
let Some(oldest) = self
.textures
.iter()
.min_by_key(|(_, entry)| entry.used)
.map(|(key, _)| key.clone())
else {
break;
};
if let Some(entry) = self.textures.remove(&oldest) {
self.bytes -= entry.bytes;
}
// Asked for again, it is fetched again — from the disk cache.
self.requested.remove(&oldest);
}
} }
pub fn mark_missing(&mut self, key: String) { pub fn mark_missing(&mut self, key: String) {
@@ -2205,9 +2396,12 @@ impl ImageCache {
/// Forget one picture, so the next ask fetches it again. /// Forget one picture, so the next ask fetches it again.
pub fn forget(&mut self, key: &str) { pub fn forget(&mut self, key: &str) {
self.textures.remove(key); if let Some(entry) = self.textures.remove(key) {
self.bytes -= entry.bytes;
}
self.requested.remove(key); self.requested.remove(key);
self.missing.remove(key); self.missing.remove(key);
self.pending.retain(|(pending, _)| pending != key);
} }
/// Forget everything. Used when the catalog is replaced, so a recrawl that /// Forget everything. Used when the catalog is replaced, so a recrawl that
@@ -2217,6 +2411,67 @@ impl ImageCache {
self.textures.clear(); self.textures.clear();
self.requested.clear(); self.requested.clear();
self.missing.clear(); self.missing.clear();
self.pending.clear();
self.bytes = 0;
}
}
#[cfg(test)]
mod image_cache_tests {
use super::*;
fn picture() -> egui::ColorImage {
egui::ColorImage::filled([4, 4], egui::Color32::WHITE)
}
#[test]
fn a_full_cache_drops_the_picture_asked_for_longest_ago() {
let ctx = egui::Context::default();
// Room for two 4×4 pictures.
let mut cache = ImageCache::with_budget(2 * 64);
for key in ["a", "b"] {
cache.texture(key, || {});
cache.insert(key.into(), picture());
}
cache.upload(&ctx);
// "a" is looked at again, so "b" is now the stale one.
assert!(cache.texture("a", || {}).is_some());
cache.texture("c", || {});
cache.insert("c".into(), picture());
cache.upload(&ctx);
assert!(cache.texture("a", || {}).is_some());
assert!(cache.texture("c", || {}).is_some());
let mut fetched = false;
assert!(cache.texture("b", || fetched = true).is_none());
assert!(
fetched,
"an evicted picture is fetched again when asked for"
);
}
#[test]
fn uploads_are_spread_over_frames() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
for index in 0..UPLOADS_PER_FRAME + 1 {
let key = format!("{index}");
cache.texture(&key, || {});
cache.insert(key, picture());
}
assert!(cache.upload(&ctx), "one picture is left for the next frame");
assert!(!cache.upload(&ctx));
}
#[test]
fn an_answer_for_a_forgotten_picture_is_dropped() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
cache.texture("a", || {});
cache.forget("a");
cache.insert("a".into(), picture());
cache.upload(&ctx);
assert!(cache.textures.is_empty());
} }
} }
+15
View File
@@ -99,7 +99,21 @@ impl Role {
/// Install a real CJK fallback before the default egui fonts. egui ships a /// Install a real CJK fallback before the default egui fonts. egui ships a
/// compact Latin font, but it intentionally does not bundle the multi-megabyte /// compact Latin font, but it intentionally does not bundle the multi-megabyte
/// CJK families. Native desktops already provide one, so use it when present. /// CJK families. Native desktops already provide one, so use it when present.
///
/// On a thread of its own: `fc-match` and reading a font file of tens of
/// megabytes held the first frame back by a visible moment, and every name
/// that needs the fallback is still loading from the catalog by then anyway.
pub fn install_font_fallbacks(ctx: &egui::Context) { pub fn install_font_fallbacks(ctx: &egui::Context) {
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("fonts".into())
.spawn(move || install_cjk_fallback(&ctx));
if let Err(error) = spawned {
tracing::debug!("font fallback thread: {error}");
}
}
fn install_cjk_fallback(ctx: &egui::Context) {
let Some((path, index)) = system_cjk_font() else { let Some((path, index)) = system_cjk_font() else {
return; return;
}; };
@@ -116,6 +130,7 @@ pub fn install_font_fallbacks(ctx: &egui::Context) {
fonts.families.entry(family).or_default().push(name.clone()); fonts.families.entry(family).or_default().push(name.clone());
} }
ctx.set_fonts(fonts); ctx.set_fonts(fonts);
ctx.request_repaint();
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+6 -4
View File
@@ -610,7 +610,9 @@ impl Catalog {
/// Record where playback is. /// Record where playback is.
pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> { pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> {
self.conn.execute( // Cached: playback saves its position every few seconds, and this is
// the one write that runs on a clock rather than on a click.
let mut upsert = self.conn.prepare_cached(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (share_id, link_id) DO UPDATE SET ON CONFLICT (share_id, link_id) DO UPDATE SET
@@ -618,15 +620,15 @@ impl Catalog {
duration_secs = excluded.duration_secs, duration_secs = excluded.duration_secs,
watched = excluded.watched, watched = excluded.watched,
updated_at = excluded.updated_at", updated_at = excluded.updated_at",
params![ )?;
upsert.execute(params![
share_id, share_id,
link_id, link_id,
state.position_secs, state.position_secs,
state.duration_secs, state.duration_secs,
state.watched as i64, state.watched as i64,
state.updated_at, state.updated_at,
], ])?;
)?;
Ok(()) Ok(())
} }
+37
View File
@@ -331,6 +331,28 @@ impl Library {
self.titles.is_empty() self.titles.is_empty()
} }
/// Record a new watch state for one file in place. Returns whether the
/// file is in the library.
///
/// What a save during playback or a "mark watched" needs: rebuilding the
/// whole library from the catalog for one changed row re-reads and re-sorts
/// every file, and on a large share that is a visible pause.
pub fn set_watch(&mut self, share_id: &str, link_id: &str, state: WatchState) -> bool {
let episode = self
.titles
.iter_mut()
.flat_map(|title| title.seasons.iter_mut())
.flat_map(|season| season.episodes.iter_mut())
.find(|episode| episode.node.share_id == share_id && episode.node.link_id == link_id);
match episode {
Some(episode) => {
episode.watch = Some(state);
true
}
None => false,
}
}
pub fn get(&self, key: &str) -> Option<&Title> { pub fn get(&self, key: &str) -> Option<&Title> {
self.titles.iter().find(|title| title.key == key) self.titles.iter().find(|title| title.key == key)
} }
@@ -590,6 +612,21 @@ mod tests {
assert_eq!(library.continue_watching().len(), 1); assert_eq!(library.continue_watching().len(), 1);
} }
#[test]
fn a_watch_state_set_in_place_matches_a_rebuild() {
let nodes = vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
];
let mut states = HashMap::new();
let mut library = Library::build(nodes.clone(), &states);
let state = watch(700.0, 1440.0, false, 20);
assert!(library.set_watch("s", "2", state));
states.insert(("s".into(), "2".into()), state);
assert_eq!(library, Library::build(nodes, &states));
assert!(!library.set_watch("s", "9", state));
}
#[test] #[test]
fn next_up_falls_through_to_the_first_unwatched() { fn next_up_falls_through_to_the_first_unwatched() {
let nodes = vec![ let nodes = vec![