mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
4027ab5fb3
commit
ff362d0d08
5 files changed
+441
-116
No files matched your search
@@ -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 {
|
||||||
|
|||||||
+356
-101
@@ -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,70 +709,74 @@ 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
|
||||||
let result = (|| {
|
.spawn_blocking(move || engine.refresh_offline(None));
|
||||||
let catalog = engine.catalog.lock();
|
}
|
||||||
let files = catalog.all_offline_files()?;
|
|
||||||
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
|
/// The offline index, against `targets` when the caller has just built a
|
||||||
let mut targets = HashMap::new();
|
/// library to take them from — otherwise one is built here, which on a
|
||||||
for title in &library.titles {
|
/// large catalog is most of what this costs.
|
||||||
for episode in title.episodes() {
|
fn refresh_offline(&self, targets: Option<HashMap<DownloadKey, PlaybackTarget>>) {
|
||||||
let target = PlaybackTarget::new(title, episode);
|
let result = (|| {
|
||||||
targets.insert(DownloadKey::from(&target), target);
|
let catalog = self.catalog.lock();
|
||||||
|
let files = catalog.all_offline_files()?;
|
||||||
|
let targets = match targets {
|
||||||
|
Some(targets) => targets,
|
||||||
|
None => download_targets(&Library::build(
|
||||||
|
catalog.all_files()?,
|
||||||
|
&catalog.all_watch_states()?,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let mut valid = HashSet::new();
|
||||||
|
let mut hydrated = Vec::new();
|
||||||
|
for ((share_id, link_id), file) in files {
|
||||||
|
let key = DownloadKey { share_id, link_id };
|
||||||
|
let path = self
|
||||||
|
.dirs
|
||||||
|
.offline_file(&key.share_id, &key.link_id, &file.revision_id);
|
||||||
|
let Some(expected) = file
|
||||||
|
.block_sizes
|
||||||
|
.iter()
|
||||||
|
.try_fold(0_u64, |sum, size| sum.checked_add(*size))
|
||||||
|
else {
|
||||||
|
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == expected) {
|
||||||
|
valid.insert(key.clone());
|
||||||
|
if let Some(target) = targets.get(&key).cloned() {
|
||||||
|
hydrated.push(DownloadItem {
|
||||||
|
key,
|
||||||
|
target,
|
||||||
|
state: DownloadState::Completed,
|
||||||
|
downloaded: expected,
|
||||||
|
total: expected,
|
||||||
|
rate: 0.0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// The catalog is an index, not the bytes. A manually
|
||||||
|
// removed or truncated file must stop wearing an
|
||||||
|
// offline badge and remain playable from the share.
|
||||||
|
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
|
||||||
}
|
}
|
||||||
let mut valid = HashSet::new();
|
|
||||||
let mut hydrated = Vec::new();
|
|
||||||
for ((share_id, link_id), file) in files {
|
|
||||||
let key = DownloadKey { share_id, link_id };
|
|
||||||
let path =
|
|
||||||
engine
|
|
||||||
.dirs
|
|
||||||
.offline_file(&key.share_id, &key.link_id, &file.revision_id);
|
|
||||||
let Some(expected) = file
|
|
||||||
.block_sizes
|
|
||||||
.iter()
|
|
||||||
.try_fold(0_u64, |sum, size| sum.checked_add(*size))
|
|
||||||
else {
|
|
||||||
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == expected) {
|
|
||||||
valid.insert(key.clone());
|
|
||||||
if let Some(target) = targets.get(&key).cloned() {
|
|
||||||
hydrated.push(DownloadItem {
|
|
||||||
key,
|
|
||||||
target,
|
|
||||||
state: DownloadState::Completed,
|
|
||||||
downloaded: expected,
|
|
||||||
total: expected,
|
|
||||||
rate: 0.0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// The catalog is an index, not the bytes. A manually
|
|
||||||
// removed or truncated file must stop wearing an
|
|
||||||
// offline badge and remain playable from the share.
|
|
||||||
catalog.remove_offline_file(&key.share_id, &key.link_id)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok::<_, pstr_core::Error>((valid, hydrated))
|
|
||||||
})();
|
|
||||||
match result {
|
|
||||||
Ok((files, hydrated)) => {
|
|
||||||
{
|
|
||||||
let mut downloads = engine.downloads.lock();
|
|
||||||
for item in hydrated {
|
|
||||||
downloads.items.entry(item.key.clone()).or_insert(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
engine.hydrate_partial_downloads();
|
|
||||||
engine.emit_downloads();
|
|
||||||
engine.emit(Event::OfflineFiles(files));
|
|
||||||
}
|
|
||||||
Err(error) => engine.fail("read offline downloads", error),
|
|
||||||
}
|
}
|
||||||
});
|
Ok::<_, pstr_core::Error>((valid, hydrated))
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok((files, hydrated)) => {
|
||||||
|
{
|
||||||
|
let mut downloads = self.downloads.lock();
|
||||||
|
for item in hydrated {
|
||||||
|
downloads.items.entry(item.key.clone()).or_insert(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.hydrate_partial_downloads();
|
||||||
|
self.emit_downloads();
|
||||||
|
self.emit(Event::OfflineFiles(files));
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
key: title_key,
|
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
|
||||||
image,
|
engine.emit(Event::Poster {
|
||||||
}),
|
key: title_key,
|
||||||
|
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`].
|
||||||
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
|
pub fn insert(&mut self, key: String, image: egui::ColorImage) {
|
||||||
self.textures.insert(key, texture);
|
// 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);
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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![
|
|
||||||
share_id,
|
|
||||||
link_id,
|
|
||||||
state.position_secs,
|
|
||||||
state.duration_secs,
|
|
||||||
state.watched as i64,
|
|
||||||
state.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
|
upsert.execute(params![
|
||||||
|
share_id,
|
||||||
|
link_id,
|
||||||
|
state.position_secs,
|
||||||
|
state.duration_secs,
|
||||||
|
state.watched as i64,
|
||||||
|
state.updated_at,
|
||||||
|
])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
Reference in new issue
Block a user