From 38aca3cd4863ee7884101d6794a5c888933469fd Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 18 Jul 2026 12:08:34 +0200 Subject: [PATCH] video streaming --- crates/pdfs-cli/src/main.rs | 7 +- crates/pdfs-core/src/control.rs | 172 +++++++++++- crates/pdfs-core/src/db.rs | 236 ++++++++++++++--- crates/pdfs-fuse/src/lib.rs | 280 ++++++++++++++++++-- crates/pdfs-gui/src/app.rs | 445 +++++++++++++++++++++++++++++--- crates/pdfs-gui/src/prompt.rs | 37 ++- 6 files changed, 1073 insertions(+), 104 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index a5440ad..4398340 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -1056,7 +1056,12 @@ fn cmd_ls(path: Option) -> Result<()> { } fn cmd_photos(limit: usize, offset: usize) -> Result<()> { - match control_request(CtlRequest::PhotosTimeline { offset, limit })? { + match control_request(CtlRequest::PhotosTimeline { + offset, + limit, + kind: None, + range: None, + })? { CtlResponse::Photos { available: false, .. } => { diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 345539b..349acf6 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -48,7 +48,28 @@ pub enum Request { /// a thumbnail path comes back only for photos already in the cache, so the /// reply never waits on the network. Front-ends ask for the thumbnails they /// actually display with [`Request::PhotoThumbs`]. - PhotosTimeline { offset: usize, limit: usize }, + PhotosTimeline { + offset: usize, + limit: usize, + /// Restrict the page to one kind (Photos / Videos / Raw). `None` (the + /// default, and what older front-ends send) returns everything. The + /// offset is relative to the filtered timeline, so paging a single tab + /// doesn't have to walk past the other kinds. + #[serde(default)] + kind: Option, + /// Restrict the page to a `[from, to)` capture-time window (epoch + /// seconds) — the date scrubber's jump to a month. `None` spans the whole + /// timeline. Like `kind`, the offset is relative to the filtered set. + #[serde(default)] + range: Option<(i64, i64)>, + }, + /// The months the timeline spans (newest first, with per-month counts) so a + /// front-end can build a date scrubber without paging the whole library. + /// `kind` scopes the counts to one tab when set. + PhotoMonths { + #[serde(default)] + kind: Option, + }, /// Fetch thumbnails for the given photo uids, downloading the ones not /// already cached (one batched round-trip) and replying with their on-disk /// paths. Keep the batch small — it is served on demand, as tiles scroll in. @@ -578,6 +599,104 @@ pub struct LocalHit { pub modified: i64, } +/// What kind of media a timeline entry is, so the Photos page can split into +/// Photos / Videos / Raw tabs. Derived from a photo's media type when the daemon +/// has resolved it, falling back to its file-name extension (see +/// [`PhotoKind::classify`]). +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PhotoKind { + /// A normal, directly viewable still image (JPEG, PNG, HEIC, …). + Photo, + /// A video clip (mp4, mkv, mov, …). + Video, + /// A camera raw file (CR2, NEF, ARW, DNG, …) — an image, but one that needs + /// developing and is worth separating from ready-to-view photos. + Raw, +} + +/// File-name extensions that denote a video, matched case-insensitively. +const VIDEO_EXTS: &[&str] = &[ + "mkv", "mp4", "mov", "avi", "webm", "m4v", "flv", "wmv", "mpg", "mpeg", "ts", "3gp", "m2ts", + "mts", "ogv", +]; + +/// File-name extensions that denote a camera raw. The server media type for +/// these is frequently a generic `application/octet-stream`, so the extension is +/// the authoritative signal. +const RAW_EXTS: &[&str] = &[ + "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "dng", "raf", "orf", "rw2", "srw", "pef", + "raw", "rwl", "iiq", "3fr", "dcr", "kdc", "mrw", "x3f", +]; + +/// File-name extensions that denote a ready-to-view still photo. Listed so a +/// known image name classifies as a photo outright, without deferring to a media +/// type that might disagree. +const PHOTO_EXTS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif", "bmp", "tif", "tiff", +]; + +impl PhotoKind { + /// Classify a photo from what the daemon knows about it. A recognised + /// file-name extension is authoritative — it is reliable even before the + /// node's media type is resolved, and for raw files the media type is often a + /// useless generic — so the media type is only consulted for names that carry + /// no extension we know. Anything still unresolved is a still photo. + pub fn classify(name: Option<&str>, media_type: Option<&str>) -> PhotoKind { + if let Some(ext) = name + .and_then(|n| n.rsplit_once('.')) + .map(|(_, e)| e.to_ascii_lowercase()) + { + if RAW_EXTS.contains(&ext.as_str()) { + return PhotoKind::Raw; + } + if VIDEO_EXTS.contains(&ext.as_str()) { + return PhotoKind::Video; + } + if PHOTO_EXTS.contains(&ext.as_str()) { + return PhotoKind::Photo; + } + } + if let Some(mt) = media_type { + if mt.starts_with("video/") { + return PhotoKind::Video; + } + } + PhotoKind::Photo + } + + /// The stable integer this kind is persisted as (see the `kind` column of the + /// `photos` table). Chosen once and never reordered. + pub fn as_i64(self) -> i64 { + match self { + PhotoKind::Photo => 0, + PhotoKind::Video => 1, + PhotoKind::Raw => 2, + } + } + + /// Inverse of [`PhotoKind::as_i64`]; any unrecognised value reads as a still + /// photo, the safe default for a tab that would otherwise show nothing. + pub fn from_i64(v: i64) -> PhotoKind { + match v { + 1 => PhotoKind::Video, + 2 => PhotoKind::Raw, + _ => PhotoKind::Photo, + } + } +} + +/// One month the timeline spans, with how many photos it holds — a tick on the +/// date scrubber (reply to [`Request::PhotoMonths`]). +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub struct PhotoMonth { + /// Local-time calendar year, e.g. `2026`. + pub year: i32, + /// Local-time month, `1..=12`. + pub month: i32, + /// Photos captured in that month (within the requested kind, if any). + pub count: usize, +} + /// One photo in a [`Request::PhotosTimeline`] page. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PhotoItem { @@ -599,6 +718,16 @@ pub struct PhotoItem { /// 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, + /// Which Photos-page tab this entry belongs to. Older daemons that predate + /// the split omit it; a front-end then treats everything as a still photo. + #[serde(default = "default_photo_kind")] + pub kind: PhotoKind, +} + +/// A daemon too old to classify a timeline entry is assumed to have served a +/// still photo — that was the only kind the Photos page showed before the split. +fn default_photo_kind() -> PhotoKind { + PhotoKind::Photo } /// One thumbnail in a [`Response::Thumbs`] batch. @@ -694,7 +823,16 @@ pub enum Response { Photos { available: bool, items: Vec, + /// Whole-timeline tab counts `(photos, videos, raw)`, so a front-end can + /// label its Photos / Videos / Raw filter without paging the library. + /// The counts describe the *whole* timeline regardless of the page's own + /// `kind` filter. Older daemons omit it; a front-end then shows no counts. + #[serde(default)] + counts: Option<(usize, usize, usize)>, }, + /// The months the timeline spans (reply to [`Request::PhotoMonths`]), + /// newest first. + PhotoMonths { months: Vec }, /// Thumbnails for a [`Request::PhotoThumbs`] batch. Thumbs { items: Vec }, /// An on-disk path the front-end can open (e.g. a downloaded photo). @@ -762,6 +900,38 @@ pub fn send(socket: &Path, req: &Request) -> Result { mod tests { use super::*; + #[test] + fn photo_kind_classifies_by_extension_then_mime() { + use PhotoKind::*; + // Raw is extension-driven: the server media type is often generic. + assert_eq!(PhotoKind::classify(Some("IMG_1.CR2"), None), Raw); + assert_eq!( + PhotoKind::classify(Some("shot.dng"), Some("application/octet-stream")), + Raw + ); + // Video by extension or by mime when the name has no useful extension. + assert_eq!(PhotoKind::classify(Some("anime.mkv"), None), Video); + assert_eq!(PhotoKind::classify(Some("clip.MP4"), None), Video); + assert_eq!(PhotoKind::classify(None, Some("video/quicktime")), Video); + // Everything else is a still photo, including a mismatched raw mime whose + // name says it is a normal JPEG. + assert_eq!(PhotoKind::classify(Some("pic.jpg"), None), Photo); + assert_eq!(PhotoKind::classify(Some("pic.heic"), None), Photo); + assert_eq!(PhotoKind::classify(None, None), Photo); + // Extension wins over mime: a name ending .jpg is a photo even if the mime + // is nonsense. + assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo); + } + + #[test] + fn photo_kind_i64_round_trips() { + for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] { + assert_eq!(PhotoKind::from_i64(k.as_i64()), k); + } + // An unknown persisted value degrades to a still photo. + assert_eq!(PhotoKind::from_i64(99), PhotoKind::Photo); + } + /// The whole point of the split counts: a queued `mkdir` is work, but it is /// not an upload and must never be reported as one. #[test] diff --git a/crates/pdfs-core/src/db.rs b/crates/pdfs-core/src/db.rs index d3a0028..3068b27 100644 --- a/crates/pdfs-core/src/db.rs +++ b/crates/pdfs-core/src/db.rs @@ -23,7 +23,7 @@ use crate::control::{ActivityEntry, ActivityKind}; use crate::localindex::LocalEntry; /// Current schema version. Bump on every forward migration added below. -const SCHEMA_VERSION: i64 = 12; +const SCHEMA_VERSION: i64 = 13; /// How many activity rows to keep. Older rows are pruned on insert, so the feed /// stays a bounded "recent history" rather than growing without limit. @@ -196,6 +196,9 @@ pub struct StoredPhoto { pub ratio: Option, /// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`]. pub thumb_state: i64, + /// Which Photos-page tab this entry belongs to, derived from its name and + /// media type when the timeline was last replaced. + pub kind: crate::control::PhotoKind, } /// One trashed node, as persisted for the Trash page. Trashed nodes live outside @@ -360,6 +363,9 @@ impl Db { if current < 12 { tx.execute_batch(MIGRATION_V12)?; } + if current < 13 { + tx.execute_batch(MIGRATION_V13)?; + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -866,32 +872,55 @@ impl Db { /// /// 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)]) -> Result<()> { + pub fn photos_replace( + &self, + items: &[(String, i64, Option, Option)], + ) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; - let learned: HashMap, 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)?))))?; + // `media_type` is learned-and-kept like the ratio and thumb verdict: the + // timeline DTO carries only the uid and capture time, so the daemon may + // not know a photo's media type yet when it replaces the timeline. Keep + // any previously learned value so the Photos/Videos/Raw split survives a + // refresh instead of collapsing back to name-extension guesses. + let learned: HashMap, i64, Option)> = { + let mut stmt = + tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?; + let rows = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?))) + })?; rows.collect::>()? }; 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)", + "INSERT INTO photos + (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", )?; - for (seq, (uid, capture_time, name)) in items.iter().enumerate() { - let (ratio, thumb_state) = - learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN)); + for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() { + let (ratio, thumb_state, learned_media) = learned + .get(uid) + .cloned() + .unwrap_or((None, THUMB_UNKNOWN, None)); + let media_type = media_type.clone().or(learned_media); + // The tab this photo lands in is derived here, once, so a page or + // count query is a plain indexed `WHERE kind = ?` rather than a + // reclassification of every row. + let kind = crate::control::PhotoKind::classify( + name.as_deref(), + media_type.as_deref(), + ); stmt.execute(params![ uid, capture_time, name, ratio, thumb_state, - seq as i64 + seq as i64, + media_type, + kind.as_i64(), ])?; } } @@ -899,27 +928,105 @@ impl Db { Ok(()) } - /// One page of the persisted timeline, newest first. - pub fn photos_page(&self, offset: usize, limit: usize) -> Result> { + /// One page of the persisted timeline, newest first. `kind`, when set, + /// restricts the page to one tab (Photos / Videos / Raw); `range`, when set, + /// restricts it to a `[from, to)` capture-time window (epoch seconds) — the + /// date scrubber's jump. `offset` is relative to whatever the filters leave. + pub fn photos_page( + &self, + offset: usize, + limit: usize, + kind: Option, + range: Option<(i64, i64)>, + ) -> Result> { 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?); + // Built up so any combination of the optional filters is one indexed + // query rather than a statement per case. + let mut sql = + String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos"); + let mut binds: Vec = Vec::new(); + let mut conds: Vec = Vec::new(); + if let Some(k) = kind { + binds.push(k.as_i64()); + conds.push(format!("kind = ?{}", binds.len())); } - Ok(photos) + if let Some((from, to)) = range { + binds.push(from); + conds.push(format!("capture_time >= ?{}", binds.len())); + binds.push(to); + conds.push(format!("capture_time < ?{}", binds.len())); + } + if !conds.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conds.join(" AND ")); + } + binds.push(limit as i64); + let limit_pos = binds.len(); + binds.push(offset as i64); + let offset_pos = binds.len(); + sql.push_str(&format!(" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}")); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(binds), |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)?, + kind: crate::control::PhotoKind::from_i64(r.get(5)?), + }) + })? + .collect::>()?; + Ok(rows) + } + + /// The months the timeline spans, newest first, each with how many photos it + /// holds — the data behind the date scrubber. Buckets are local-time + /// `(year, month)` so they line up with the day headings the gallery draws + /// and with the boundaries a front-end computes when it jumps to one. `kind` + /// scopes the counts to one tab when set. + pub fn photos_months( + &self, + kind: Option, + ) -> Result> { + let conn = self.conn.lock().unwrap(); + let (filter, binds): (&str, Vec) = match kind { + Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]), + None => ("", Vec::new()), + }; + let sql = format!( + "SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \ + CAST(strftime('%m', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS m, \ + COUNT(*) \ + FROM photos{filter} GROUP BY y, m ORDER BY y DESC, m DESC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(binds), |r| { + Ok((r.get::<_, i32>(0)?, r.get::<_, i32>(1)?, r.get::<_, i64>(2)? as usize)) + })? + .collect::>()?; + Ok(rows) + } + + /// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`. + pub fn photos_counts(&self) -> Result<(usize, usize, usize)> { + use crate::control::PhotoKind; + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; + let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize); + for row in rows { + let (kind, n) = row?; + match PhotoKind::from_i64(kind) { + PhotoKind::Photo => photos = n as usize, + PhotoKind::Video => videos = n as usize, + PhotoKind::Raw => raw = n as usize, + } + } + Ok((photos, videos, raw)) } /// The stored photos for `uids`, in no particular order. Used by the thumbnail @@ -932,7 +1039,7 @@ impl Db { 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 + "SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos WHERE uid IN ({placeholders})" ))?; let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| { @@ -942,6 +1049,7 @@ impl Db { name: r.get(2)?, ratio: r.get(3)?, thumb_state: r.get(4)?, + kind: crate::control::PhotoKind::from_i64(r.get(5)?), }) })?; let mut photos = Vec::new(); @@ -2001,6 +2109,17 @@ ALTER TABLE pending_op ADD COLUMN name TEXT; CREATE INDEX pending_op_parent ON pending_op(parent_uid); "; +/// Schema v13: photos record their media type and a derived `kind` so the +/// timeline can be split into Photos / Videos / Raw tabs. `media_type` is +/// nullable — it is filled once the daemon resolves a photo's node — while +/// `kind` (see [`crate::control::PhotoKind`]) defaults to `0` (still photo), the +/// classification anything unresolved falls back to. Existing rows are +/// reclassified for free on the next timeline refresh, so no backfill is needed. +const MIGRATION_V13: &str = " +ALTER TABLE photos ADD COLUMN media_type TEXT; +ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0; +"; + #[cfg(test)] mod tests { use super::*; @@ -2060,9 +2179,9 @@ mod tests { 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), + ("p1".into(), 300, None, Some("video/mp4".into())), + ("p2".into(), 200, None, None), + ("p3".into(), 100, None, None), ]) .unwrap(); @@ -2071,14 +2190,15 @@ mod tests { db.photo_set_thumb("p2", THUMB_NONE, None).unwrap(); // The next refresh brings a new photo, keeps p1 and p2, and loses p3. + // p1's media type arrives as `None` this time and must not be forgotten. db.photos_replace(&[ - ("p0".into(), 400, Some("new.jpg".into())), - ("p1".into(), 300, None), - ("p2".into(), 200, None), + ("p0".into(), 400, Some("new.jpg".into()), None), + ("p1".into(), 300, None, None), + ("p2".into(), 200, None, None), ]) .unwrap(); - let page = db.photos_page(0, 10).unwrap(); + let page = db.photos_page(0, 10, None, None).unwrap(); assert_eq!( page.iter().map(|p| p.uid.as_str()).collect::>(), ["p0", "p1", "p2"], @@ -2092,27 +2212,59 @@ mod tests { // A photo we know nothing about yet starts blank. assert_eq!(page[0].ratio, None); assert_eq!(page[0].thumb_state, THUMB_UNKNOWN); + // Media type is learned-and-kept like the ratio: p1 keeps the video type + // it was first seen with even though the later refresh carried `None`, so + // it stays classified as a video. + assert_eq!(page[1].kind, crate::control::PhotoKind::Video); assert_eq!(db.photos_count().unwrap(), 3); + // The counts break down by tab, and a filtered page returns only its tab. + assert_eq!(db.photos_counts().unwrap(), (2, 1, 0)); + let videos = db + .photos_page(0, 10, Some(crate::control::PhotoKind::Video), None) + .unwrap(); + assert_eq!( + videos.iter().map(|p| p.uid.as_str()).collect::>(), + ["p1"] + ); let by_uid = db.photos_by_uid(&["p2".into()]).unwrap(); assert_eq!(by_uid.len(), 1); assert_eq!(by_uid[0].capture_time, 200); + + // A date-range page keeps only the window's photos: [150, 350) is p1+p2, + // not p0 at 400. Combined with a kind filter both conditions apply. + let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap(); + assert_eq!( + ranged.iter().map(|p| p.uid.as_str()).collect::>(), + ["p1", "p2"] + ); + let ranged_video = db + .photos_page(0, 10, Some(crate::control::PhotoKind::Video), Some((150, 350))) + .unwrap(); + assert_eq!( + ranged_video.iter().map(|p| p.uid.as_str()).collect::>(), + ["p1"] + ); + // All three surviving photos sit in the same (1970-01) local month. + let months = db.photos_months(None).unwrap(); + assert_eq!(months.len(), 1); + assert_eq!(months[0].2, 3); } #[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)) + .map(|i| (format!("p{i}"), 500 - i as i64, None, None::)) .collect(); db.photos_replace(&items).unwrap(); - let page = db.photos_page(2, 2).unwrap(); + let page = db.photos_page(2, 2, None, None).unwrap(); assert_eq!( page.iter().map(|p| p.uid.as_str()).collect::>(), ["p2", "p3"] ); - assert!(db.photos_page(9, 2).unwrap().is_empty()); + assert!(db.photos_page(9, 2, None, None).unwrap().is_empty()); } #[test] diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 24ae6bf..7f2dba6 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::OsStr; use std::fs::File; use std::io::{BufRead, BufReader, Write as _}; @@ -53,7 +53,8 @@ use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem, - LocalHit, PhotoItem, PhotoThumb, PublicLinkInfo, RefreshScope, Request as CtlRequest, + LocalHit, PhotoItem, PhotoKind, PhotoMonth, PhotoThumb, PublicLinkInfo, RefreshScope, + Request as CtlRequest, Response as CtlResponse, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, }; @@ -104,6 +105,12 @@ const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300); /// 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); +/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`] +/// call when enriching a refreshed timeline with names and media types (for the +/// Photos / Videos / Raw split). Batched so a large library is a handful of +/// round-trips rather than one request per photo, and bounded so a single call +/// never asks the server to decrypt the whole library at once. +const TIMELINE_ENRICH_CHUNK: usize = 200; /// 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. @@ -127,6 +134,27 @@ const THUMB_QUALITY: u8 = 82; /// saturate the link and starve the rest of the daemon. const THUMB_GEN_CONCURRENCY: usize = 4; +/// A read of an unpinned video at least this large streams *without* persisting +/// its blocks to the on-disk cache. Playing a 2 GB film would otherwise pour it +/// through the block LRU and evict everything else the user actually wants kept — +/// and it re-streams cheaply enough on a rewatch that keeping it was never worth +/// that. Pinned videos (kept offline on purpose) and anything smaller cache as +/// usual. +const STREAM_BYPASS_MIN: u64 = 256 * 1024 * 1024; + +/// How many bytes of streamed blocks the in-memory ring keeps. Bypassing the +/// on-disk cache must not mean re-fetching: the kernel asks for a streamed file +/// in reads far smaller than [`BLOCK_SIZE`], so without a ring every 128 KiB the +/// player consumes would download and decrypt a whole 4 MiB block again. Sized +/// for a handful of blocks per concurrently-streamed file. +const STREAM_RING_BYTES: u64 = 128 * 1024 * 1024; + +/// Blocks fetched *past* the one a sequential streaming read needed, warmed into +/// the ring in the background so the player's next read is already in memory +/// instead of waiting a round-trip. Kept small so it cannot crowd out the +/// in-flight block governor. +const STREAM_READAHEAD: u64 = 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 /// disk churn; the scanner also always rebuilds once per daemon start when the @@ -645,6 +673,72 @@ static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0); /// /// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task /// gets its own copy while the FUSE session keeps another. +/// In-memory LRU of blocks belonging to a file streaming past the on-disk cache +/// (see [`STREAM_BYPASS_MIN`]). Bypassing the disk stops one film evicting the +/// block LRU; it must not also mean the same 4 MiB block is downloaded once per +/// 128 KiB the player reads out of it. +/// +/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node +/// whose tag no longer matches has its blocks dropped rather than served, so a +/// new revision can never be stitched together from the old one. +#[derive(Default)] +struct StreamRing { + blocks: HashMap<(NodeUid, u64), Arc>>, + /// Keys oldest-first, for eviction. + order: VecDeque<(NodeUid, u64)>, + /// Per-node validity tag, `(mtime, size)`. + tags: HashMap, + bytes: u64, +} + +impl StreamRing { + fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option>> { + if self.tags.get(uid) != Some(&(mtime, size)) { + return None; + } + self.blocks.get(&(uid.clone(), idx)).cloned() + } + + fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc>) { + if self.tags.get(uid) != Some(&(mtime, size)) { + // Revision changed under us (or first sight): anything held for this + // node describes the old one. + self.drop_node(uid); + self.tags.insert(uid.clone(), (mtime, size)); + } + let key = (uid.clone(), idx); + if self.blocks.contains_key(&key) { + return; + } + self.bytes += bytes.len() as u64; + self.blocks.insert(key.clone(), bytes); + self.order.push_back(key); + while self.bytes > STREAM_RING_BYTES { + let Some(victim) = self.order.pop_front() else { + break; + }; + if let Some(dropped) = self.blocks.remove(&victim) { + self.bytes -= dropped.len() as u64; + } + } + } + + fn drop_node(&mut self, uid: &NodeUid) { + self.order.retain(|(u, _)| u != uid); + let mut freed = 0u64; + self.blocks.retain(|(u, _), bytes| { + if u == uid { + freed += bytes.len() as u64; + false + } else { + true + } + }); + self.bytes -= freed; + self.tags.remove(uid); + } +} + #[derive(Clone)] struct Core { client: ProtonDriveClient, @@ -656,6 +750,10 @@ struct Core { /// Validated by `(mtime, size)` exactly like the content cache, and bounded /// by [`MAX_OPEN_READERS`]. readers: Arc>>, + /// Blocks of files streaming past the on-disk cache, held in memory so a + /// player's small sequential reads are served from the last few 4 MiB blocks + /// instead of re-downloading each one. See [`StreamRing`]. + stream_ring: Arc>, /// Threads that serve the FUSE handlers which touch the network, so the /// session's dispatch loop stays free to answer cheap metadata calls while a /// cold read is on the wire. See [`Workers`]. @@ -1258,6 +1356,7 @@ impl Core { fsize: u64, offset: u64, len: u64, + cache_blocks: bool, ) -> Result, Errno> { // A queued write has not reached the remote yet, so the remote's current // revision is stale and the staged blob is the truth. Serve from it until @@ -1271,7 +1370,7 @@ impl Core { if is_local_uid(uid) { return Ok(Vec::new()); } - self.read_range_remote(uid, mtime, fsize, offset, len) + self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks) } /// Serve a read from a staged blob, falling back to the remote base for any @@ -1319,6 +1418,7 @@ impl Core { m.base_size, s, bend - s, + true, )?); } // Past the base: a hole the write extended over. @@ -1327,6 +1427,59 @@ impl Core { Ok(out) } + /// Warm the [`STREAM_READAHEAD`] blocks after `last` into the ring, in the + /// background, so a player reading forward finds its next block already in + /// memory rather than paying a fetch + decrypt round-trip at each boundary. + /// + /// Fire-and-forget: a failure here is not an error, it only means the read + /// that needs those bytes will fetch them itself. + fn stream_readahead(&self, uid: &NodeUid, mtime: i64, fsize: u64, last: u64) { + let last_block = (fsize.saturating_sub(1)) / BLOCK_SIZE; + let wanted: Vec = ((last + 1)..=(last + STREAM_READAHEAD).min(last_block)) + .filter(|&bidx| { + self.stream_ring + .lock() + .unwrap() + .get(uid, mtime, fsize, bidx) + .is_none() + }) + .collect(); + if wanted.is_empty() { + return; + } + let core = self.clone(); + let uid = uid.clone(); + self.rt.spawn(async move { + let Ok(reader) = core.revision_reader(&uid, mtime, fsize).await else { + return; + }; + // Concurrently, for the same reason the demand path fetches its + // misses concurrently: serially awaited blocks cost the sum of their + // round-trips, and the player catches up with the readahead before it + // finishes. The SDK's in-flight block governor bounds the fan-out. + let mut set = tokio::task::JoinSet::new(); + for bidx in wanted { + let reader = reader.clone(); + let bstart = bidx * BLOCK_SIZE; + let blen = BLOCK_SIZE.min(fsize - bstart); + set.spawn(async move { (bidx, reader.read_at(bstart, blen).await) }); + } + while let Some(joined) = set.join_next().await { + let Ok((bidx, read)) = joined else { continue }; + match read { + Ok(bytes) => core.stream_ring.lock().unwrap().insert( + &uid, + mtime, + fsize, + bidx, + Arc::new(bytes), + ), + Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"), + } + } + }); + } + /// Read from the content cache, else the remote. The base-content path, with /// no awareness of queued writes — callers wanting the file's *current* /// content want [`Core::read_range`]. Gap-filling a staged blob is the one @@ -1339,6 +1492,7 @@ impl Core { fsize: u64, offset: u64, len: u64, + cache_blocks: bool, ) -> Result, Errno> { if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) { return Ok(bytes); @@ -1357,10 +1511,22 @@ impl Core { // would otherwise stall on each block round-trip in turn; downloading the // misses in parallel saturates the connection and bounds latency at the // slowest single block instead of their sum. - let mut blocks: Vec>> = Vec::with_capacity((last - first + 1) as usize); + let mut blocks: Vec>>> = Vec::with_capacity((last - first + 1) as usize); let mut misses: Vec = Vec::new(); for bidx in first..=last { - match self.cache.cached_block(uid, mtime, fsize, bidx) { + // A streaming read keeps its blocks only in memory, so the ring is + // the only cache it has — check it before the disk (which will not + // hold them) and before the network (which would refetch the same + // 4 MiB block for every 128 KiB the player consumes). + let hit = if cache_blocks { + self.cache.cached_block(uid, mtime, fsize, bidx).map(Arc::new) + } else { + self.stream_ring + .lock() + .unwrap() + .get(uid, mtime, fsize, bidx) + }; + match hit { Some(b) => blocks.push(Some(b)), None => { blocks.push(None); @@ -1401,9 +1567,23 @@ impl Core { Ok::<_, Errno>(out) })?; for (bidx, bytes) in fetched { - let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes); + // A streaming read (large unpinned video) skips the on-disk cache + // so it can't evict the rest of it; it keeps the block in the + // in-memory ring instead, which is bounded and dies with the read. + let bytes = Arc::new(bytes); + if cache_blocks { + let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes); + } else { + self.stream_ring + .lock() + .unwrap() + .insert(uid, mtime, fsize, bidx, bytes.clone()); + } blocks[(bidx - first) as usize] = Some(bytes); } + if !cache_blocks { + self.stream_readahead(uid, mtime, fsize, last); + } } for (i, block) in blocks.into_iter().enumerate() { @@ -1457,6 +1637,7 @@ impl Core { base_size, s, bend - s, + true, )?); } // Anything past the base is a hole: zero-fill. @@ -1496,7 +1677,7 @@ impl Core { if s >= bend { continue; // wholly past the base: already zero-filled on disk } - let bytes = self.read_range_remote(uid, base_mtime, base_size, s, bend - s)?; + let bytes = self.read_range_remote(uid, base_mtime, base_size, s, bend - s, true)?; file.write_all_at(&bytes, s).map_err(|err| { error!(%uid, error = %err, "scratch gap-fill write failed"); Errno::EIO @@ -2957,6 +3138,8 @@ impl Core { &self, offset: usize, limit: usize, + kind: Option, + range: Option<(i64, i64)>, ) -> Result>, String> { let count = self.db.photos_count().map_err(|e| e.to_string())?; if count == 0 { @@ -2976,7 +3159,7 @@ impl Core { let page = self .db - .photos_page(offset, limit) + .photos_page(offset, limit, kind, range) .map_err(|e| e.to_string())?; Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect())) } @@ -2998,6 +3181,7 @@ impl Core { name: photo.name, ratio: photo.ratio, no_thumb: photo.thumb_state == db::THUMB_NONE, + kind: photo.kind, } } @@ -3222,9 +3406,37 @@ impl Core { .enumerate_timeline() .await .map_err(|e| format!("timeline: {e}"))?; - let rows: Vec<(String, i64, Option)> = items + + // The timeline DTO carries only a uid and capture time, but the Photos + // page has to split into Photos / Videos / Raw — which needs each photo's + // name and media type. Resolve those in batches off the request path. + // Best-effort: a photo whose node we fail to resolve keeps whatever was + // learned before (or classifies from nothing, i.e. a still photo), so a + // partial resolve never blanks the timeline. + let uids: Vec = items.iter().map(|it| it.uid.clone()).collect(); + let mut meta: HashMap, Option)> = HashMap::new(); + for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) { + match photos.enumerate_nodes(chunk).await { + Ok(nodes) => { + for node in nodes { + let media_type = match &node.kind { + NodeKind::File { media_type, .. } => Some(media_type.clone()), + NodeKind::Folder => None, + }; + meta.insert(node.uid.to_string(), (Some(node.name), media_type)); + } + } + Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"), + } + } + + let rows: Vec<(String, i64, Option, Option)> = items .iter() - .map(|it| (it.uid.to_string(), it.capture_time, None)) + .map(|it| { + let key = it.uid.to_string(); + let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None)); + (key, it.capture_time, name, media_type) + }) .collect(); self.db.photos_replace(&rows).map_err(|e| e.to_string())?; let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1); @@ -5718,11 +5930,22 @@ impl Filesystem for ProtonFs { } return; } - let (uid, mtime, fsize) = { + let (uid, mtime, fsize, is_video) = { let st = self.core.state.lock().unwrap(); match st.entries.get(&ino.0) { Some(e) if e.node.is_file() => { - (e.uid.clone(), e.node.modification_time, node_size(&e.node)) + let media_type = match &e.node.kind { + NodeKind::File { media_type, .. } => Some(media_type.as_str()), + NodeKind::Folder => None, + }; + let is_video = + PhotoKind::classify(Some(&e.node.name), media_type) == PhotoKind::Video; + ( + e.uid.clone(), + e.node.modification_time, + node_size(&e.node), + is_video, + ) } Some(_) => { reply.error(Errno::EISDIR); @@ -5734,6 +5957,10 @@ impl Filesystem for ProtonFs { } } }; + // A large unpinned video streams without polluting the block cache (see + // [`STREAM_BYPASS_MIN`]); everything else caches its blocks as usual. + let cache_blocks = + !(is_video && fsize >= STREAM_BYPASS_MIN && !self.core.cache.is_pinned(&uid)); // Off the dispatch loop: this is the one handler that routinely goes to // the network (block fetch + decrypt, hundreds of ms on a cold file), // and until it returns the kernel's next request for this mount is not @@ -5741,7 +5968,7 @@ impl Filesystem for ProtonFs { // races with nothing. FUSE does not require replies in request order. let core = self.core.clone(); self.core.workers.run(move || { - match core.read_range(&uid, mtime, fsize, offset, size as u64) { + match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) { Ok(bytes) => reply.data(&bytes), Err(e) => reply.error(e), } @@ -6710,19 +6937,41 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Err(e) => CtlResponse::Error { message: e }, } } - Ok(CtlRequest::PhotosTimeline { offset, limit }) => { - match core.photos_timeline(offset, limit) { + Ok(CtlRequest::PhotosTimeline { + offset, + limit, + kind, + range, + }) => { + match core.photos_timeline(offset, limit, kind, range) { Ok(Some(items)) => CtlResponse::Photos { available: true, items, + counts: core.db.photos_counts().ok(), }, Ok(None) => CtlResponse::Photos { available: false, items: Vec::new(), + counts: None, }, Err(e) => CtlResponse::Error { message: e }, } } + Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) { + Ok(months) => CtlResponse::PhotoMonths { + months: months + .into_iter() + .map(|(year, month, count)| PhotoMonth { + year, + month, + count, + }) + .collect(), + }, + Err(e) => CtlResponse::Error { + message: e.to_string(), + }, + }, Ok(CtlRequest::PhotoThumbs { uids }) => { let parsed: Vec = uids.iter().filter_map(|u| parse_uid(u)).collect(); CtlResponse::Thumbs { @@ -7406,6 +7655,7 @@ pub fn mount( })), cache: Arc::new(cache), readers: Arc::new(Mutex::new(HashMap::new())), + stream_ring: Arc::new(Mutex::new(StreamRing::default())), workers: Arc::new(Workers::new(FUSE_WORKERS)?), db, online: Arc::new(AtomicBool::new(online)), diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index ec30569..4fca10f 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -34,7 +34,7 @@ use pdfs_core::auth; use pdfs_core::config::AppDirs; use pdfs_core::control::{ ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem, - PhotoItem, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry, + PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary, send, }; @@ -276,6 +276,23 @@ struct Ui { gallery_upload: gtk4::Button, /// "1,204 photos" under the page title. gallery_subtitle: gtk4::Label, + /// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None` + /// for All. Read by [`load_gallery`] and set by the filter toggles. + gallery_kind: Cell>, + /// The filter toggles, index-aligned with [`kind_for_tab`], kept so their + /// labels can carry live per-kind counts. + gallery_tabs: [gtk4::ToggleButton; 4], + /// The date-jump dropdown ("All dates" then a month per timeline entry), and + /// the `[from, to)` window each of its rows selects (index-aligned; `None` is + /// "All dates"). Selecting a row loads that month via [`load_gallery`]. + gallery_dates: gtk4::DropDown, + gallery_date_ranges: RefCell>>, + /// The capture-time window the timeline is currently filtered to, or `None` + /// for the whole span. Read by [`load_gallery`], set by the date dropdown. + gallery_range: Cell>, + /// Set while the date dropdown is being repopulated, so resetting its model + /// doesn't fire the selection handler and kick off a spurious reload. + gallery_date_suppress: Cell, /// True while a timeline page is in flight, so the scroll-to-the-end paging /// can't fire a second request for the page already coming. gallery_loading: Cell, @@ -543,6 +560,8 @@ fn load_proton_theme() { .photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\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-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\ + .photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\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\ @@ -693,6 +712,12 @@ fn build_window(app: &adw::Application) { gallery_more: gallery_widgets.more.clone(), gallery_upload: gallery_widgets.upload.clone(), gallery_subtitle: gallery_widgets.subtitle.clone(), + gallery_kind: Cell::new(None), + gallery_tabs: gallery_widgets.tabs.clone(), + gallery_dates: gallery_widgets.dates.clone(), + gallery_date_ranges: RefCell::new(vec![None]), + gallery_range: Cell::new(None), + gallery_date_suppress: Cell::new(false), gallery_loading: Cell::new(false), gallery_width: Cell::new(0), photo_tex: RefCell::new(HashMap::new()), @@ -2795,13 +2820,33 @@ fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, popover.set_parent(anchor); popover.connect_closed(|p| p.unparent()); + // Videos lead with Play (stream from the mount, no download); the plain Open + // below still downloads a local copy for anyone who wants one. + if is_video_entry(entry) { + let play = menu_item("Play (stream)", "media-playback-start-symbolic"); + let ui_play = ui.clone(); + let entry_play = entry.clone(); + let pop = popover.clone(); + play.connect_clicked(move |_| { + pop.popdown(); + stream_entry(&ui_play, &entry_play); + }); + menu.append(&play); + } + let open = menu_item("Open", "document-open-symbolic"); let ui_open = ui.clone(); let entry_open = entry.clone(); let pop = popover.clone(); open.connect_clicked(move |_| { pop.popdown(); - activate_entry(&ui_open, &entry_open); + // Open always means "download a local copy and hand off", even for a + // video — `activate_entry` would otherwise stream it. + if is_video_entry(&entry_open) { + download_and_open(&ui_open, &entry_open); + } else { + activate_entry(&ui_open, &entry_open); + } }); menu.append(&open); @@ -2892,7 +2937,33 @@ fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option bool { + !entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video +} + +/// Stream a video straight from the mount, no download. Drive folders *are* part +/// of the FUSE mount, so a player pointed at `/` reads the file +/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it +/// seeks and buffers — instead of waiting for the whole file to land like +/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC +/// `.mkv` starts playing in seconds. +fn stream_entry(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); + let abs = mountpoint.join(&rel); + let Some(path) = abs.to_str() else { + toast_error(&ui, "Couldn't play video", "The file path isn't valid UTF-8."); + return; + }; + toast(ui, &format!("Streaming “{}”…", entry.name)); + play_external(path); +} + +/// Open an entry the Nautilus way: folders descend, videos stream from the mount, +/// other files download-and-open. fn activate_entry(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); if entry.is_dir { @@ -2903,37 +2974,50 @@ fn activate_entry(ui: &Rc, entry: &DirEntry) { } *ui.browser_path.borrow_mut() = rel; load_browser(ui); + } else if is_video_entry(entry) { + // A video streams rather than downloads: that is exactly the "play it, + // don't fetch the whole thing" behaviour this is for. + stream_entry(ui, entry); } else { - // Ignore a repeat activation of a file already downloading, so an - // impatient double-click doesn't kick off a second round-trip. - if !ui.opening.borrow_mut().insert(rel.clone()) { - return; - } - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::OpenFile { path: rel.clone() }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - ui.opening.borrow_mut().remove(&rel); - match result { - Ok(Ok(Response::FilePath { path })) => open_path(&path), - Ok(Ok(Response::Error { message })) => { - toast_error(&ui, "Couldn't open file", &message) - } - _ => toast_error( - &ui, - "Couldn't open file", - "The mount service didn't respond.", - ), - } - }); + download_and_open(ui, entry); } } +/// Download a file's full content into the cache and hand it to the user's +/// default application. The download-and-open path behind both a plain +/// double-click and the context menu's "Open" (including for a video, when the +/// user explicitly wants a local copy rather than to stream it). +fn download_and_open(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + // Ignore a repeat activation of a file already downloading, so an impatient + // double-click doesn't kick off a second round-trip. + if !ui.opening.borrow_mut().insert(rel.clone()) { + return; + } + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenFile { path: rel.clone() }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.opening.borrow_mut().remove(&rel); + match result { + Ok(Ok(Response::FilePath { path })) => open_path(&path), + Ok(Ok(Response::Error { message })) => { + toast_error(&ui, "Couldn't open file", &message) + } + _ => toast_error( + &ui, + "Couldn't open file", + "The mount service didn't respond.", + ), + } + }); +} + /// Pin or unpin an entry through the daemon, then reload to reflect the new /// state. fn toggle_pin(ui: &Rc, entry: &DirEntry) { @@ -6336,6 +6420,11 @@ struct GalleryWidgets { retry: gtk4::Button, upload: gtk4::Button, refresh: gtk4::Button, + /// The All / Photos / Videos / Raw filter toggles, in that order (index maps + /// to [`kind_for_tab`]). + tabs: [gtk4::ToggleButton; 4], + /// The date-jump dropdown, populated with the timeline's months. + dates: gtk4::DropDown, } /// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over @@ -6425,6 +6514,43 @@ fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { header_box.append(&refresh); header_box.append(&upload); + // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented + // control: exactly one is active, and flipping it reloads the timeline + // filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts + // once a page lands. + let tab_labels = ["All", "Photos", "Videos", "Raw"]; + let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| { + gtk4::ToggleButton::builder() + .label(tab_labels[i]) + .active(i == 0) + .build() + }); + // Group the toggles so they behave as a radio set: chaining each to the first + // is what GTK turns into mutual exclusion. + for btn in &tabs[1..] { + btn.set_group(Some(&tabs[0])); + } + let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + tab_group.add_css_class("linked"); + for btn in &tabs { + btn.add_css_class("pill"); + tab_group.append(btn); + } + + // Date jump: "All dates" plus a row per month, filled in once the timeline's + // months are known (see [`refresh_photo_months`]). Pushed to the far end of + // the filter row, opposite the kind toggles. + let dates = gtk4::DropDown::from_strings(&["All dates"]); + dates.add_css_class("pill"); + dates.set_tooltip_text(Some("Jump to a month")); + + let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + filter_bar.append(&tab_group); + let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + filter_bar.append(&spacer); + filter_bar.append(&dates); + // The timeline (plus its pager) or the status page, never both. let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); timeline.append(&scroll); @@ -6442,6 +6568,7 @@ fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&header_box); + inner.append(&filter_bar); inner.append(&content); ( @@ -6458,10 +6585,90 @@ fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { retry, upload, refresh, + tabs, + dates, }, ) } +/// The [`PhotoKind`] filter a gallery tab index selects: index 0 is All (no +/// filter), then Photos / Videos / Raw. Index-aligned with the toggle array. +fn kind_for_tab(index: usize) -> Option { + match index { + 1 => Some(PhotoKind::Photo), + 2 => Some(PhotoKind::Video), + 3 => Some(PhotoKind::Raw), + _ => None, + } +} + +/// The `[from, to)` epoch-second window of a local calendar month, or `None` if +/// the date is somehow unrepresentable. Computed with glib so month rollover and +/// the local timezone (matching the daemon's month buckets) are handled for us. +fn month_range(year: i32, month: i32) -> Option<(i64, i64)> { + let start = glib::DateTime::from_local(year, month, 1, 0, 0, 0.0).ok()?; + let end = start.add_months(1).ok()?; + Some((start.to_unix(), end.to_unix())) +} + +/// English month names, indexed 1..=12. +const MONTH_NAMES: [&str; 12] = [ + "January", "February", "March", "April", "May", "June", "July", "August", "September", + "October", "November", "December", +]; + +/// Rebuild the date-jump dropdown for the active kind: ask the daemon which +/// months the timeline spans and turn them into "Month YYYY (count)" rows, each +/// remembering the window it jumps to. Resets the selection to "All dates" — the +/// caller pairs this with a fresh timeline load. Off the UI thread; a failure +/// just leaves the dropdown as it was. +fn refresh_photo_months(ui: &Rc) { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::PhotoMonths { + kind: ui.gallery_kind.get(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else { + return; + }; + let mut labels = vec!["All dates".to_string()]; + let mut ranges: Vec> = vec![None]; + for m in months { + let name = MONTH_NAMES.get((m.month - 1) as usize).copied().unwrap_or("?"); + labels.push(format!("{name} {} ({})", m.year, m.count)); + ranges.push(month_range(m.year, m.month)); + } + let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect(); + + // Repopulating resets the selection to 0, which would otherwise fire the + // handler and reload; suppress that — the caller is already reloading. + ui.gallery_date_suppress.set(true); + ui.gallery_dates + .set_model(Some(>k4::StringList::new(&label_refs))); + ui.gallery_dates.set_selected(0); + *ui.gallery_date_ranges.borrow_mut() = ranges; + ui.gallery_date_suppress.set(false); + }); +} + +/// Label the filter toggles with the whole-timeline `(photos, videos, raw)` +/// counts, so a glance shows how much sits behind each tab. A tab with nothing +/// behind it is disabled — you can't filter to an empty set — but the currently +/// selected one stays clickable so you can always switch back off it. +fn update_gallery_tabs(ui: &Rc, counts: (usize, usize, usize)) { + let (photos, videos, raw) = counts; + let totals = [photos + videos + raw, photos, videos, raw]; + for (index, tab) in ui.gallery_tabs.iter().enumerate() { + let name = ["All", "Photos", "Videos", "Raw"][index]; + let n = totals[index]; + tab.set_label(&format!("{name} {n}")); + tab.set_sensitive(n > 0 || tab.is_active()); + } +} + /// Wire the gallery: install the section factory, the zoom gestures, the pager /// and the upload button. Activating a thumbnail downloads the photo and opens it /// in the in-app lightbox. @@ -6582,6 +6789,48 @@ fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::ScrolledWindo load_gallery(&ui_more, true); }); + // Filter toggles: flipping to a tab reloads the timeline filtered to that + // kind. Only the button being switched *on* acts — the group also fires a + // `toggled` for the one switching off, which this skips — and a redundant + // toggle to the already-current kind is a no-op. + for (index, tab) in ui.gallery_tabs.iter().enumerate() { + let ui_tab = ui.clone(); + tab.connect_toggled(move |btn| { + if !btn.is_active() { + return; + } + let kind = kind_for_tab(index); + if ui_tab.gallery_kind.get() == kind { + return; + } + ui_tab.gallery_kind.set(kind); + // A different kind has a different set of months; clearing the active + // window makes the reload rebuild the date jump for the new kind. + ui_tab.gallery_range.set(None); + load_gallery(&ui_tab, false); + }); + } + + // Date jump: selecting a month loads that window; "All dates" (row 0) clears + // it. Skipped while the model is being repopulated (see `gallery_date_suppress`). + let ui_dates = ui.clone(); + ui.gallery_dates.connect_selected_notify(move |dd| { + if ui_dates.gallery_date_suppress.get() { + return; + } + let range = ui_dates + .gallery_date_ranges + .borrow() + .get(dd.selected() as usize) + .copied() + .flatten(); + if ui_dates.gallery_range.get() == range { + return; + } + ui_dates.gallery_range.set(range); + load_gallery(&ui_dates, false); + }); + let ui_upload = ui.clone(); ui.gallery_upload.connect_clicked(move |_| { let dialog = gtk4::FileDialog::builder() @@ -6845,6 +7094,20 @@ fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { overlay.add_overlay(&picture); overlay.add_overlay(&caption); + // A video reads as a video at a glance: a play glyph centred over the poster + // thumbnail. Kept above the caption scrim so it stays legible on hover. + let is_video = tile.photo.kind == PhotoKind::Video; + if is_video { + let badge = gtk4::Image::builder() + .icon_name("media-playback-start-symbolic") + .pixel_size(28) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + badge.add_css_class("photo-video-badge"); + overlay.add_overlay(&badge); + } + let button = gtk4::Button::builder() .child(&overlay) .width_request(tile.width) @@ -6858,9 +7121,17 @@ fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { want_thumb(ui, &tile.photo, &picture); + // A still opens in the in-app lightbox; a video can't render there, so it + // downloads and hands off to an external player instead. let ui_open = ui.clone(); let uid = tile.photo.uid.clone(); - button.connect_clicked(move |_| open_photo_viewer(&ui_open, uid.clone())); + button.connect_clicked(move |_| { + if is_video { + play_video(&ui_open, uid.clone()); + } else { + open_photo_viewer(&ui_open, uid.clone()); + } + }); button } @@ -7142,9 +7413,15 @@ fn repaint_gallery(ui: &Rc) { let count = ui.gallery_model.n_items(); ui.gallery_subtitle.set_visible(count > 0); + // The noun tracks the active filter, so a Videos tab doesn't count "photos". + let (one, many) = match ui.gallery_kind.get() { + Some(PhotoKind::Video) => ("video", "videos"), + Some(PhotoKind::Raw) => ("raw photo", "raw photos"), + _ => ("photo", "photos"), + }; ui.gallery_subtitle.set_label(&match count { - 1 => "1 photo".to_string(), - n => format!("{n} photos"), + 1 => format!("1 {one}"), + n => format!("{n} {many}"), }); } @@ -7341,6 +7618,11 @@ fn prefetch_photo(ui: &Rc, viewer: &Rc, delta: i32) { else { return; }; + // Never prefetch a video: the lightbox skips over it, and warming it would + // mean downloading a whole clip the user won't watch from here. + if photo.kind == PhotoKind::Video { + return; + } let rx = spawn_request( ui.dirs.control_socket(), Request::OpenPhoto { uid: photo.uid }, @@ -7529,15 +7811,41 @@ fn navigate_photo(ui: &Rc, viewer: &Rc, delta: i32) { } let uid_val = viewer.uid.borrow().clone(); let current_idx = find_photo_index(model, &uid_val).unwrap_or(0); - let next_idx = (current_idx as i32 + delta).clamp(0, n as i32 - 1) as u32; - let Some(obj) = model.item(next_idx) else { + // Where the raw step lands (prev/next are ±1; Home/End are huge deltas that + // clamp to the ends), and which way it was travelling. + let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1); + let dir = if delta < 0 { -1 } else { 1 }; + + // The lightbox renders stills only — a video plays in an external player, so + // it has no frame here. Land on the nearest renderable item, preferring the + // travel direction and falling back to scanning inward from a boundary (so + // Home/End still reach the first/last still even when videos sit at the end). + let renderable = |idx: u32| -> Option { + model + .item(idx) + .and_then(|obj| obj.downcast_ref::().map(|b| b.borrow::().clone())) + .filter(|photo| photo.kind != PhotoKind::Video) + }; + let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> { + let mut probe = from; + while (0..n as i32).contains(&probe) { + if let Some(photo) = renderable(probe as u32) { + return Some((probe as u32, photo)); + } + probe += step; + } + None + }; + // Scan the travel direction first. Only fall back to scanning inward when the + // step landed on a boundary — that is the Home/End case (jump to first/last, + // then step inward past any videos there); a mid-list prev/next with only + // videos ahead should simply stay put rather than reverse. + let at_boundary = start == 0 || start == n as i32 - 1; + let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten()); + let Some((next_idx, photo)) = landing else { return; }; - let Some(boxed) = obj.downcast_ref::() else { - return; - }; - let photo = boxed.borrow::().clone(); *viewer.uid.borrow_mut() = photo.uid.clone(); show_photo_position(viewer, next_idx, n, photo.capture_time); @@ -7903,6 +8211,12 @@ fn load_gallery(ui: &Rc, append: bool) { "Reading your Proton Drive timeline.", false, ); + // Rebuild the date jump for the current kind, but only for a full-span + // load — a jump *to* a month sets a range and reloads, and refreshing the + // dropdown then would fight the selection the user just made. + if ui.gallery_range.get().is_none() { + refresh_photo_months(ui); + } } let offset = ui.gallery_model.n_items() as usize; ui.gallery_loading.set(true); @@ -7914,6 +8228,8 @@ fn load_gallery(ui: &Rc, append: bool) { Request::PhotosTimeline { offset, limit: PHOTOS_PAGE, + kind: ui.gallery_kind.get(), + range: ui.gallery_range.get(), }, ); let ui = ui.clone(); @@ -7923,7 +8239,11 @@ fn load_gallery(ui: &Rc, append: bool) { ui.gallery_loading.set(false); ui.gallery_more.set_sensitive(true); match result { - Ok(Ok(Response::Photos { available, items })) => { + Ok(Ok(Response::Photos { + available, + items, + counts, + })) => { if !available { gallery_status( &ui, @@ -7934,6 +8254,10 @@ fn load_gallery(ui: &Rc, append: bool) { ); return; } + // Label the filter tabs with live per-kind counts. + if let Some(counts) = counts { + update_gallery_tabs(&ui, counts); + } // 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 @@ -8043,6 +8367,47 @@ fn open_path(path: &str) { } } +/// Play a video with an external player. Prefers `mpv` — it sniffs the container +/// from the bytes, so the cache's extensionless blob plays fine, and it is the +/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the +/// user's default handler when mpv isn't installed. +fn play_external(path: &str) { + if Command::new("mpv").arg(path).spawn().is_ok() { + return; + } + open_path(path); +} + +/// Download a Photos-library video, then hand it to an external player. Unlike a +/// still photo — which the in-app lightbox can render — a video needs a real +/// player, and the photos volume isn't part of the FUSE mount, so there is no +/// path to stream it from: [`Request::OpenPhoto`] fetches the whole file into the +/// cache (served straight from there on a repeat) and we launch the player on it. +/// +/// For large videos kept in an on-demand *drive* folder, streaming through the +/// mount is the better route — that is the file-browser "Play" action, not this. +fn play_video(ui: &Rc, uid: String) { + toast(ui, "Preparing video…"); + let rx = spawn_request(ui.dirs.control_socket(), Request::OpenPhoto { uid }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => play_external(&path), + Ok(Ok(Response::Error { message })) => { + toast_error(&ui, "Couldn't open this video", &message) + } + Ok(Ok(_)) => toast_error( + &ui, + "Couldn't open this video", + "Unexpected reply from the mount service.", + ), + Ok(Err(_)) | Err(_) => { + toast_error(&ui, "Couldn't open this video", "Couldn't reach Proton Drive.") + } + } + }); +} + /// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`). fn human_bytes(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index 7233ce9..e245cd3 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -224,6 +224,13 @@ struct Ui { /// further input is ignored. opening: Cell, indexing: Cell, + /// Set when Enter arrives before the in-flight query has rendered (typing + /// then hitting Enter faster than the search debounce). The open is honoured + /// against the selected row once the fresh results settle, rather than being + /// dropped — so a plain "type, Enter" always opens something. + open_pending: Cell, + /// The window, so a deferred open (above) can reach it from `render`. + window: RefCell>, } /// A titled group of rows: header (hidden when empty) plus its list. @@ -438,7 +445,10 @@ fn build_window(app: &adw::Application) { pending: Cell::new(0), opening: Cell::new(false), indexing: Cell::new(false), + open_pending: Cell::new(false), + window: RefCell::new(None), }); + *ui.window.borrow_mut() = Some(window.clone()); for (filter, label) in FILTERS { let chip = gtk4::ToggleButton::builder() @@ -506,9 +516,9 @@ fn build_window(app: &adw::Application) { glib::Propagation::Stop } gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter => { - if let Some(index) = ui_key.cursor.get() { - ui_key.open(index, &window_key); - } + // Fall back to the top row when nothing is explicitly selected + // (e.g. results haven't rendered yet); `open` guards the rest. + ui_key.open(ui_key.cursor.get().unwrap_or(0), &window_key); glib::Propagation::Stop } _ => glib::Propagation::Proceed, @@ -521,6 +531,9 @@ fn build_window(app: &adw::Application) { let debounce: Rc>> = Rc::new(RefCell::new(None)); let ui_changed = ui.clone(); entry.connect_changed(move |entry| { + // A fresh keystroke supersedes any Enter that was waiting on results: + // the user is still refining the query, not asking to open yet. + ui_changed.open_pending.set(false); if let Some(source) = debounce.borrow_mut().take() { source.remove(); } @@ -821,6 +834,18 @@ impl Ui { } self.hint.set_label(&self.status_text(total, searching)); + + // Honour an Enter that arrived before this query rendered, now that the + // results (and the cursor) are settled. Only once both searches are in, + // so it lands on the final list, not a half-populated one. + if self.open_pending.get() && self.pending.get() == 0 { + self.open_pending.set(false); + if let (Some(index), Some(window)) = + (self.cursor.get(), self.window.borrow().clone()) + { + self.open(index, &window); + } + } } fn status_text(&self, total: usize, searching: bool) -> String { @@ -906,9 +931,11 @@ impl Ui { return; } // The rows on screen may belong to an older query whose reply is still - // settling. Refuse to act until the visible list matches what's typed, - // so Enter can never launch a file the user has already typed past. + // settling. Don't act on them — Enter must never launch a file the user + // has typed past. But don't drop the intent either: remember it and open + // the right row once the fresh results land (see `render`). if self.entry.text().trim() != *self.rendered_query.borrow() { + self.open_pending.set(true); return; } let Some(hit) = self.visible.borrow().get(index).cloned() else {