added photos thumbnail caching + bigger photos

This commit is contained in:
nvrl committed 2026-07-15 01:00:43 +02:00
1 parent ae0d374ec0
commit cc744d56c7
8 files changed
+1616 -144

No files matched your search

+21 -2
View File
@@ -213,15 +213,34 @@ pub struct PhotoItem {
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
/// with [`Request::PhotoThumbs`].
pub thumb_path: Option<String>,
/// File name, when the daemon knows it.
pub name: Option<String>,
/// Aspect ratio (w/h), remembered by the daemon from the last time this
/// photo's thumbnail was decoded. Lets the gallery justify its rows correctly
/// on the first frame instead of guessing and reflowing as images land.
pub ratio: Option<f64>,
/// True when this photo can never be given a thumbnail — the server has none
/// and its bytes could not be decoded locally. The tile shows a placeholder
/// rather than waiting for an image that will never come.
pub no_thumb: bool,
}
/// One thumbnail in a [`Response::Thumbs`] batch.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoThumb {
pub uid: String,
/// On-disk path, or `None` when the photo genuinely has no thumbnail (or the
/// fetch failed) — a front-end can then stop asking for it.
/// On-disk path, or `None` when there is no thumbnail to serve *yet*.
pub path: Option<String>,
/// True when the daemon is making this thumbnail itself, because the server
/// has none: the photo's full file is downloading and will be scaled when it
/// lands. A `None` path with `pending` set means "ask again shortly"; a `None`
/// path *without* it means the photo can never have a thumbnail, and a
/// front-end should stop asking.
///
/// Generation is not made to block the reply: one 20 MB camera photo takes
/// far longer to fetch than the whole rest of a batch, and holding the batch
/// for it would leave a screenful of ready thumbnails unpainted.
pub pending: bool,
}
/// The daemon's reply to a [`Request`].
+356 -1
View File
@@ -10,6 +10,7 @@
//! applies the forward-only schema migrations. Write-through of nodes, the
//! event cursor, FTS, and the cache index land in later phases on this schema.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
@@ -21,7 +22,16 @@ use crate::Result;
use crate::localindex::LocalEntry;
/// Current schema version. Bump on every forward migration added below.
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_VERSION: i64 = 7;
/// A photo whose thumbnail state is not known yet: it has never been asked for.
pub const THUMB_UNKNOWN: i64 = 0;
/// A thumbnail exists for this photo — served by the server, or generated locally
/// from the full file when the server had none.
pub const THUMB_HAVE: i64 = 1;
/// This photo can never be given a thumbnail: the server has none and the bytes
/// could not be decoded locally either. Never retried.
pub const THUMB_NONE: i64 = 2;
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
@@ -42,6 +52,31 @@ pub struct SearchHit {
pub path: String,
}
/// One photo of the persisted timeline. The timeline itself is server-ordered
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
/// are locally learned and survive a refresh of the timeline.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPhoto {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
pub ratio: Option<f64>,
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
pub thumb_state: i64,
}
/// One trashed node, as persisted for the Trash page. Trashed nodes live outside
/// the mounted tree, so a uid is their only handle.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredTrash {
pub uid: String,
pub name: String,
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
}
/// Handle to the unified metadata database.
///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
@@ -123,6 +158,9 @@ impl Db {
if current < 6 {
tx.execute_batch(MIGRATION_V6)?;
}
if current < 7 {
tx.execute_batch(MIGRATION_V7)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -299,6 +337,189 @@ impl Db {
Ok(())
}
/// Read a `sync_state` value as an integer (the freshness stamps of the
/// photos timeline and the trash listing are kept there).
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = conn
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()?;
Ok(v.and_then(|v| v.parse().ok()))
}
/// Write a `sync_state` integer value.
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value.to_string()],
)?;
Ok(())
}
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
pub fn clear_state(&self, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
/// Replace the persisted photos timeline with `items` (newest first), keeping
/// what was locally learned about the photos that are still there: a ratio or
/// a thumbnail verdict costs a download to rediscover, while the server order
/// and capture times are cheap and authoritative.
///
/// Photos no longer in the timeline are dropped, so a deletion on another
/// client doesn't leave a ghost tile behind.
pub fn photos_replace(&self, items: &[(String, i64, Option<String>)]) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, i64)> = {
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?)))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM photos", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
for (seq, (uid, capture_time, name)) in items.iter().enumerate() {
let (ratio, thumb_state) = learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN));
stmt.execute(params![uid, capture_time, name, ratio, thumb_state, seq as i64])?;
}
}
tx.commit()?;
Ok(())
}
/// One page of the persisted timeline, newest first.
pub fn photos_page(&self, offset: usize, limit: usize) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
ORDER BY seq LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt.query_map(params![limit as i64, offset as i64], |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
})
})?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
}
Ok(photos)
}
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
/// path, which needs each photo's capture time (the cache validity tag) and
/// its thumbnail verdict.
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
})
})?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
}
Ok(photos)
}
/// Record what a thumbnail attempt learned: whether the photo now has one
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
/// seen. A `None` ratio leaves any previously learned one alone.
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
/// Number of photos in the persisted timeline.
pub fn photos_count(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// Replace the persisted trash listing.
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
for item in items {
stmt.execute(params![
item.uid,
item.name,
item.is_dir as i64,
item.size,
item.mtime
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted trash listing, folders first then by name — the order the
/// Trash page shows it in.
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT uid, name, is_dir, size, mtime FROM trash
ORDER BY is_dir DESC, name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredTrash {
uid: r.get(0)?,
name: r.get(1)?,
is_dir: r.get::<_, i64>(2)? != 0,
size: r.get(3)?,
mtime: r.get(4)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
/// Children of `parent`, for the offline `ensure_children` fast path when a
/// listed folder's `children` map entry was trimmed mid-run. Returns `None`
/// when the folder is not marked `listed` (listing unknown, must re-fetch).
@@ -849,6 +1070,35 @@ CREATE VIRTUAL TABLE local_fts USING fts5(
);
";
/// Schema v7: the photos timeline and the trash listing become persistent, so
/// opening the app paints them from disk instead of re-fetching the world (both
/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all).
///
/// `photos.seq` preserves the server's newest-first order, which is the only order
/// the timeline has — `capture_time` ties are common (a burst of shots) and would
/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally
/// learned*: they cost a download to rediscover, so `photos_replace` carries them
/// across a refresh while capture times and order come from the server.
const MIGRATION_V7: &str = "
CREATE TABLE photos (
uid TEXT PRIMARY KEY,
capture_time INTEGER NOT NULL,
name TEXT,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE INDEX idx_photos_seq ON photos(seq);
CREATE TABLE trash (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0
);
";
#[cfg(test)]
mod tests {
use super::*;
@@ -879,6 +1129,111 @@ mod tests {
serde_json::from_value(v).unwrap()
}
#[test]
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None),
("p2".into(), 200, None),
("p3".into(), 100, None),
])
.unwrap();
// A thumbnail attempt teaches us p1's ratio and that p2 can never have one.
db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap();
db.photo_set_thumb("p2", THUMB_NONE, None).unwrap();
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
db.photos_replace(&[
("p0".into(), 400, Some("new.jpg".into())),
("p1".into(), 300, None),
("p2".into(), 200, None),
])
.unwrap();
let page = db.photos_page(0, 10).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
"server order is preserved, and the dropped photo is gone"
);
assert_eq!(page[0].name.as_deref(), Some("new.jpg"));
// Ratios and verdicts cost a download to rediscover: they survive a refresh.
assert_eq!(page[1].ratio, Some(1.5));
assert_eq!(page[1].thumb_state, THUMB_HAVE);
assert_eq!(page[2].thumb_state, THUMB_NONE);
// A photo we know nothing about yet starts blank.
assert_eq!(page[0].ratio, None);
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
assert_eq!(db.photos_count().unwrap(), 3);
let by_uid = db.photos_by_uid(&["p2".into()]).unwrap();
assert_eq!(by_uid.len(), 1);
assert_eq!(by_uid[0].capture_time, 200);
}
#[test]
fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap();
let items: Vec<_> = (0..5)
.map(|i| (format!("p{i}"), 500 - i as i64, None))
.collect();
db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"]
);
assert!(db.photos_page(9, 2).unwrap().is_empty());
}
#[test]
fn trash_replace_lists_folders_first() {
let db = Db::open_in_memory().unwrap();
db.trash_replace(&[
StoredTrash {
uid: "t1".into(),
name: "zeta.txt".into(),
is_dir: false,
size: 10,
mtime: 1,
},
StoredTrash {
uid: "t2".into(),
name: "Alpha".into(),
is_dir: true,
size: 0,
mtime: 2,
},
])
.unwrap();
let items = db.trash_list().unwrap();
assert_eq!(
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
["Alpha", "zeta.txt"]
);
// A replace is a replace: emptying the trash on the server empties it here.
db.trash_replace(&[]).unwrap();
assert!(db.trash_list().unwrap().is_empty());
}
#[test]
fn state_stamps_round_trip_and_clear() {
let db = Db::open_in_memory().unwrap();
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None);
db.set_state_i64("photos_synced_ms", 1234).unwrap();
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234));
db.clear_state("photos_synced_ms").unwrap();
assert_eq!(
db.state_i64("photos_synced_ms").unwrap(),
None,
"a cleared stamp reads as never fetched, so the next request blocks on a refresh"
);
}
fn folder(link: &str, parent: Option<&str>, name: &str) -> Node {
let parent = match parent {
Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}),
+1
View File
@@ -15,3 +15,4 @@ tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
image.workspace = true
+525 -124
View File
@@ -31,7 +31,7 @@
//! set, so a multi-GiB write never buffers in RAM and only the untouched
//! remainder of the file is pulled from the remote — lazily, at commit.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::File;
use std::io::{BufRead, BufReader, Write as _};
@@ -55,13 +55,13 @@ use pdfs_core::control::{
DirEntry, LocalHit, PhotoItem, PhotoThumb, Request as CtlRequest, Response as CtlResponse,
SearchHit, TransferDirection,
};
use pdfs_core::db::{Db, StoredNode};
use pdfs_core::db::{self, Db, StoredNode, StoredPhoto, StoredTrash};
use pdfs_core::localindex;
use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
ProtonPhotosClient, ThumbnailType,
DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient, ProtonPhotosClient,
ThumbnailType,
};
mod transfers;
@@ -75,10 +75,33 @@ const TTL: Duration = Duration::from_secs(30);
/// How often the background task polls the remote event cursor.
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// How long a fetched photos timeline stays good before the next page request
/// re-fetches it. The SDK hands back the whole timeline at once, so we cache it
/// in [`Core`] and serve every page from memory until it goes stale.
const TIMELINE_TTL: Duration = Duration::from_secs(60);
/// How long the persisted photos timeline stays good before a page request
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
/// in the DB and every page is sliced from there; a stale one is still served
/// immediately and refreshed in the background.
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
/// The same, for the persisted trash listing. Shorter, because the trash is the
/// one listing a user changes and then immediately looks at — though our own
/// mutations also invalidate it outright, so this only covers other clients.
const TRASH_TTL: Duration = Duration::from_secs(60);
/// `sync_state` keys for the freshness stamps of the two persisted listings, and
/// for whether the account has a photos volume at all (so an account without one
/// doesn't re-ask the server on every page request).
const PHOTOS_SYNCED_MS: &str = "photos_synced_ms";
const PHOTOS_AVAILABLE: &str = "photos_available";
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
/// Longest edge, in px, of a thumbnail generated locally for a photo the server
/// has none for. Matches the server's own thumbnail scale closely enough that a
/// tile can't tell them apart.
const THUMB_EDGE: u32 = 512;
/// JPEG quality of a locally generated thumbnail.
const THUMB_QUALITY: u8 = 82;
/// How many photos may be downloaded at once to generate their missing
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
/// saturate the link and starve the rest of the daemon.
const THUMB_GEN_CONCURRENCY: usize = 4;
/// How stale the local-file index may get before the background scanner rebuilds
/// it. A rescan is a full walk of `$HOME`, so this trades index freshness against
@@ -310,10 +333,16 @@ struct Core {
/// `State` maps. Every mutation writes through here, and the maps rehydrate
/// from it on mount (plan.md P1).
db: Arc<Db>,
/// Cached full photos timeline (newest first), so paged `PhotosTimeline`
/// requests slice memory instead of re-fetching the whole timeline per page.
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
timeline: Arc<Mutex<Option<TimelineCache>>>,
/// True while a background refresh of the photos timeline (resp. the trash) is
/// already running, so a burst of page requests against a stale listing kicks
/// off one refresh rather than one per request.
timeline_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
/// Photos whose missing thumbnail is being generated right now. A tile that is
/// still on screen asks for its thumbnail again every few seconds, and each of
/// those downloads is a full-size photo — so an in-flight uid is never started
/// twice.
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
/// across the FUSE session and the control-socket task.
transfers: Arc<TransferRegistry>,
@@ -322,12 +351,6 @@ struct Core {
indexing: Arc<AtomicBool>,
}
/// The whole photos timeline plus when it was fetched, for TTL freshness.
struct TimelineCache {
items: Vec<PhotosTimelineItem>,
fetched: Instant,
}
impl Core {
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
/// start serves previously-seen metadata (stable inodes, instant listings)
@@ -1047,132 +1070,314 @@ impl Core {
.collect())
}
/// A page of the photos timeline (newest first). Metadata only: a thumbnail
/// path is attached for photos whose thumbnail is *already* cached, and no
/// thumbnail is downloaded here — the page comes back as fast as the cached
/// timeline can be sliced, and the front-end pulls the thumbnails it actually
/// paints via [`Core::photo_thumbs`]. `Ok(None)` when the account has no
/// photos volume.
/// A page of the photos timeline (newest first), sliced out of the DB.
/// `Ok(None)` when the account has no photos volume.
///
/// The SDK's `enumerate_timeline` returns the *whole* timeline, so the first
/// request (or the first after [`TIMELINE_TTL`] elapses) fetches and caches
/// it in [`Core::timeline`]; every page then slices that cached vec, so
/// "load more" scrolling doesn't re-hit the network per page.
/// Stale-while-revalidate: a persisted timeline is served *immediately*, and
/// refreshed on the runtime if it is older than [`TIMELINE_TTL`] — so opening
/// the app paints from disk rather than waiting on a full `enumerate_timeline`
/// (which returns the whole timeline, not a page). Only an empty DB blocks on
/// the network, i.e. the very first run.
///
/// Metadata only: a thumbnail path is attached for photos whose thumbnail is
/// already cached, and nothing is downloaded here — the front-end pulls the
/// thumbnails it actually paints via [`Core::photo_thumbs`].
fn photos_timeline(
&self,
offset: usize,
limit: usize,
) -> Result<Option<Vec<PhotoItem>>, String> {
let page: Vec<PhotosTimelineItem> = {
// Refresh the cached timeline if absent or stale. The lock is only
// held for the freshness check and the store, never across the fetch.
let stale = {
let guard = self.timeline.lock().unwrap();
guard
.as_ref()
.is_none_or(|c| c.fetched.elapsed() >= TIMELINE_TTL)
};
if stale {
let photos = self.photos();
if self
.rt
.block_on(photos.get_photos_root())
.map_err(|e| format!("photos root: {e}"))?
.is_none()
{
return Ok(None);
}
let items = self
.rt
.block_on(photos.enumerate_timeline())
.map_err(|e| format!("timeline: {e}"))?;
*self.timeline.lock().unwrap() = Some(TimelineCache {
items,
fetched: Instant::now(),
});
let count = self.db.photos_count().map_err(|e| e.to_string())?;
if count == 0 {
// Nothing to serve, so this one request has to wait for the fetch —
// unless we already know the account has no photos volume and the
// answer is a fresh "no".
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
return Ok(None);
}
let guard = self.timeline.lock().unwrap();
match guard.as_ref() {
Some(c) => c.items.iter().skip(offset).take(limit).cloned().collect(),
None => return Ok(None),
if !self.rt.block_on(self.refresh_timeline())? {
return Ok(None);
}
};
let ttype = ThumbnailType::Thumbnail.as_i32();
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_timeline_refresh();
}
let page = self
.db
.photos_page(offset, limit)
.map_err(|e| e.to_string())?;
Ok(Some(
page.into_iter()
.map(|it| {
let thumb_path = self
.cache
.cached_thumbnail_path(&it.uid, ttype, it.capture_time)
.map(|p| p.display().to_string());
PhotoItem {
uid: it.uid.to_string(),
capture_time: it.capture_time,
thumb_path,
}
})
.collect(),
page.into_iter().map(|p| self.photo_item(p)).collect(),
))
}
/// Thumbnails for `uids`, served from the cache and downloaded (in one
/// batched SDK call) for whatever is missing. Requested on demand as tiles
/// scroll into view, so a cold timeline paints immediately and only the
/// photos actually on screen cost a round-trip.
/// Project a persisted photo into the wire item the front-end paints: its
/// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
/// thumbnail when one is cached (tagged with the capture time, which is the
/// only revision handle a photo carries).
fn photo_item(&self, photo: StoredPhoto) -> PhotoItem {
let thumb_path = parse_uid(&photo.uid).and_then(|uid| {
self.cache
.cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time)
.map(|p| p.display().to_string())
});
PhotoItem {
uid: photo.uid,
capture_time: photo.capture_time,
thumb_path,
name: photo.name,
ratio: photo.ratio,
no_thumb: photo.thumb_state == db::THUMB_NONE,
}
}
/// Thumbnails for `uids`, served from the cache, fetched from the server for
/// whatever is missing, and — for the photos the server has no thumbnail for
/// at all — *generated locally* from the full file (see
/// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view,
/// so a cold timeline paints immediately and only the photos actually on
/// screen cost a round-trip.
///
/// The cache tags a thumbnail with its photo's capture time, which is what
/// the timeline carries — a uid absent from the cached timeline therefore
/// can't be validated and is skipped rather than guessed at.
/// A photo absent from the persisted timeline is skipped: its capture time is
/// the cache's validity tag, and guessing that would poison the cache.
fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32();
let tags: HashMap<NodeUid, i64> = {
let guard = self.timeline.lock().unwrap();
match guard.as_ref() {
Some(c) => c
.items
.iter()
.map(|it| (it.uid.clone(), it.capture_time))
.collect(),
None => HashMap::new(),
}
};
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
let tags: HashMap<String, i64> = stored
.iter()
.map(|p| (p.uid.clone(), p.capture_time))
.collect();
// Ask the server only for photos that are missing a cached thumbnail and
// haven't already been written off as un-thumbnailable.
let want: Vec<NodeUid> = uids
.iter()
.filter(|uid| {
tags.get(*uid)
.is_some_and(|&tag| self.cache.cached_thumbnail_path(uid, ttype, tag).is_none())
let key = uid.to_string();
stored
.iter()
.find(|p| p.uid == key)
.is_some_and(|p| p.thumb_state != db::THUMB_NONE)
&& tags.get(&key).is_some_and(|&tag| {
self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()
})
})
.cloned()
.collect();
if !want.is_empty() {
let mut missing = want.clone();
match self.rt.block_on(
self.photos()
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
) {
Ok(thumbs) => {
for ft in thumbs {
if let (Ok(bytes), Some(&tag)) = (ft.result, tags.get(&ft.file_uid)) {
let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes);
let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
continue;
};
let Ok(bytes) = ft.result else { continue };
if bytes.is_empty() {
continue;
}
if self
.cache
.store_thumbnail(&ft.file_uid, ttype, tag, &bytes)
.is_ok()
{
missing.retain(|uid| uid != &ft.file_uid);
self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes));
}
}
}
// A failed batch is not a verdict: leave every uid in `missing` so
// the local fallback still gives those tiles an image.
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
}
// Whatever the server had nothing for gets a thumbnail made from its
// own bytes — this is what fills in camera photos uploaded by clients
// that never generated one. Off the request path: a full-size photo
// takes far longer to fetch than the whole rest of the batch, and the
// thumbnails that *are* ready must not wait behind it.
if !missing.is_empty() {
self.spawn_generate_thumbs(missing, &tags);
}
}
let pending = self.thumb_gen.lock().unwrap();
uids.iter()
.map(|uid| PhotoThumb {
uid: uid.to_string(),
path: tags.get(uid).and_then(|&tag| {
path: tags.get(&uid.to_string()).and_then(|&tag| {
self.cache
.cached_thumbnail_path(uid, ttype, tag)
.map(|p| p.display().to_string())
}),
pending: pending.contains(uid),
})
.collect()
}
/// Generate the missing thumbnails on the runtime, skipping any photo already
/// being generated. The uids are marked in-flight before the task starts, so
/// the reply this call is about to send already reports them as pending.
fn spawn_generate_thumbs(&self, uids: Vec<NodeUid>, tags: &HashMap<String, i64>) {
let fresh: Vec<NodeUid> = {
let mut inflight = self.thumb_gen.lock().unwrap();
uids.into_iter()
.filter(|uid| inflight.insert(uid.clone()))
.collect()
};
if fresh.is_empty() {
return;
}
let core = self.clone();
let tags = tags.clone();
// `generate_thumbs` blocks on the runtime itself, so it belongs on the
// blocking pool rather than on an async worker.
self.rt.spawn_blocking(move || {
core.generate_thumbs(&fresh, &tags);
let mut inflight = core.thumb_gen.lock().unwrap();
for uid in &fresh {
inflight.remove(uid);
}
});
}
/// Make thumbnails for photos the server has none for: download each full
/// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail
/// cache exactly as if the server had served it.
///
/// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a
/// screenful of them at once would saturate the link. A photo whose bytes
/// can't be decoded (a codec `image` doesn't speak) is marked
/// [`db::THUMB_NONE`] and never attempted again.
fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap<String, i64>) {
info!(
count = uids.len(),
"generating thumbnails the server has none for"
);
let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async {
let mut out = Vec::with_capacity(uids.len());
for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) {
let mut set = tokio::task::JoinSet::new();
for uid in chunk {
let client = self.client.clone();
let uid = uid.clone();
set.spawn(async move {
let bytes = match client.download_file(&uid).await {
Ok(bytes) => bytes,
Err(e) => {
warn!(%uid, error = %e, "photo download for thumbnail failed");
return (uid, ThumbAttempt::Unavailable);
}
};
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool.
let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes))
.await
.unwrap_or(None);
match made {
Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
None => (uid, ThumbAttempt::Undecodable),
}
});
}
while let Some(joined) = set.join_next().await {
if let Ok(result) = joined {
out.push(result);
}
}
}
out
});
let ttype = ThumbnailType::Thumbnail.as_i32();
for (uid, attempt) in results {
match attempt {
ThumbAttempt::Made(thumb) => {
let Some(&tag) = tags.get(&uid.to_string()) else {
continue;
};
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)),
Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"),
}
}
// The photo's own bytes aren't an image we can read: no thumbnail
// will ever exist for it, so stop trying.
ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None),
// The download failed — a dropped connection, an expired link. That
// is not a verdict on the photo: leave its state alone so the next
// scroll past it tries again.
ThumbAttempt::Unavailable => {}
}
}
}
/// Persist what a thumbnail attempt learned about a photo.
fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
warn!(%uid, error = %e, "recording thumbnail state failed");
}
}
/// Whether the listing stamped under `key` is older than `ttl` (or was never
/// fetched).
fn listing_stale(&self, key: &str, ttl: Duration) -> bool {
match self.db.state_i64(key).ok().flatten() {
Some(ms) => now_ms().saturating_sub(ms) >= ttl.as_millis() as i64,
None => true,
}
}
/// Re-fetch the whole photos timeline and persist it. Returns whether the
/// account has a photos volume at all.
async fn refresh_timeline(&self) -> Result<bool, String> {
let photos = self.photos();
if photos
.get_photos_root()
.await
.map_err(|e| format!("photos root: {e}"))?
.is_none()
{
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
return Ok(false);
}
let items = photos
.enumerate_timeline()
.await
.map_err(|e| format!("timeline: {e}"))?;
let rows: Vec<(String, i64, Option<String>)> = items
.iter()
.map(|it| (it.uid.to_string(), it.capture_time, None))
.collect();
self.db.photos_replace(&rows).map_err(|e| e.to_string())?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
Ok(true)
}
/// Refresh the timeline off the request path, so a stale page is still served
/// at DB speed. At most one refresh runs at a time.
fn spawn_timeline_refresh(&self) {
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_timeline().await {
warn!(error = %e, "background timeline refresh failed");
}
core.timeline_refreshing.store(false, Ordering::SeqCst);
});
}
/// Download a photo's full content into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> {
@@ -1300,42 +1505,101 @@ impl Core {
.unwrap_or_default();
self.cache.evict(&uid);
self.invalidate_parent_listing(rel);
self.invalidate_trash();
Ok(name)
}
/// List the account's trash. Trashed nodes are outside the mounted tree — the
/// FUSE side forgot them when they were trashed — so this always goes to the
/// server rather than to `State` or the DB, and each entry is identified by
/// its uid (its only remaining handle) with an empty path.
/// List the account's trash, from the DB. Trashed nodes are outside the
/// mounted tree — the FUSE side forgot them when they were trashed — so the
/// listing is persisted in its own table rather than derived from `State`, and
/// each entry is identified by its uid (its only remaining handle) with an
/// empty path.
///
/// Stale-while-revalidate, like the photos timeline: a persisted listing comes
/// back at DB speed and is refreshed in the background past [`TRASH_TTL`].
/// Our own trash mutations invalidate it outright (see
/// [`Core::invalidate_trash`]), so the TTL only covers changes made elsewhere.
fn list_trash(&self) -> Result<Vec<DirEntry>, String> {
let uids = self
.rt
.block_on(self.client.enumerate_trash_node_uids())
.map_err(|e| format!("enumerate trash: {e}"))?;
if uids.is_empty() {
return Ok(Vec::new());
let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL);
// Never fetched: this request has to wait for it.
if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() {
self.rt.block_on(self.refresh_trash())?;
} else if stale {
self.spawn_trash_refresh();
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("enumerate nodes: {e}"))?;
Ok(nodes
Ok(self
.db
.trash_list()
.map_err(|e| e.to_string())?
.into_iter()
.map(|node| DirEntry {
name: node.name.clone(),
is_dir: node.is_folder(),
size: node_size(&node),
modified: node.modification_time,
.map(|item| DirEntry {
name: item.name,
is_dir: item.is_dir,
size: item.size.max(0) as u64,
modified: item.mtime,
// A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither.
pinned: false,
cached: false,
uid: node.uid.to_string(),
uid: item.uid,
path: String::new(),
})
.collect())
}
/// Re-fetch the trash listing from the server and persist it.
async fn refresh_trash(&self) -> Result<(), String> {
let uids = self
.client
.enumerate_trash_node_uids()
.await
.map_err(|e| format!("enumerate trash: {e}"))?;
let nodes = if uids.is_empty() {
Vec::new()
} else {
self.client
.enumerate_nodes(&uids)
.await
.map_err(|e| format!("enumerate nodes: {e}"))?
};
let items: Vec<StoredTrash> = nodes
.into_iter()
.map(|node| StoredTrash {
uid: node.uid.to_string(),
name: node.name.clone(),
is_dir: node.is_folder(),
size: node_size(&node) as i64,
mtime: node.modification_time,
})
.collect();
self.db.trash_replace(&items).map_err(|e| e.to_string())?;
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
Ok(())
}
/// Refresh the trash off the request path. At most one refresh at a time.
fn spawn_trash_refresh(&self) {
if self.trash_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_trash().await {
warn!(error = %e, "background trash refresh failed");
}
core.trash_refreshing.store(false, Ordering::SeqCst);
});
}
/// Drop the persisted trash listing's freshness stamp after we changed the
/// trash ourselves. The next Trash page then *waits* for a fresh listing
/// rather than painting one from before the mutation — the user just made the
/// change and is about to look straight at it.
fn invalidate_trash(&self) {
let _ = self.db.clear_state(TRASH_SYNCED_MS);
}
/// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole
/// batch if any is malformed — a partial trash mutation is worse than none.
fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> {
@@ -1363,12 +1627,15 @@ impl Core {
self.rt
.block_on(self.client.restore_nodes(&parsed))
.map_err(|e| format!("restore: {e}"))?;
let mut st = self.state.lock().unwrap();
for parent in parents {
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
{
let mut st = self.state.lock().unwrap();
for parent in parents {
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
}
}
self.invalidate_trash();
Ok(parsed.len())
}
@@ -1380,6 +1647,7 @@ impl Core {
.block_on(self.client.delete_nodes(&parsed))
.map_err(|e| format!("delete: {e}"))?;
self.drop_local(&parsed);
self.invalidate_trash();
Ok(parsed.len())
}
@@ -1395,6 +1663,7 @@ impl Core {
.block_on(self.client.empty_trash())
.map_err(|e| format!("empty trash: {e}"))?;
self.drop_local(&uids);
self.invalidate_trash();
Ok(uids.len())
}
@@ -1573,6 +1842,7 @@ impl ProtonFs {
}
self.core.state.lock().unwrap().forget(&uid);
self.core.cache.evict(&uid);
self.core.invalidate_trash();
reply.ok();
}
}
@@ -1613,6 +1883,72 @@ fn node_size(node: &Node) -> u64 {
}
}
/// Wall clock in milliseconds since the epoch — the unit the persisted listings
/// stamp their freshness in.
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn.
fn ratio_of(bytes: &[u8]) -> Option<f64> {
let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()?;
(height > 0).then(|| f64::from(width) / f64::from(height))
}
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
/// made from (free at that point — the full image had to be decoded anyway).
struct GeneratedThumb {
bytes: Vec<u8>,
ratio: f64,
}
/// How one attempt at generating a missing thumbnail ended. The distinction that
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove
/// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt {
Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for. Permanent.
Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time.
Unavailable,
}
/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an
/// image this build can decode — the caller then writes the photo off as
/// un-thumbnailable.
///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
fn scale_thumbnail(bytes: &[u8]) -> Option<GeneratedThumb> {
let image = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 {
return None;
}
let ratio = f64::from(width) / f64::from(height);
// `thumbnail` fits the image *inside* the box, so the longest edge lands on
// THUMB_EDGE and the ratio is untouched.
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY)
.encode_image(&thumb)
.ok()?;
Some(GeneratedThumb { bytes, ratio })
}
/// Convert a Unix timestamp (seconds, possibly negative) to a `SystemTime`.
fn unix_secs(secs: i64) -> SystemTime {
if secs >= 0 {
@@ -2670,7 +3006,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
false,
)) {
Ok(uid) => {
*core.timeline.lock().unwrap() = None;
// The photo we just uploaded belongs at the head of the
// timeline, and the GUI reloads the gallery the moment this
// reply lands — so refresh now rather than leaving it to a
// background pass that would land just after that reload.
if let Err(e) = core.rt.block_on(core.refresh_timeline()) {
warn!(error = %e, "timeline refresh after upload failed");
}
CtlResponse::Ok {
message: format!("uploaded photo with uid {uid}"),
}
@@ -2929,7 +3271,9 @@ pub fn mount(
})),
cache: Arc::new(cache),
db,
timeline: Arc::new(Mutex::new(None)),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)),
};
@@ -3047,6 +3391,63 @@ pub fn mount(
Ok(outcome)
}
#[cfg(test)]
mod thumb_tests {
use super::{THUMB_EDGE, ratio_of, scale_thumbnail};
/// A `width`×`height` JPEG, standing in for a camera photo the server never
/// generated a thumbnail for.
fn jpeg(width: u32, height: u32) -> Vec<u8> {
let image = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
.encode_image(&image)
.unwrap();
bytes
}
#[test]
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
let photo = jpeg(4000, 3000);
let thumb = scale_thumbnail(&photo).expect("a JPEG scales");
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap();
assert_eq!(width, THUMB_EDGE, "the long edge lands on the target");
assert_eq!(height, THUMB_EDGE * 3 / 4, "and nothing is cropped");
assert!((thumb.ratio - 4.0 / 3.0).abs() < 1e-6);
assert!(
thumb.bytes.len() < photo.len(),
"a thumbnail that isn't smaller than its photo is no thumbnail"
);
}
#[test]
fn a_portrait_photo_fits_its_long_edge_too() {
let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales");
assert!(thumb.ratio < 1.0, "portrait stays portrait");
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
}
#[test]
fn undecodable_bytes_are_not_a_thumbnail() {
// What a photo in a format this build has no decoder for looks like: the
// caller writes it off as un-thumbnailable rather than retrying forever.
assert!(scale_thumbnail(b"not an image at all").is_none());
assert!(ratio_of(b"not an image at all").is_none());
}
#[test]
fn ratio_is_read_from_the_header_alone() {
assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5));
}
}
#[cfg(test)]
mod tests {
use super::Intervals;
+104 -16
View File
@@ -60,11 +60,11 @@ const SEARCH_LIMIT: usize = 200;
/// content width, so this is the *target* height a row lands near, not an exact
/// tile size (see [`justify_rows`]).
const TILE_MIN: i32 = 90;
const TILE_MAX: i32 = 300;
const TILE_DEFAULT: i32 = 180;
const TILE_MAX: i32 = 340;
const TILE_DEFAULT: i32 = 220;
const TILE_STEP: i32 = 30;
/// Gap between tiles, horizontally and vertically.
const TILE_GAP: i32 = 4;
const TILE_GAP: i32 = 6;
/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded
/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the
/// commonest camera/phone ratio; once the thumbnail lands the real ratio
@@ -82,6 +82,10 @@ const THUMB_BATCH: usize = 16;
/// Idle pause before a thumbnail batch is sent, so a fast scroll coalesces into
/// one request per settle instead of one per row that flickers past.
const THUMB_DEBOUNCE: Duration = Duration::from_millis(60);
/// How long to wait before asking again for a thumbnail the daemon is generating
/// itself. That means downloading the photo's full file, so the wait is measured
/// in seconds, not milliseconds.
const THUMB_RETRY: Duration = Duration::from_secs(4);
/// Decoded thumbnails held in memory. Each is a few hundred KiB of GPU texture;
/// this caps the gallery's footprint while covering several screens of scroll.
const TEXTURE_CACHE_MAX: usize = 600;
@@ -457,11 +461,13 @@ fn load_proton_theme() {
.viewer-spinner {{ color: white; }}\n\
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
.gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 4px 2px; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: alpha(currentColor, 0.08); transition: filter 150ms ease; }}\n\
.photo-tile:hover {{ filter: brightness(1.08); }}\n\
.gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\
.photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
.photo-missing {{ background: alpha(currentColor, 0.12); }}\n\
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
@@ -4134,9 +4140,14 @@ fn gallery_width(ui: &Rc<Ui>) -> i32 {
}
}
/// One photo tile: a fixed-size button wrapping the thumbnail. A button (rather
/// than a bare picture) so the tile is focusable, keyboard-activatable and gets
/// hover feedback for free.
/// One photo tile: a fixed-size button wrapping the thumbnail, with the capture
/// time revealed on hover over a bottom scrim. A button (rather than a bare
/// picture) so the tile is focusable, keyboard-activatable and gets hover feedback
/// for free.
///
/// The picture sits in an overlay over a placeholder, so a tile is never a hole:
/// until the thumbnail lands it shows a dim card, and a photo that can never have
/// one keeps an image glyph instead of an empty rectangle.
fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
let picture = gtk4::Picture::builder()
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
@@ -4146,8 +4157,34 @@ fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
.can_shrink(true)
.build();
let placeholder = gtk4::Image::builder()
.icon_name("image-x-generic-symbolic")
.pixel_size(24)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
placeholder.add_css_class("photo-placeholder");
// The capture time, on a gradient that only exists while the pointer is over
// the tile — legible over any photo, invisible the rest of the time.
let caption = gtk4::Label::builder()
// Fill horizontally so the scrim spans the tile; the text itself stays
// left-aligned inside it.
.halign(gtk4::Align::Fill)
.valign(gtk4::Align::End)
.xalign(0.0)
.label(short_capture_time(tile.photo.capture_time))
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
caption.add_css_class("photo-caption");
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&placeholder));
overlay.add_overlay(&picture);
overlay.add_overlay(&caption);
let button = gtk4::Button::builder()
.child(&picture)
.child(&overlay)
.width_request(tile.width)
.height_request(tile.height)
.tooltip_text(format_capture_time(tile.photo.capture_time))
@@ -4176,10 +4213,10 @@ fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
picture.set_paintable(Some(texture));
return;
}
// No thumbnail will ever come for this one: leave the tile as its placeholder
// (still clickable — the full photo may open fine).
// No thumbnail will ever come for this one — not from the server, and not
// from the daemon's own scaling of the file. The tile keeps its placeholder
// glyph, and stays clickable: the full photo may still open fine.
if ui.photo_nothumb.borrow().contains(&photo.uid) {
picture.add_css_class("photo-missing");
return;
}
@@ -4203,6 +4240,25 @@ fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
}
}
/// Come back for thumbnails the daemon is still generating — it is downloading
/// each photo's full file to scale it, which takes far longer than a batch. The
/// tiles keep their placeholder until then, and a tile that has scrolled away is
/// dropped by [`flush_thumbs`] like any other queued uid.
fn retry_pending_thumbs(ui: &Rc<Ui>, uids: Vec<String>) {
let ui = ui.clone();
glib::timeout_add_local_once(THUMB_RETRY, move || {
{
let mut queue = ui.thumb_queue.borrow_mut();
for uid in uids {
if !queue.contains(&uid) {
queue.push_back(uid);
}
}
}
schedule_thumbs(&ui);
});
}
/// Ask the daemon for the queued thumbnails after a short pause, so a fast scroll
/// coalesces into one batch per settle rather than one per row it flew past.
fn schedule_thumbs(ui: &Rc<Ui>) {
@@ -4253,11 +4309,15 @@ fn flush_thumbs(ui: &Rc<Ui>) {
Ok(Ok(Response::Thumbs { items })) => {
let mut decode = ui.decode_queue.borrow_mut();
let mut nothumb = ui.photo_nothumb.borrow_mut();
let mut pending = Vec::new();
for item in items {
match item.path {
Some(path) => decode.push_back((item.uid, path)),
// The photo has no thumbnail at all (or its fetch failed):
// remember that, so scrolling past it doesn't re-ask.
// The daemon is making this one itself, from the photo's
// full file — that takes a download, so come back for it.
None if item.pending => pending.push(item.uid),
// No thumbnail exists and none can be made: remember that,
// so scrolling past the tile doesn't re-ask forever.
None => {
nothumb.insert(item.uid);
}
@@ -4265,6 +4325,9 @@ fn flush_thumbs(ui: &Rc<Ui>) {
}
drop((decode, nothumb));
schedule_decode(&ui);
if !pending.is_empty() {
retry_pending_thumbs(&ui, pending);
}
}
// A thumbnail that doesn't arrive is not worth a toast — the tile just
// stays a placeholder, and the next scroll past it tries again.
@@ -4491,6 +4554,15 @@ fn format_capture_time(secs: i64) -> String {
}
}
/// The capture time as a tile caption: the clock time alone, since the day is
/// already the section heading right above it.
fn short_capture_time(secs: i64) -> String {
glib::DateTime::from_unix_local(secs)
.and_then(|d| d.format("%H:%M"))
.map(|s| s.to_string())
.unwrap_or_default()
}
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
/// the button/key handlers so each one takes a single handle instead of a dozen
/// widget arguments.
@@ -5200,6 +5272,22 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
);
return;
}
// Take the daemon's word on ratios and thumbnail verdicts before
// anything is laid out: it persists both, so the very first frame
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
// and reflowing as the images land.
{
let mut ratios = ui.photo_ratio.borrow_mut();
let mut nothumb = ui.photo_nothumb.borrow_mut();
for item in &items {
if let Some(ratio) = item.ratio {
ratios.insert(item.uid.clone(), ratio);
}
if item.no_thumb {
nothumb.insert(item.uid.clone());
}
}
}
for item in &items {
ui.gallery_model.append(&BoxedAnyObject::new(item.clone()));
}