diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 5de4454..df8ad87 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -163,6 +163,10 @@ pub struct AppConfig { /// Defaulted for configs predating the field. #[serde(default)] pub files_view: FilesView, + /// How the desktop app's Photos page lays out its grid. Defaulted for + /// configs predating the field. + #[serde(default)] + pub photos_view: PhotosView, /// The desktop app's main window size, remembered between runs. Defaulted /// for configs predating the field. #[serde(default)] @@ -220,6 +224,10 @@ pub struct FilesView { pub descending: bool, /// Folders ahead of files, whatever the sort. pub folders_first: bool, + /// The icon grid's thumbnail edge in logical pixels. `None` means the + /// app's default. + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_size: Option, } impl Default for FilesView { @@ -229,10 +237,23 @@ impl Default for FilesView { sort: FileSort::Name, descending: false, folders_first: true, + icon_size: None, } } } +/// The Photos page's grid choices, remembered between runs. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(default)] +pub struct PhotosView { + /// The zoom level: the row height the grid aims for, in logical pixels. + /// `None` means the app's default. + pub row_height: Option, + /// Crop every photo to a square tile instead of showing it at its own + /// shape. + pub square: bool, +} + /// The desktop app's main window, as it was when it last closed. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(default)] @@ -761,6 +782,11 @@ mod tests { sort: FileSort::Modified, descending: true, folders_first: false, + icon_size: Some(192), + }, + photos_view: PhotosView { + row_height: Some(240), + square: true, }, window: WindowState { width: 1200, @@ -792,6 +818,7 @@ mod tests { assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.download_limit, None); assert_eq!(decoded.files_view, config.files_view); + assert_eq!(decoded.photos_view, config.photos_view); assert_eq!(decoded.window, config.window); } @@ -835,6 +862,17 @@ mod tests { let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap(); assert_eq!(partial.sort, FileSort::Size); assert!(partial.folders_first); + assert_eq!(partial.icon_size, None); + } + + #[test] + fn a_config_without_a_photos_view_gets_the_default() { + let decoded: AppConfig = + serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap(); + assert_eq!(decoded.photos_view, PhotosView::default()); + let partial: PhotosView = serde_json::from_str(r#"{"square":true}"#).unwrap(); + assert!(partial.square); + assert_eq!(partial.row_height, None); } #[test] diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 14a37db..4a757d5 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -1851,6 +1851,34 @@ pub fn is_thumbnail_image_name(name: &str) -> bool { || RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) } +/// The smallest `.ts` file taken for an MPEG transport stream. `.ts` is +/// TypeScript source as often as it is a recording, and the two sit far apart +/// in size: a source file is kilobytes, while a second of broadcast video is +/// already megabytes. Drive often stores either kind as +/// `application/octet-stream`, so the size is the only signal a listing has. +pub const TS_VIDEO_MIN_BYTES: u64 = 1024 * 1024; + +/// Whether a file named `name`, of `size` bytes when the caller knows it, is a +/// video. The extension decides, except for `.ts`, which counts as video only +/// from [`TS_VIDEO_MIN_BYTES`] up. A `.ts` of unknown size counts as video, as +/// [`PhotoKind::classify`] has it. +pub fn is_video_file_name(name: &str, size: Option) -> bool { + if PhotoKind::classify(Some(name), None) != PhotoKind::Video { + return false; + } + let transport_stream = name + .rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("ts")); + !transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES) +} + +/// Whether file listings show a thumbnail for a file named `name`, of `size` +/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video, +/// whose thumbnail is a frame the daemon takes with ffmpeg. +pub fn is_thumbnail_media_name(name: &str, size: Option) -> bool { + is_thumbnail_image_name(name) || is_video_file_name(name, size) +} + /// Whether `name` is one of the concrete RAW formats handled by the exiftool /// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose /// preview availability is not dependable. @@ -2675,6 +2703,41 @@ mod tests { assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo); } + #[test] + fn a_ts_file_is_a_video_only_at_the_size_of_one() { + const GB: u64 = 1024 * 1024 * 1024; + assert!(!is_video_file_name("app.ts", Some(3 * 1024))); + assert!(!is_video_file_name( + "bundle.TS", + Some(TS_VIDEO_MIN_BYTES - 1) + )); + assert!(is_video_file_name("recording.ts", Some(9 * GB))); + assert!(is_video_file_name("recording.TS", Some(TS_VIDEO_MIN_BYTES))); + assert!(is_video_file_name("recording.ts", None)); + // Only `.ts` is in doubt; every other video name is one at any size. + assert!(is_video_file_name("clip.mkv", Some(10))); + assert!(is_video_file_name("clip.m2ts", Some(10))); + assert!(!is_video_file_name("notes.txt", Some(9 * GB))); + assert!(!is_video_file_name("ts", Some(9 * GB))); + } + + #[test] + fn listings_thumbnail_images_and_videos() { + assert!(is_thumbnail_media_name("photo.JPG", Some(10))); + assert!(is_thumbnail_media_name("camera.nef", None)); + assert!(is_thumbnail_media_name("clip.mp4", Some(10))); + assert!(is_thumbnail_media_name( + "recording.ts", + Some(64 * 1024 * 1024) + )); + assert!(!is_thumbnail_media_name("app.ts", Some(2048))); + assert!(!is_thumbnail_media_name( + "song.flac", + Some(64 * 1024 * 1024) + )); + assert!(!is_thumbnail_media_name("notes.txt", None)); + } + /// Keep every extension accepted by the shared classifier covered. Drive /// activation uses this classification before it knows whether opening a /// file should stream through FUSE or materialise a complete cache entry; diff --git a/crates/pdfs-core/src/opener.rs b/crates/pdfs-core/src/opener.rs index 6795f86..c21d477 100644 --- a/crates/pdfs-core/src/opener.rs +++ b/crates/pdfs-core/src/opener.rs @@ -27,6 +27,7 @@ use std::os::unix::process::CommandExt; use std::path::Path; use std::process::{Command, Stdio}; +use crate::control::is_video_file_name; use serde::{Deserialize, Serialize}; /// Environment override for the terminal used by `"terminal": true` rules. @@ -108,11 +109,12 @@ impl OpenWith { /// a Markdown file). The rules are about *what the user opened*, so the /// caller passes that name and the argv still gets the real path. pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec { + let kind = Kind::of(path, name, is_dir); for rule in &self.rules { if rule.command.is_empty() { continue; } - if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) { + if !rule.patterns.iter().any(|p| matches(p, name, kind)) { continue; } let argv = expand(&rule.command, path); @@ -325,16 +327,46 @@ fn expand_env(token: &str) -> Option { value.filter(|v| !v.is_empty()) } +/// What is being opened, as far as the `@class` patterns care. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Dir, + Video, + File, +} + +impl Kind { + /// `.ts` names TypeScript and MPEG transport streams alike, so for that + /// one extension the size on disk decides (see + /// [`is_video_file_name`](crate::control::is_video_file_name)). No other + /// name costs a `stat`. + fn of(path: &Path, name: &str, is_dir: bool) -> Kind { + if is_dir { + return Kind::Dir; + } + let size = extension(name) + .eq_ignore_ascii_case("ts") + .then(|| std::fs::metadata(path).ok().map(|meta| meta.len())) + .flatten(); + if is_video_file_name(name, size) { + Kind::Video + } else { + Kind::File + } + } +} + /// Whether `pattern` — a glob or an `@class` — matches this name. -fn matches(pattern: &str, name: &str, is_dir: bool) -> bool { +fn matches(pattern: &str, name: &str, kind: Kind) -> bool { let name_lower = name.to_lowercase(); + let file = kind == Kind::File; match pattern.to_lowercase().as_str() { "@any" => true, - "@dir" => is_dir, - "@text" => !is_dir && class_text(&name_lower), - "@document" => !is_dir && class_document(&name_lower), - "@image" => !is_dir && class_image(&name_lower), - "@media" => !is_dir && class_media(&name_lower), + "@dir" => kind == Kind::Dir, + "@text" => file && class_text(&name_lower), + "@document" => file && class_document(&name_lower), + "@image" => file && class_image(&name_lower), + "@media" => kind == Kind::Video || (file && class_media(&name_lower)), other if other.starts_with('@') => false, other => glob_match(other, &name_lower), } @@ -533,6 +565,37 @@ mod tests { ); } + #[test] + fn a_ts_file_opens_as_media_or_text_by_its_size() { + let policy = OpenWith { + terminal: Some(vec!["alacritty".into(), "-e".into()]), + default: None, + rules: vec![ + rule(&["@text"], &["nvim"], true), + rule(&["@media"], &["mpv"], false), + ], + }; + let dir = std::env::temp_dir().join(format!("pdfs-opener-ts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("blob-source"); + std::fs::write(&source, "export const answer = 42;\n").unwrap(); + let recording = dir.join("blob-recording"); + std::fs::File::create(&recording) + .unwrap() + .set_len(crate::control::TS_VIDEO_MIN_BYTES) + .unwrap(); + + let source_argv = policy.command_for_named(&source, "app.ts", false); + let recording_argv = policy.command_for_named(&recording, "recording.ts", false); + // A file that is not on this machine yet has no size to go by. + let unknown_argv = policy.command_for_named(&dir.join("missing"), "recording.ts", false); + std::fs::remove_dir_all(&dir).unwrap(); + + assert_eq!(source_argv[..3], ["alacritty", "-e", "nvim"]); + assert_eq!(recording_argv[0], "mpv"); + assert_eq!(unknown_argv[0], "mpv"); + } + #[test] fn an_empty_policy_is_xdg_open() { let policy = OpenWith::default(); diff --git a/crates/pdfs-fuse/src/drive.rs b/crates/pdfs-fuse/src/drive.rs index f1196ab..162c60e 100644 --- a/crates/pdfs-fuse/src/drive.rs +++ b/crates/pdfs-fuse/src/drive.rs @@ -29,7 +29,7 @@ use proton_drive_rs::proton_sdk::error::Result; use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId}; use proton_drive_rs::{ AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, - ProtonDriveClient, RevisionReader, Thumbnail, + ProtonDriveClient, RevisionReader, Thumbnail, ThumbnailType, }; /// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`]. @@ -118,6 +118,14 @@ pub(crate) trait DriveApi: Send + Sync { async fn open_revision(&self, uid: &NodeUid) -> Result>; + /// The file's thumbnail of `thumbnail_type`, or `None` when its revision + /// carries none. + async fn download_thumbnail( + &self, + uid: &NodeUid, + thumbnail_type: ThumbnailType, + ) -> Result>>; + async fn download_file_to_dyn( &self, uid: &NodeUid, @@ -445,6 +453,14 @@ impl DriveApi for ProtonDriveClient { Ok(Arc::new(reader)) } + async fn download_thumbnail( + &self, + uid: &NodeUid, + thumbnail_type: ThumbnailType, + ) -> Result>> { + ProtonDriveClient::download_thumbnail(self, uid, thumbnail_type).await + } + async fn download_file_to_dyn( &self, uid: &NodeUid, diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index d53198f..4e7b36b 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fs::OpenOptions; use std::io::{Read as _, Write as _}; -use std::os::unix::fs::OpenOptionsExt as _; +use std::os::unix::fs::{FileExt as _, OpenOptionsExt as _}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -20,14 +20,17 @@ use base64::Engine as _; use pdfs_core::control::{ FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo, ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name, + is_thumbnail_media_name, }; use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::proton_sdk::error::Result as DriveResult; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType}; use tracing::{debug, info, warn}; +use super::drive::RevisionRead; use super::{ Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority, node_size, now_ms, parse_uid, @@ -58,9 +61,18 @@ const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); static EXIFTOOL_MISSING_WARNING: Once = Once::new(); static FFMPEG_MISSING_WARNING: Once = Once::new(); -/// Largest video downloaded whole to grab one frame for its thumbnail. The -/// download lands in memory, so a long 4K recording is left without one. -const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024; +/// How much of a video's start a thumbnail downloads. The first frame, and +/// the one a second in, sit inside it at any common bitrate. A video is never +/// downloaded whole for one frame: a recording can be many gigabytes. +const VIDEO_HEAD_BYTES: u64 = 16 * 1024 * 1024; +/// A video no larger than this is downloaded whole: its head and tail would +/// cover most of it anyway. +const VIDEO_WHOLE_MAX_BYTES: u64 = 24 * 1024 * 1024; +/// Largest MP4/MOV index (`moov` box) fetched from past the head. An hour of +/// video indexes in a few MiB; anything larger is not worth one frame. +const VIDEO_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024; +/// Top-level boxes walked looking for the index before giving up. +const VIDEO_BOX_WALK_MAX: usize = 32; /// A single frame is seconds of work even for 4K; this only stops a wedged /// ffmpeg from holding a thumbnail permit. const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30); @@ -245,8 +257,8 @@ struct RawPreviewUnavailable; /// 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, or a video - /// too large to fetch for one frame. Permanent. + /// Decoded nothing: a format this build has no decoder for, a video + /// ffmpeg takes no frame from, or a `.ts` that is not one. Permanent. Undecodable, /// The photo couldn't be downloaded. Transient: try again next time. Unavailable, @@ -577,21 +589,6 @@ fn warn_ffmpeg_missing() { }); } -/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a -/// staging copy. See [`video_file_thumbnail`]. -fn video_thumbnail( - bytes: &[u8], - name: &str, - staging_dir: &Path, - ffmpeg: &OsStr, - timeout: Duration, -) -> ScaleAttempt { - let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else { - return ScaleAttempt::Unavailable; - }; - video_file_thumbnail(&file.0, ffmpeg, timeout) -} - /// A thumbnail from one frame of the video at `path`. A second in, past the /// black lead-in most clips open on, and the first frame for a clip shorter than /// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out @@ -624,6 +621,151 @@ fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> Scale ScaleAttempt::Undecodable } +/// What a thumbnail is made from. +enum Fetched { + /// The thumbnail itself, from the server. + Ready(Vec), + /// Nothing: a video with no server thumbnail, and no ffmpeg to take one. + Nothing, + /// A whole image. + Whole(Vec), + Video(VideoSample), +} + +/// The parts of a video its thumbnail needs, downloaded: the start of the +/// file, and for an MP4 or MOV whose index is further in, that index. +struct VideoSample { + /// The whole file's length. + size: u64, + head: Vec, + /// The `moov` box and its offset, when it is not inside `head`. + index: Option<(u64, Vec)>, +} + +impl VideoSample { + /// Whether this could be an MPEG transport stream: one opens on the 0x47 + /// sync byte, and repeats it every 188-byte packet. Only asked of `.ts` + /// files, which are TypeScript source as often as they are video. + fn is_transport_stream(&self) -> bool { + self.head.first() == Some(&0x47) && self.head.get(188).is_none_or(|&byte| byte == 0x47) + } +} + +/// Download what a thumbnail of the video `reader` reads needs. A file of up +/// to [`VIDEO_WHOLE_MAX_BYTES`] comes whole. A larger one brings its first +/// [`VIDEO_HEAD_BYTES`], which is all a stream format (Matroska, WebM, MPEG-TS, +/// AVI) needs for a frame, plus the index of an ISO media file (MP4, MOV, 3GP) +/// written with its index after the media data, which is how most cameras and +/// phones write it. +async fn sample_video(reader: &dyn RevisionRead) -> DriveResult { + let size = reader.size(); + if size <= VIDEO_WHOLE_MAX_BYTES { + let head = reader.read_at(0, size).await?; + return Ok(VideoSample { + size, + head, + index: None, + }); + } + let head = reader.read_at(0, VIDEO_HEAD_BYTES).await?; + let index = match iso_media_index(reader, &head, size).await? { + Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => { + Some((offset, reader.read_at(offset, len).await?)) + } + _ => None, + }; + Ok(VideoSample { size, head, index }) +} + +/// Where the `moov` box of an ISO media file sits, as `(offset, length)`. +/// `None` for a file that is not one, or whose index is missing, oversized or +/// past [`VIDEO_BOX_WALK_MAX`] boxes. Box headers past `head` are read one at a +/// time, 16 bytes each. +async fn iso_media_index( + reader: &dyn RevisionRead, + head: &[u8], + size: u64, +) -> DriveResult> { + const FIRST_BOXES: [&[u8; 4]; 6] = [b"ftyp", b"moov", b"mdat", b"wide", b"free", b"skip"]; + if !head + .get(4..8) + .is_some_and(|kind| FIRST_BOXES.iter().any(|known| kind == *known)) + { + return Ok(None); + } + let mut offset = 0u64; + for _ in 0..VIDEO_BOX_WALK_MAX { + if offset.saturating_add(8) > size { + break; + } + let header = match usize::try_from(offset) + .ok() + .and_then(|start| head.get(start..start.checked_add(16)?)) + { + Some(header) => header.to_vec(), + None => reader.read_at(offset, 16).await?, + }; + let Some((kind, len)) = iso_box_header(&header, size - offset) else { + break; + }; + if kind == *b"moov" { + return Ok((len <= VIDEO_INDEX_MAX_BYTES).then_some((offset, len))); + } + offset = offset.saturating_add(len); + } + Ok(None) +} + +/// The type and length of the ISO box whose header starts `header`, of a box +/// that may run at most `left` bytes to the end of the file. +fn iso_box_header(header: &[u8], left: u64) -> Option<([u8; 4], u64)> { + let size = u32::from_be_bytes(header.get(0..4)?.try_into().ok()?); + let kind: [u8; 4] = header.get(4..8)?.try_into().ok()?; + let len = match size { + // Runs to the end of the file. + 0 => left, + // The real length follows as 64 bits. + 1 => u64::from_be_bytes(header.get(8..16)?.try_into().ok()?), + len => u64::from(len), + }; + (8..=left).contains(&len).then_some((kind, len)) +} + +/// A thumbnail from one frame of a downloaded [`VideoSample`]. The sample is +/// laid out in a staging file at the offsets it came from, so ffmpeg finds an +/// index where the file's own boxes say it is. The gap between holds no data +/// and costs no disk. +fn video_sample_thumbnail( + sample: &VideoSample, + name: &str, + staging_dir: &Path, + ffmpeg: &OsStr, + timeout: Duration, +) -> ScaleAttempt { + let transport_stream_name = Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("ts")); + if transport_stream_name && !sample.is_transport_stream() { + return ScaleAttempt::Undecodable; + } + let Some(file) = RawTempFile::create_in(staging_dir, &sample.head, name) else { + return ScaleAttempt::Unavailable; + }; + if let Some((offset, index)) = &sample.index { + let laid_out = OpenOptions::new() + .write(true) + .open(&file.0) + .and_then(|staged| { + staged.write_all_at(index, *offset)?; + staged.set_len(sample.size.max(offset + index.len() as u64)) + }); + if laid_out.is_err() { + return ScaleAttempt::Unavailable; + } + } + video_file_thumbnail(&file.0, ffmpeg, timeout) +} + /// The thumbnail to attach to an upload of the local file at `path`, which is /// named `name` in Drive. Without one, Drive's own apps show a generic icon and /// the gallery has to download the whole file to make its own. @@ -1406,10 +1548,10 @@ impl Core { .map(|item| (item.uid.clone(), item.name.clone())) .collect(); - // Proton only stores server-generated thumbnails for nodes in Photos. - // These requests come exclusively from ordinary Drive, Shared and Trash - // views, so downloading a remote thumbnail here would be a guaranteed - // miss. Generate directly from the full image and cache the result. + // Drive makes no thumbnails itself; a file has one only when its + // uploader attached it, as this client and Proton's apps do. The + // generation step asks for that first, and only makes one from the + // file's content without it. let mut wanted = Vec::new(); let mut seen = HashSet::new(); for (raw, uid, modified) in &parsed { @@ -1560,7 +1702,10 @@ impl Core { return (uid, ThumbAttempt::Cancelled); } let video = is_video_name(&name); - if video && !ffmpeg_available() { + // Without ffmpeg a video can still have its uploader's + // thumbnail, but Photos has asked the server already. + let can_sample = !video || ffmpeg_available(); + if !can_sample && matches!(job, ThumbJob::Photos) { return (uid, ThumbAttempt::Unavailable); } @@ -1609,21 +1754,34 @@ impl Core { return (uid, ThumbAttempt::Cancelled); } - // A video is downloaded whole for one frame, so a long - // recording is not worth it — and would not fit in memory. - if video { - match core.photos().get_node(&uid).await { - Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => { - info!(%uid, size = node_size(&node), "video too large for a local thumbnail"); - return (uid, ThumbAttempt::Undecodable); - } - Ok(Some(_)) => {} - Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable), - } - } - let download_uid = uid.clone(); - let download = async move { client.download_file(&download_uid).await }; + let drive = core.drive.clone(); + let download = async move { + // Photos asked the server in one batch already. A + // file view's or a build's file may carry a + // thumbnail its uploader made, which costs one + // small download instead of its content. + if !matches!(job, ThumbJob::Photos) + && let Some(bytes) = drive + .download_thumbnail(&download_uid, ThumbnailType::Thumbnail) + .await? + .filter(|bytes| !bytes.is_empty()) + { + return Ok(Fetched::Ready(bytes)); + } + if !can_sample { + return Ok(Fetched::Nothing); + } + if video { + let reader = drive.open_revision(&download_uid).await?; + sample_video(reader.as_ref()).await.map(Fetched::Video) + } else { + client + .download_file(&download_uid) + .await + .map(Fetched::Whole) + } + }; tokio::pin!(download); let downloaded = match job { ThumbJob::Files(_) => loop { @@ -1661,10 +1819,10 @@ impl Core { let Some(downloaded) = downloaded else { return (uid, ThumbAttempt::Cancelled); }; - let bytes = match downloaded { - Ok(bytes) => bytes, + let fetched = match downloaded { + Ok(fetched) => fetched, Err(e) => { - warn!(%uid, error = %e, "image download for thumbnail failed"); + warn!(%uid, error = %e, "download for a thumbnail failed"); return (uid, ThumbAttempt::Unavailable); } }; @@ -1674,18 +1832,20 @@ impl Core { // Decoding + scaling a 20 MP JPEG is CPU-bound and would // stall the runtime's worker; hand it to the blocking pool. let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); - let made = tokio::task::spawn_blocking(move || { - if video { - video_thumbnail( - &bytes, - &name, - &staging_dir, - OsStr::new("ffmpeg"), - FFMPEG_TIMEOUT, - ) - } else { - scale_thumbnail(&bytes, &name, &staging_dir) - } + let made = tokio::task::spawn_blocking(move || match fetched { + Fetched::Video(sample) => video_sample_thumbnail( + &sample, + &name, + &staging_dir, + OsStr::new("ffmpeg"), + FFMPEG_TIMEOUT, + ), + Fetched::Whole(bytes) => scale_thumbnail(&bytes, &name, &staging_dir), + Fetched::Nothing => ScaleAttempt::Unavailable, + Fetched::Ready(bytes) => match ratio_of(&bytes) { + Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }), + None => ScaleAttempt::Undecodable, + }, }) .await .unwrap_or(ScaleAttempt::Unavailable); @@ -1869,7 +2029,7 @@ impl Core { if seen_folders.insert(entry.uid) { folders.push(folder.join(entry.name)); } - } else if is_thumbnail_image_name(&entry.name) { + } else if is_thumbnail_media_name(&entry.name, Some(entry.size)) { match parse_uid(&entry.uid) { Some(uid) if seen_images.insert(uid.clone()) => { found_here += 1; @@ -2295,15 +2455,19 @@ mod decode_budget_tests { mod thumb_tests { use super::{ RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES, - can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name, - ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes, - thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail, + VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary, + ffmpeg_available, fit_upload_thumb, is_video_name, ratio_of, sample_video, scale_thumbnail, + scale_thumbnail_with_exiftool, similar_hashes, thumbnail_build_may_start, + upload_thumbnails_with, video_sample_thumbnail, }; + use crate::drive::RevisionRead; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; use proton_drive_rs::ThumbnailType; + use proton_drive_rs::proton_sdk::error::Result as DriveResult; use std::collections::HashMap; use std::ffi::OsStr; use std::os::unix::fs::PermissionsExt as _; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; /// A `width`×`height` JPEG, standing in for a camera photo the server never @@ -2536,6 +2700,199 @@ mod thumb_tests { std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id())) } + /// A sample of a video small enough to come whole. + fn whole(bytes: &[u8]) -> VideoSample { + VideoSample { + size: bytes.len() as u64, + head: bytes.to_vec(), + index: None, + } + } + + fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(future) + } + + /// A Drive revision of `size` bytes, zero except for the parts written in, + /// that counts the bytes read from it. Stands for a recording far larger + /// than a test should hold in memory. + struct FakeRevision { + size: u64, + parts: Vec<(u64, Vec)>, + read: AtomicU64, + } + + impl FakeRevision { + fn new(size: u64) -> Self { + Self { + size, + parts: Vec::new(), + read: AtomicU64::new(0), + } + } + + fn with(mut self, offset: u64, bytes: Vec) -> Self { + self.parts.push((offset, bytes)); + self + } + } + + #[async_trait::async_trait] + impl RevisionRead for FakeRevision { + fn size(&self) -> u64 { + self.size + } + + fn block_sizes(&self) -> &[u64] { + &[] + } + + async fn read_at(&self, offset: u64, length: u64) -> DriveResult> { + let end = offset.saturating_add(length).min(self.size); + let mut out = vec![0u8; end.saturating_sub(offset) as usize]; + for (start, bytes) in &self.parts { + let part_end = start + bytes.len() as u64; + let (from, to) = (offset.max(*start), end.min(part_end)); + if from < to { + out[(from - offset) as usize..(to - offset) as usize] + .copy_from_slice(&bytes[(from - start) as usize..(to - start) as usize]); + } + } + self.read.fetch_add(out.len() as u64, Ordering::Relaxed); + Ok(out) + } + } + + /// An ISO media box of `len` bytes, of which only the header is returned. + fn iso_box_header(kind: &[u8; 4], len: u64) -> Vec { + if let Ok(len) = u32::try_from(len) { + [&len.to_be_bytes()[..], kind].concat() + } else { + [&1u32.to_be_bytes()[..], kind, &len.to_be_bytes()[..]].concat() + } + } + + const GIB: u64 = 1024 * 1024 * 1024; + + #[test] + fn a_large_stream_video_downloads_its_head_alone() { + let mut head = vec![0u8; 188 * 2]; + head[0] = 0x47; + head[188] = 0x47; + let revision = FakeRevision::new(9 * GIB).with(0, head); + let sample = block_on(sample_video(&revision)).unwrap(); + assert_eq!(sample.size, 9 * GIB); + assert_eq!(sample.head.len() as u64, VIDEO_HEAD_BYTES); + assert!(sample.index.is_none()); + assert!(sample.is_transport_stream()); + assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES); + } + + /// A camera MP4 keeps its index after gigabytes of media data. The sample + /// is the head, the box headers on the way, and the index itself. + #[test] + fn a_large_mp4_downloads_its_head_and_its_index() { + let ftyp = [ + iso_box_header(b"ftyp", 24), + b"isom\0\0\0\0mp41isom".to_vec(), + ] + .concat(); + let mdat_len = 6 * GIB; + let moov_at = ftyp.len() as u64 + mdat_len; + let moov = [iso_box_header(b"moov", 4096), vec![7u8; 4088]].concat(); + let size = moov_at + moov.len() as u64; + let revision = FakeRevision::new(size) + .with(0, ftyp.clone()) + .with(ftyp.len() as u64, iso_box_header(b"mdat", mdat_len)) + .with(moov_at, moov.clone()); + + let sample = block_on(sample_video(&revision)).unwrap(); + assert_eq!(sample.index, Some((moov_at, moov))); + assert!( + revision.read.load(Ordering::Relaxed) <= VIDEO_HEAD_BYTES + 4096 + 16, + "read {} bytes", + revision.read.load(Ordering::Relaxed) + ); + } + + #[test] + fn an_mp4_with_its_index_up_front_downloads_its_head_alone() { + let ftyp = [iso_box_header(b"ftyp", 16), b"isom\0\0\0\0".to_vec()].concat(); + let moov = [iso_box_header(b"moov", 64), vec![1u8; 56]].concat(); + let revision = FakeRevision::new(GIB) + .with(0, ftyp.clone()) + .with(ftyp.len() as u64, moov) + .with(80, iso_box_header(b"mdat", GIB - 80)); + let sample = block_on(sample_video(&revision)).unwrap(); + assert!(sample.index.is_none()); + assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES); + } + + #[test] + fn a_small_video_downloads_whole() { + let revision = FakeRevision::new(VIDEO_WHOLE_MAX_BYTES).with(0, vec![3u8; 64]); + let sample = block_on(sample_video(&revision)).unwrap(); + assert_eq!(sample.head.len() as u64, VIDEO_WHOLE_MAX_BYTES); + assert!(sample.index.is_none()); + } + + /// `.ts` that is TypeScript reaches ffmpeg never: the sync byte is missing, + /// and that is a final answer, not a retry. + #[test] + fn a_ts_without_the_sync_byte_is_no_video() { + let attempt = video_sample_thumbnail( + &whole(b"export const answer = 42;"), + "bundle.ts", + &std::env::temp_dir(), + OsStr::new("pdfs-ffmpeg-deliberately-absent"), + Duration::from_millis(10), + ); + assert!(matches!(attempt, ScaleAttempt::Undecodable)); + } + + /// ffmpeg reads the index where the file's own boxes put it, so the staging + /// file has the full length and the index at its offset. + #[test] + fn the_index_is_staged_at_its_own_offset() { + let tmp = scratch("video-index"); + let frame = tmp.join("frame.jpg"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(&frame, jpeg(640, 360)).unwrap(); + let size: u64 = 3 * GIB; + let at: u64 = size - 4096; + // Arguments: -nostdin -loglevel error -ss -i … + let ffmpeg = fake_ffmpeg( + &tmp, + &format!( + "[ \"$(stat -c %s \"$7\")\" = {size} ] && \ + [ \"$(tail -c +{} \"$7\" | head -c 4)\" = moov ] && cat '{}'", + // The box type, after its 4-byte length; `tail` counts from 1. + at + 5, + frame.display() + ), + ); + let sample = VideoSample { + size, + head: vec![0u8; 1024], + index: Some(( + at, + [iso_box_header(b"moov", 4096), vec![0u8; 4088]].concat(), + )), + }; + let attempt = video_sample_thumbnail( + &sample, + "clip.mp4", + &tmp, + ffmpeg.as_os_str(), + Duration::from_secs(10), + ); + std::fs::remove_dir_all(&tmp).unwrap(); + assert!(matches!(attempt, ScaleAttempt::Made(_))); + } + #[test] fn a_video_frame_becomes_a_thumbnail() { let tmp = scratch("video-frame"); @@ -2544,8 +2901,8 @@ mod thumb_tests { std::fs::write(&frame, jpeg(1080, 1920)).unwrap(); let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display())); - let ScaleAttempt::Made(thumb) = video_thumbnail( - b"video bytes", + let ScaleAttempt::Made(thumb) = video_sample_thumbnail( + &whole(b"video bytes"), "clip.mp4", &tmp, ffmpeg.as_os_str(), @@ -2561,8 +2918,8 @@ mod thumb_tests { fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() { let tmp = scratch("video-broken"); let ffmpeg = fake_ffmpeg(&tmp, "exit 1"); - let attempt = video_thumbnail( - b"not a video", + let attempt = video_sample_thumbnail( + &whole(b"not a video"), "clip.mp4", &tmp, ffmpeg.as_os_str(), @@ -2574,8 +2931,8 @@ mod thumb_tests { #[test] fn missing_ffmpeg_is_retryable() { - let attempt = video_thumbnail( - b"video bytes", + let attempt = video_sample_thumbnail( + &whole(b"video bytes"), "clip.mp4", &std::env::temp_dir(), OsStr::new("pdfs-ffmpeg-deliberately-absent"), @@ -2591,9 +2948,12 @@ mod thumb_tests { let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE"); let path = std::path::PathBuf::from(path); let name = path.file_name().unwrap().to_string_lossy(); - let bytes = std::fs::read(&path).unwrap(); - let ScaleAttempt::Made(thumb) = video_thumbnail( - &bytes, + // Through the same partial download a Drive video gets. + let revision = FakeRevision::new(std::fs::read(&path).unwrap().len() as u64) + .with(0, std::fs::read(&path).unwrap()); + let sample = block_on(sample_video(&revision)).unwrap(); + let ScaleAttempt::Made(thumb) = video_sample_thumbnail( + &sample, &name, &std::env::temp_dir(), OsStr::new("ffmpeg"), diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index 700a097..e0fb73d 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -3731,4 +3731,110 @@ mod tests { assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } + + /// A video in My files gets a thumbnail from a frame of its own, but a + /// recording can be gigabytes: only its start, and an MP4's index, are + /// downloaded for it. A video with a thumbnail from its uploader is not + /// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail. + #[test] + #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] + fn a_video_thumbnail_downloads_a_small_part_of_the_video() { + if std::process::Command::new("ffmpeg") + .arg("-version") + .output() + .is_err() + { + eprintln!("skipped: needs ffmpeg"); + return; + } + let dir = scratch("video-thumbs"); + std::fs::create_dir_all(&dir).unwrap(); + // Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole. + // An MP4 written this way has its index at the end. + let encode = |name: &str| { + let path = dir.join(name); + let status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"]) + .args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"]) + .args(["-c:v", "libx264", "-preset", "ultrafast"]) + .args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"]) + .arg(&path) + .status() + .unwrap(); + assert!(status.success()); + let bytes = std::fs::read(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len()); + bytes + }; + let (recording, clip) = (encode("recording.ts"), encode("clip.mp4")); + let source = b"export const answer = 42;\n".repeat(64 * 1024); + + let drive = FakeDrive::new(); + drive.device().write("recording.ts", &recording); + drive.device().write("clip.mp4", &clip); + drive.device().write("app.ts", &source); + let phone = drive.device().write("phone.mp4", &clip); + let mut frame = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut frame) + .encode_image(&image::RgbImage::new(320, 180)) + .unwrap(); + let client = drive.client(1, Faults::lan()); + client.set_thumbnail(&phone, frame); + let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap(); + let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir { + path: String::new(), + }) else { + panic!("listing"); + }; + let items: Vec = entries + .iter() + .filter(|entry| !entry.is_dir) + .map(|entry| control::FileThumbRequest { + uid: entry.uid.clone(), + modified: entry.modified, + name: entry.name.clone(), + }) + .collect(); + let Ok(Response::FileThumbGeneration { generation }) = + daemon.request(&Request::ReserveFileThumbGeneration) + else { + panic!("generation"); + }; + let read_before = client.content_read(); + + let mut paths = std::collections::HashMap::new(); + let settled = wait_until(Duration::from_secs(120), || { + let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs { + items: items.clone(), + generation, + }) else { + return false; + }; + paths = replies + .iter() + .filter_map(|reply| { + let name = &items.iter().find(|item| item.uid == reply.uid)?.name; + Some((name.clone(), (reply.path.clone(), reply.pending))) + }) + .collect(); + paths.values().all(|(_, pending)| !pending) && paths.len() == 4 + }); + assert!(settled, "{paths:?}"); + let read = client.content_read() - read_before; + + for name in ["recording.ts", "clip.mp4", "phone.mp4"] { + assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}"); + } + assert_eq!(paths["app.ts"].0, None, "TypeScript is no video"); + // Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing + // of the video that came with a thumbnail. + assert!( + read < 34 << 20, + "downloaded {} MiB for three thumbnails", + read >> 20 + ); + assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/pdfs-fuse/src/sim/fake_drive.rs b/crates/pdfs-fuse/src/sim/fake_drive.rs index cbfc18e..a9869e8 100644 --- a/crates/pdfs-fuse/src/sim/fake_drive.rs +++ b/crates/pdfs-fuse/src/sim/fake_drive.rs @@ -62,7 +62,7 @@ use proton_drive_rs::proton_sdk::ids::{ }; use proton_drive_rs::{ AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, - NodeKind, NodeMoveItem, RevisionState, Thumbnail, + NodeKind, NodeMoveItem, RevisionState, Thumbnail, ThumbnailType, }; use sha1::{Digest as _, Sha1}; @@ -396,6 +396,8 @@ impl FakeDrive { hold_answer: Mutex::new(None), hold_trash: Mutex::new(None), hold_download: Mutex::new(None), + thumbnails: Mutex::new(HashMap::new()), + content_read: AtomicU64::new(0), hold_any_landing: AtomicBool::new(false), hold_landing: Mutex::new(None), drop_after_create: AtomicBool::new(false), @@ -677,6 +679,10 @@ struct ClientInner { hold_trash: Mutex>>, /// The download [`FakeClient::hold_next_download`] is waiting for. hold_download: Mutex>>, + /// Thumbnails set by [`FakeClient::set_thumbnail`]. + thumbnails: Mutex>>, + /// File content bytes served, by downloads and ranged reads alike. + content_read: AtomicU64, /// The landing [`FakeClient::hold_next_landing`] is waiting for. hold_landing: Mutex>>, /// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node @@ -821,6 +827,16 @@ impl FakeClient { /// Hold the next download of `uid`'s content once Drive has answered it, /// before the bytes reach the daemon, until the answer is dropped. + /// Give `uid` a server thumbnail, as an upload that carried one would. + pub(crate) fn set_thumbnail(&self, uid: &NodeUid, bytes: Vec) { + self.0.thumbnails.lock().insert(uid.clone(), bytes); + } + + /// File content bytes served so far, by whole downloads and ranged reads. + pub(crate) fn content_read(&self) -> u64 { + self.0.content_read.load(Ordering::SeqCst) + } + pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held { let state = Arc::new(HoldState { uid: uid.clone(), @@ -1475,6 +1491,21 @@ impl DriveApi for FakeClient { })) } + async fn download_thumbnail( + &self, + uid: &NodeUid, + _thumbnail_type: ThumbnailType, + ) -> Result>> { + self.request(false, |server| { + server + .current(uid) + .map(|_| ()) + .ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found")) + }) + .await?; + Ok(self.0.thumbnails.lock().get(uid).cloned()) + } + async fn download_file_to_dyn( &self, uid: &NodeUid, @@ -1490,6 +1521,9 @@ impl DriveApi for FakeClient { .await?; self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid)) .await; + self.0 + .content_read + .fetch_add(content.len() as u64, Ordering::SeqCst); output.write_all(&content).map_err(io_error) } @@ -1733,13 +1767,19 @@ impl RevisionRead for FakeRevision { async fn read_at(&self, offset: u64, length: u64) -> Result> { let content = self.content.clone(); - self.client + let read = self + .client .request(false, move |_| { let start = (offset as usize).min(content.len()); let end = (offset.saturating_add(length) as usize).min(content.len()); Ok(content[start..end].to_vec()) }) - .await + .await?; + self.client + .0 + .content_read + .fetch_add(read.len() as u64, Ordering::SeqCst); + Ok(read) } } diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-album-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-album-symbolic.svg new file mode 100644 index 0000000..3eb3634 --- /dev/null +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-album-symbolic.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-photos-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-photos-symbolic.svg new file mode 100644 index 0000000..9ce78b7 --- /dev/null +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-photos-symbolic.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-slideshow-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-slideshow-symbolic.svg new file mode 100644 index 0000000..e820b11 --- /dev/null +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-slideshow-symbolic.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml index 80b0b1b..a61ad12 100644 --- a/crates/pdfs-gui/resources/pdfs.gresource.xml +++ b/crates/pdfs-gui/resources/pdfs.gresource.xml @@ -15,6 +15,9 @@ scalable/actions/pdfs-link-symbolic.svg scalable/actions/pdfs-map-symbolic.svg scalable/actions/pdfs-people-symbolic.svg + scalable/actions/pdfs-photos-symbolic.svg + scalable/actions/pdfs-album-symbolic.svg + scalable/actions/pdfs-slideshow-symbolic.svg scalable/actions/pdfs-share-symbolic.svg scalable/actions/pdfs-sync-symbolic.svg scalable/actions/pdfs-synced-symbolic.svg diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index a21039a..16ade45 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -53,7 +53,7 @@ button.sidebar-account { padding: 4px 8px; font-weight: normal; } /* The folder on screen, last in the breadcrumb trail: a flat button's side * padding, so the "›" before it is spaced like the ones between buttons. */ .crumb-current { padding: 0 10px; } -scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } +scale.browser-status-meter trough { min-width: 140px; min-height: 6px; } .bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); } .dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; } .dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); } @@ -80,7 +80,12 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } .viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); } /* Photos grid */ -.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; } +.gallery-day { font-size: 1.1rem; font-weight: 700; padding: 4px 2px 8px 2px; } +/* The check that picks a whole section: on hover, and always while selecting. */ +.gallery-day-check { opacity: 0; transition: opacity 160ms ease; min-width: 24px; min-height: 24px; margin-bottom: 4px; } +.gallery-heading:hover .gallery-day-check, .gallery-heading.selecting .gallery-day-check, .gallery-day-check:focus-visible { opacity: 1; } +/* The section at the top of the grid, floating once its heading scrolled off. */ +.gallery-float-heading { font-weight: 700; padding: 4px 12px; border-radius: 999px; background-color: color-mix(in srgb, var(--popover-bg-color) 92%, transparent); color: var(--popover-fg-color); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); } .photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; } .photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; } .photo-thumb { transition: transform 220ms ease; } @@ -94,6 +99,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } .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; } .photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; } .photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); } +.photo-favorite-badge { color: white; -gtk-icon-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); } .photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); } .memory-card { padding: 0; border-radius: 12px; } .memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); } @@ -103,7 +109,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } .album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; } .album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); } .album-card:hover .photo-thumb { transform: scale(1.05); } -.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; } +.view-switch button, .view-switch toggle { padding-left: 12px; padding-right: 12px; } /* Shared with Me: what a share lets me do, as a tinted pill beside the name. */ .role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index cfb98be..1678368 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -71,7 +71,7 @@ use gtk4::glib::BoxedAnyObject; use pdfs_core::auth; -use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState}; +use pdfs_core::config::{AppDirs, FileSort, FilesView, PhotosView, WindowState}; use pdfs_core::control::{ ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, @@ -488,6 +488,7 @@ fn build_window(app: &adw::Application) { empty_actions: browser_widgets.empty_actions.clone(), summary: browser_widgets.summary.clone(), zoom: browser_widgets.zoom.clone(), + zoom_save: RefCell::new(None), }, details: DetailsState { details: browser_widgets.details, @@ -587,10 +588,13 @@ fn build_window(app: &adw::Application) { selected: RefCell::new(HashSet::new()), select_anchor: RefCell::new(None), select_btn: gallery_widgets.select_btn.clone(), - select_bar: gallery_widgets.select_bar.clone(), - select_label: gallery_widgets.select_label.clone(), - select_trash: gallery_widgets.select_trash.clone(), - select_album: gallery_widgets.select_album.clone(), + selection: gallery_widgets.selection.clone(), + more: gallery_widgets.more.clone(), + upload_face: gallery_widgets.upload_face.clone(), + float_heading: gallery_widgets.float_heading.clone(), + float_label: gallery_widgets.float_label.clone(), + square: Cell::new(false), + zoom_save: RefCell::new(None), }, shared: SharedState::new(&shared_widgets), shared_by_me: SharedByMeState::new(&shared_by_me_widgets), @@ -693,7 +697,7 @@ fn build_window(app: &adw::Application) { &ui, &gallery_widgets.list, &gallery_widgets.scroll, - &gallery_widgets.select_done, + &gallery_widgets.square_action, ); wire_gallery_empty( &ui, @@ -795,6 +799,7 @@ fn build_window(app: &adw::Application) { /// split collapsed whatever the width. fn set_narrow(ui: &Ui, narrow: bool) { ui.narrow.set(narrow); + set_photos_narrow(ui, narrow); if ui.session.borrow().is_some() { ui.nav.set_collapsed(narrow); } @@ -858,11 +863,7 @@ fn window_hidden(window: &adw::ApplicationWindow) -> bool { /// sync, set off by a separator. const DESTINATIONS: [(&str, &str, &str); 7] = [ ("browser", gettext_noop("My files"), "folder-symbolic"), - ( - "gallery", - gettext_noop("Photos"), - "image-x-generic-symbolic", - ), + ("gallery", gettext_noop("Photos"), "pdfs-photos-symbolic"), ( "shared", gettext_noop("Shared with me"), @@ -1523,6 +1524,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) { ("Left Right", gettext_noop("Back / forward")), ("Up", gettext_noop("Parent folder")), ("1 2", gettext_noop("Grid / list")), + ("plus", gettext_noop("Larger thumbnails")), + ("minus", gettext_noop("Smaller thumbnails")), + ("0", gettext_noop("Reset thumbnail size")), ("F2", gettext_noop("Rename")), ("Delete", gettext_noop("Move to Trash")), ("Escape", gettext_noop("Clear the selection")), @@ -1588,6 +1592,21 @@ fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { ui.browser.search.grab_focus(); } gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), + gtk4::gdk::Key::plus | gtk4::gdk::Key::equal | gtk4::gdk::Key::KP_Add + if ctrl && on_browser && !ui.browser.files.shows_list() => + { + zoom_files(&ui, 1) + } + gtk4::gdk::Key::minus | gtk4::gdk::Key::KP_Subtract + if ctrl && on_browser && !ui.browser.files.shows_list() => + { + zoom_files(&ui, -1) + } + gtk4::gdk::Key::_0 | gtk4::gdk::Key::KP_0 + if ctrl && on_browser && !ui.browser.files.shows_list() => + { + zoom_files(&ui, 0) + } gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view( &ui, FilesView { diff --git a/crates/pdfs-gui/src/app/pages/albums.rs b/crates/pdfs-gui/src/app/pages/albums.rs index 29e64ec..6af6ffd 100644 --- a/crates/pdfs-gui/src/app/pages/albums.rs +++ b/crates/pdfs-gui/src/app/pages/albums.rs @@ -40,7 +40,7 @@ pub(crate) fn load_albums(ui: &Rc) { if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") { albums_status( ui, - "view-grid-symbolic", + "pdfs-album-symbolic", &gettext("Loading albums…"), &gettext("Reading your Proton Drive albums."), ); @@ -62,7 +62,7 @@ pub(crate) fn load_albums(ui: &Rc) { ), Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status( &ui, - "view-grid-symbolic", + "pdfs-album-symbolic", &gettext("No albums"), &gettext("Create one with New Album, or in Proton Photos."), ), @@ -136,7 +136,7 @@ fn album_card(ui: &Rc, album: &AlbumInfo) -> gtk4::Button { picture.add_css_class("photo-thumb"); let placeholder = gtk4::Image::builder() - .icon_name("view-grid-symbolic") + .icon_name("pdfs-album-symbolic") .pixel_size(32) .halign(gtk4::Align::Center) .valign(gtk4::Align::Center) @@ -618,9 +618,7 @@ pub(crate) fn open_album(ui: &Rc, album: AlbumInfo) { // would silently do something else. The Photos/Albums switcher goes too: an // album is a level below it, and back is the way out. ui.gallery.filters.set_visible(false); - ui.gallery.view_switch.widget().set_visible(false); - ui.gallery.upload.set_visible(false); - ui.gallery.back.set_visible(true); + sync_header(ui); // A stale kind/date filter would otherwise be sent with the album request. ui.gallery.kind.set(None); ui.gallery.range.set(None); @@ -636,9 +634,7 @@ pub(crate) fn close_album(ui: &Rc) { return; } ui.gallery.title.set_title(&gettext("Photos")); - ui.gallery.view_switch.widget().set_visible(true); - ui.gallery.upload.set_visible(true); - ui.gallery.back.set_visible(false); + sync_header(ui); } /// Show the album grid's own status page (loading, empty, or an error). diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index b24130e..a3d46a0 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -77,6 +77,8 @@ pub(crate) struct BrowserState { /// zoom value, and Proton account storage usage. pub(crate) summary: gtk4::Label, pub(crate) zoom: gtk4::Scale, + /// Saves the zoom once the slider rests, not on every step. + pub(crate) zoom_save: RefCell>, } /// Idle pause after the last keystroke before a search query is sent, so typing @@ -87,9 +89,11 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); pub(crate) const SEARCH_LIMIT: usize = 200; /// Browser grid thumbnail size controlled by the bottom zoom slider. The -/// default preserves the former fixed 72 px presentation. +/// default preserves the former fixed 72 px presentation. The largest size is +/// big enough to tell photos apart by their thumbnail, and thumbnails are +/// 512 px, so they stay sharp up to it on a 2× screen. pub(crate) const GRID_THUMB_MIN: i32 = 48; -pub(crate) const GRID_THUMB_MAX: i32 = 144; +pub(crate) const GRID_THUMB_MAX: i32 = 256; pub(crate) const GRID_THUMB_DEFAULT: i32 = 72; pub(crate) const GRID_THUMB_STEP: i32 = 8; @@ -801,7 +805,35 @@ pub(crate) fn wire_browser(ui: &Rc) { &[], ))); ui_zoom.browser.files.set_thumbnail_size(size); + save_icon_size(&ui_zoom, size); }); + // Ctrl+scroll steps the icon size, as in Photos. Capture phase so the + // scrolled window does not scroll the grid as well. A touchpad sends many + // small deltas, so they add up to a whole notch before each step. + if let Some(grid_scroll) = files.views.child_by_name("grid") { + let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); + wheel.set_propagation_phase(gtk4::PropagationPhase::Capture); + let ui_wheel = ui.clone(); + let pending = Cell::new(0.0_f64); + wheel.connect_scroll(move |controller, _dx, dy| { + if !controller + .current_event_state() + .contains(gtk4::gdk::ModifierType::CONTROL_MASK) + { + pending.set(0.0); + return glib::Propagation::Proceed; + } + let total = pending.get() + dy; + let steps = total.trunc(); + pending.set(total - steps); + if steps != 0.0 { + // Scrolling up (negative dy) makes the icons bigger. + zoom_files(&ui_wheel, -(steps as i32)); + } + glib::Propagation::Stop + }); + grid_scroll.add_controller(wheel); + } files .column_view @@ -1306,6 +1338,11 @@ pub(crate) fn wire_browser_actions(ui: &Rc, build_thumbnails: >k4::Button) let view = ui.dirs.load_config().files_view; ui.browser.view.set(view); + if let Some(size) = view.icon_size { + ui.browser + .zoom + .set_value(f64::from(size.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX))); + } let layout = gio::SimpleAction::new_stateful( "view", Some(glib::VariantTy::STRING), @@ -1407,6 +1444,41 @@ pub(crate) fn wire_browser_actions(ui: &Rc, build_thumbnails: >k4::Button) ui.browser.content.add_controller(drop); } +/// Remember the grid's icon size, once the slider has rested for a moment. +fn save_icon_size(ui: &Rc, size: i32) { + let mut view = ui.browser.view.get(); + if view.icon_size == Some(size) { + return; + } + view.icon_size = Some(size); + ui.browser.view.set(view); + if let Some(source) = ui.browser.zoom_save.borrow_mut().take() { + source.remove(); + } + let ui_save = ui.clone(); + let source = glib::timeout_add_local_once(Duration::from_millis(500), move || { + ui_save.browser.zoom_save.borrow_mut().take(); + let mut config = ui_save.dirs.load_config(); + config.files_view.icon_size = ui_save.browser.view.get().icon_size; + if let Err(e) = ui_save.dirs.save_config(&config) { + tracing::warn!("cannot save the icon size: {e}"); + } + }); + *ui.browser.zoom_save.borrow_mut() = Some(source); +} + +/// Step the grid's icon size by `steps` slider notches; `0` resets it. +pub(crate) fn zoom_files(ui: &Rc, steps: i32) { + let zoom = &ui.browser.zoom; + let size = if steps == 0 { + GRID_THUMB_DEFAULT + } else { + (zoom.value().round() as i32 + steps * GRID_THUMB_STEP) + .clamp(GRID_THUMB_MIN, GRID_THUMB_MAX) + }; + zoom.set_value(f64::from(size)); +} + fn layout_name(view: FilesView) -> &'static str { if view.list { "list" } else { "grid" } } @@ -1728,8 +1800,9 @@ fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { let text = if status.scanning { ui.browser.thumbnail_progress.pulse(); let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]); - let images = ngettext_f("{n} image", "{n} images", status.images_found, &[]); - // Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 images". + // Translators: a count of images and videos that can have a thumbnail. + let images = ngettext_f("{n} file", "{n} files", status.images_found, &[]); + // Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 files". gettext_f( "Scanning {root}… {folders}, {images}", &[("root", root), ("folders", &folders), ("images", &images)], @@ -1743,7 +1816,7 @@ fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { ui.browser .thumbnail_progress .set_fraction(fraction.clamp(0.0, 1.0)); - // Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are image counts. + // Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are counts of images and videos. gettext_f( "Building thumbnails in {root}… {done} of {total}", &[ @@ -1756,17 +1829,17 @@ fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { ui.browser.thumbnail_progress.set_fraction(1.0); let available = status.completed.saturating_sub(status.failed); match status.failed { - // Translators: {root} is a folder path or "Proton Drive". + // Translators: {root} is a folder path or "Proton Drive"; {n} counts images and videos. 0 => ngettext_f( - "Thumbnails ready for {n} image in {root}", - "Thumbnails ready for {n} images in {root}", + "Thumbnails ready for {n} file in {root}", + "Thumbnails ready for {n} files in {root}", available, &[("root", root)], ), - // Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images could not get a thumbnail. + // Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images and videos could not get a thumbnail. failed => ngettext_f( - "Thumbnails ready for {n} image in {root}; {failed} unavailable", - "Thumbnails ready for {n} images in {root}; {failed} unavailable", + "Thumbnails ready for {n} file in {root}; {failed} unavailable", + "Thumbnails ready for {n} files in {root}; {failed} unavailable", available, &[("root", root), ("failed", &failed.to_string())], ), @@ -2765,6 +2838,11 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str { if entry.is_dir { return "folder"; } + // Every video type, including a `.ts` recording, which by its name alone + // is TypeScript. + if pdfs_core::control::is_video_file_name(&entry.name, Some(entry.size)) { + return "video-x-generic"; + } let ext = entry .name .rsplit('.') @@ -2775,7 +2853,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str { "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { "image-x-generic" } - "mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic", "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", "pdf" | "doc" | "docx" | "odt" => "x-office-document", "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", @@ -3122,7 +3199,7 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { #[cfg(test)] mod tests { use super::{ - MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary, + MoveTarget, badge_for, copy_target_allowed, hit_location, icon_base_for, listing_summary, move_target_allowed, show_thumbnail_build_progress, sort_entries, }; use pdfs_core::config::{FileSort, FilesView}; @@ -3149,6 +3226,32 @@ mod tests { } } + #[test] + fn every_video_type_gets_the_video_icon() { + const MIB: u64 = 1024 * 1024; + for name in [ + "clip.mp4", + "clip.MKV", + "broadcast.m2ts", + "camera.mts", + "old.flv", + ] { + assert_eq!( + icon_base_for(&entry(name, false, 10, 0)), + "video-x-generic", + "{name}" + ); + } + assert_eq!( + icon_base_for(&entry("recording.ts", false, 900 * MIB, 0)), + "video-x-generic" + ); + assert_eq!( + icon_base_for(&entry("app.ts", false, 4096, 0)), + "text-x-generic" + ); + } + fn names(entries: &[DirEntry]) -> Vec<&str> { entries.iter().map(|e| e.name.as_str()).collect() } diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 05460a6..7866633 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -40,7 +40,9 @@ pub(crate) struct Viewer { /// Details panel: the toggle that reveals it, the rows it fills in, and the /// "Show on map" button (hidden when the photo carries no GPS tags). pub(crate) info_toggle: gtk4::ToggleButton, - pub(crate) info_revealer: gtk4::Revealer, + /// Lays the details panel beside the photo, or over it in a narrow + /// window. + pub(crate) info_split: adw::OverlaySplitView, pub(crate) info_rows: gtk4::Box, pub(crate) info_map: gtk4::Button, /// Coordinates behind `info_map`, once a photo with GPS tags is shown. @@ -358,9 +360,7 @@ fn stop_slideshow(viewer: &Rc) { if viewer.slide_fullscreened.replace(false) { viewer.window.unfullscreen(); } - viewer - .slideshow - .set_icon_name("media-playback-start-symbolic"); + viewer.slideshow.set_icon_name("pdfs-slideshow-symbolic"); if viewer.slideshow.is_active() { viewer.slideshow.set_active(false); } @@ -1004,13 +1004,16 @@ fn show_photo_position(ui: &Rc, viewer: &Rc, index: u32, capture_tim pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let parent = ui.stack.root().and_downcast::().unwrap(); - let window = gtk4::Window::builder() + // An `adw::Window` for its breakpoints: the details panel lies over the + // photo once the window is too narrow to show both side by side. + let adw_window = adw::Window::builder() .title(gettext("Photo")) .modal(true) .transient_for(&parent) .default_width(1100) .default_height(760) .build(); + let window: gtk4::Window = adw_window.clone().upcast(); window.add_css_class("photo-viewer-window"); let overlay = gtk4::Overlay::new(); @@ -1120,7 +1123,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { favorite_btn.add_css_class("viewer-action-btn"); let slideshow_btn = gtk4::ToggleButton::builder() - .icon_name("media-playback-start-symbolic") + .icon_name("pdfs-slideshow-symbolic") .tooltip_text(gettext("Slideshow (F5)")) .valign(gtk4::Align::Center) .build(); @@ -1205,17 +1208,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0); info_panel.add_css_class("viewer-info-panel"); - info_panel.set_width_request(320); info_panel.append(&info_scroll); - // Set explicitly: the title's hexpand would otherwise propagate up and have - // the revealer take half the window even while it is closed. - let info_revealer = gtk4::Revealer::builder() - .transition_type(gtk4::RevealerTransitionType::SlideLeft) - .hexpand(false) - .child(&info_panel) - .build(); - let spinner = spinner(); spinner.set_halign(gtk4::Align::Center); spinner.set_valign(gtk4::Align::Center); @@ -1232,10 +1226,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { status_label.add_css_class("viewer-status"); overlay.add_overlay(&status_label); - let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - body.append(&overlay); - body.append(&info_revealer); - window.set_child(Some(&body)); + // The panel docks beside the photo, or lies over it in a narrow window, + // where a click on the photo closes it again. + let info_split = adw::OverlaySplitView::builder() + .content(&overlay) + .sidebar(&info_panel) + .sidebar_position(gtk4::PackType::End) + .min_sidebar_width(300.0) + .max_sidebar_width(360.0) + .show_sidebar(false) + .build(); + let narrow = adw::Breakpoint::new( + adw::BreakpointCondition::parse("max-width: 800sp").expect("static breakpoint"), + ); + narrow.add_setter(&info_split, "collapsed", Some(&true.to_value())); + adw_window.add_breakpoint(narrow); + let body = info_split.clone(); + adw_window.set_content(Some(&body)); let viewer = Rc::new(Viewer { window: window.clone(), @@ -1250,7 +1257,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { prev: prev_btn.clone(), next: next_btn.clone(), info_toggle: info_toggle.clone(), - info_revealer: info_revealer.clone(), + info_split: info_split.clone(), info_rows, info_map: info_map.clone(), favorite: favorite_btn.clone(), @@ -1435,9 +1442,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let Some(viewer_info) = viewer_info.upgrade() else { return; }; - viewer_info - .info_revealer - .set_reveal_child(toggle.is_active()); + viewer_info.info_split.set_show_sidebar(toggle.is_active()); + }); + // A collapsed panel also closes on its own, by a click on the photo. + let toggle_info = info_toggle.clone(); + info_split.connect_show_sidebar_notify(move |split| { + if toggle_info.is_active() != split.shows_sidebar() { + toggle_info.set_active(split.shows_sidebar()); + } }); let ui_slides = ui.clone(); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 979c379..09c74b5 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -204,12 +204,37 @@ pub(crate) struct GalleryState { /// The photo last picked or unpicked by a click, which a Shift+click /// selects a range from. pub(crate) select_anchor: RefCell>, - /// The Select toggle, the bar it reveals, and the bar's own widgets. - pub(crate) select_btn: gtk4::ToggleButton, - pub(crate) select_bar: gtk4::Revealer, - pub(crate) select_label: gtk4::Label, - pub(crate) select_trash: gtk4::Button, - pub(crate) select_album: gtk4::Button, + /// The Select button, and what selection mode shows instead of the + /// page's own header and actions. + pub(crate) select_btn: gtk4::Button, + pub(crate) selection: SelectionBar, + /// The header's page menu and the Upload button's face, which drops its + /// label in a narrow window. + pub(crate) more: gtk4::MenuButton, + pub(crate) upload_face: adw::ButtonContent, + /// The section the top of the timeline is in, floating over the grid + /// once its own heading has scrolled away. + pub(crate) float_heading: gtk4::Revealer, + pub(crate) float_label: gtk4::Label, + /// Crop every photo to a square tile (the page menu's "Square Tiles"). + pub(crate) square: Cell, + /// The pending save of the zoom level, debounced: a Ctrl+scroll is a + /// flood of steps and only where it stops is worth writing. + pub(crate) zoom_save: RefCell>, +} + +/// Selection mode's controls: the header's "N selected" title and Cancel, and +/// the action bar along the bottom of the page. +#[derive(Clone)] +pub(crate) struct SelectionBar { + pub(crate) bar: gtk4::ActionBar, + pub(crate) title: adw::WindowTitle, + pub(crate) cancel: gtk4::Button, + pub(crate) favorite: gtk4::Button, + pub(crate) album: gtk4::Button, + pub(crate) remove: gtk4::Button, + pub(crate) save: gtk4::Button, + pub(crate) trash: gtk4::Button, } impl GalleryState { @@ -332,10 +357,14 @@ pub(crate) enum GalleryRow { /// outside the list as the user scrolled resized the viewport under them, /// and the list shook between the two heights near the top. Memories, - /// A day heading: "Today", "3 June 2026". - Heading(String), + /// A section heading: "Today", "3 June 2026", with the check that picks + /// every photo under it. + Heading(SectionHeading), /// One row of a day's grid, already justified to the current width and zoom. Tiles { + /// The heading of the section the row belongs to, for the heading that + /// floats over the grid. + section: String, tiles: Vec, /// True for the last row of its day, which carries the section's bottom /// margin so days stay visually separated. @@ -343,7 +372,28 @@ pub(crate) enum GalleryRow { }, } +/// A section heading row. +#[derive(Clone, PartialEq)] +pub(crate) struct SectionHeading { + pub(crate) label: String, + /// The photos of the section, which its check selects. + pub(crate) uids: Vec, + /// Whether the grid is in selection mode, and whether every photo of the + /// section is picked. + pub(crate) selecting: bool, + pub(crate) selected: bool, +} + impl GalleryRow { + /// The section the row belongs to. + fn section(&self) -> Option<&str> { + match self { + GalleryRow::Memories => None, + GalleryRow::Heading(heading) => Some(&heading.label), + GalleryRow::Tiles { section, .. } => Some(section), + } + } + /// The uid of the row's first photo, or `None` for a heading — the anchor a /// relayout scrolls back to. fn anchor(&self) -> Option { @@ -361,10 +411,19 @@ impl GalleryRow { (GalleryRow::Memories, GalleryRow::Memories) => true, (GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b, ( - GalleryRow::Tiles { tiles: a, last: al }, - GalleryRow::Tiles { tiles: b, last: bl }, + GalleryRow::Tiles { + tiles: a, + last: al, + section: sa, + }, + GalleryRow::Tiles { + tiles: b, + last: bl, + section: sb, + }, ) => { al == bl + && sa == sb && a.len() == b.len() && a.iter().zip(b).all(|(x, y)| { x.photo.uid == y.photo.uid @@ -373,6 +432,7 @@ impl GalleryRow { && x.selecting == y.selecting && x.selected == y.selected && x.grouping == y.grouping + && x.photo.favorite == y.photo.favorite }) } _ => false, @@ -403,13 +463,15 @@ pub(crate) struct GalleryWidgets { pub(crate) empty_actions: gtk4::Box, pub(crate) empty_upload: gtk4::Button, pub(crate) empty_import: gtk4::Button, - /// The Select toggle and the bar it reveals. - pub(crate) select_btn: gtk4::ToggleButton, - pub(crate) select_bar: gtk4::Revealer, - pub(crate) select_label: gtk4::Label, - pub(crate) select_trash: gtk4::Button, - pub(crate) select_album: gtk4::Button, - pub(crate) select_done: gtk4::Button, + /// The Select button and what selection mode shows. + pub(crate) select_btn: gtk4::Button, + pub(crate) selection: SelectionBar, + pub(crate) more: gtk4::MenuButton, + pub(crate) upload_face: adw::ButtonContent, + pub(crate) float_heading: gtk4::Revealer, + pub(crate) float_label: gtk4::Label, + /// The page menu's "Square Tiles" check. + pub(crate) square_action: gio::SimpleAction, /// The Albums grid, its own status page and the stack between them, plus the /// Photos/Albums switcher and the back button out of an album. pub(crate) albums: gtk4::FlowBox, @@ -494,7 +556,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { status_child.append(&empty_actions); let status = adw::StatusPage::builder() - .icon_name("image-x-generic-symbolic") + .icon_name("pdfs-photos-symbolic") .vexpand(true) .child(&status_child) .build(); @@ -537,15 +599,14 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { Some(&gettext("Import from Google Photos…")), Some("win.show-import"), ); + let upload_face = adw::ButtonContent::builder() + .label(pgettext("verb", "Upload")) + .icon_name("pdfs-upload-symbolic") + .build(); let upload = adw::SplitButton::builder() - .child( - &adw::ButtonContent::builder() - .label(pgettext("verb", "Upload")) - .icon_name("list-add-symbolic") - .build(), - ) + .child(&upload_face) .menu_model(&upload_menu) - .tooltip_text(gettext("Upload photos")) + .tooltip_text(gettext("Upload photos and videos")) .dropdown_tooltip(gettext("More ways to add photos")) .valign(gtk4::Align::Center) .build(); @@ -554,10 +615,20 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // Hunting for duplicates is a rarer, heavier job than adding one photo, so // it waits in the page menu rather than taking a header button. let page_menu = gio::Menu::new(); - page_menu.append( + // Each photo at its own shape is the default; a grid of squares is the + // other way to see a library, and some prefer its even sheet. + let view_section = gio::Menu::new(); + view_section.append(Some(&gettext("Square Tiles")), Some("photos.square-tiles")); + page_menu.append_section(None, &view_section); + let tools_section = gio::Menu::new(); + tools_section.append( Some(&gettext("Find Duplicates…")), Some("win.find-duplicates"), ); + page_menu.append_section(None, &tools_section); + let square_action = gio::SimpleAction::new_stateful("square-tiles", None, &false.to_variant()); + let page_actions = gio::SimpleActionGroup::new(); + page_actions.add_action(&square_action); let more = gtk4::MenuButton::builder() .icon_name("view-more-symbolic") .tooltip_text(gettext("More")) @@ -566,51 +637,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .build(); more.add_css_class("flat"); - // Picking photos is a mode, so its control is a toggle rather than a - // button. It has a label: no icon says "select" in every theme. - let select_btn = gtk4::ToggleButton::builder() - .label(pgettext("verb", "Select")) + // Picking photos is a mode: the button enters it, and the header then + // trades the page's own controls for Cancel and a count, as GNOME apps do. + let select_btn = gtk4::Button::builder() + .icon_name("selection-mode-symbolic") .tooltip_text(gettext("Select photos")) .valign(gtk4::Align::Center) .build(); select_btn.add_css_class("flat"); - - // What the selection can do, revealed with the mode. A revealer rather than - // a hidden box so the grid slides down instead of jumping. - let select_label = gtk4::Label::builder() - .label(gettext("Select photos")) - .hexpand(true) - .xalign(0.0) - .build(); - let select_trash = gtk4::Button::builder() - .label(gettext("Move to Trash")) - .valign(gtk4::Align::Center) - .sensitive(false) - .build(); - select_trash.add_css_class("destructive-action"); - let select_album = gtk4::Button::builder() - .label(gettext("Add to Album…")) - .valign(gtk4::Align::Center) - .sensitive(false) - .build(); - let select_done = gtk4::Button::builder() - .icon_name("window-close-symbolic") - .tooltip_text(gettext("Leave selection (Esc)")) - .valign(gtk4::Align::Center) - .build(); - select_done.add_css_class("flat"); - select_done.add_css_class("circular"); - let select_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - select_box.add_css_class("toolbar"); - select_box.add_css_class("bulk-bar"); - select_box.append(&select_label); - select_box.append(&select_album); - select_box.append(&select_trash); - select_box.append(&select_done); - let select_bar = gtk4::Revealer::builder() - .transition_type(gtk4::RevealerTransitionType::SlideDown) - .child(&scroll_sideways(&select_box)) - .build(); + let selection = build_selection_bar(); // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented // control: exactly one is active, and flipping it reloads the timeline @@ -669,30 +704,33 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // The kind toggles and the Filters button travel together: they filter the // timeline, and neither applies to the album grid or to an open album. + // One row: the kind tabs scroll sideways in a narrow window, and the + // Filters button stays at the end where it can always be reached. let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); filters.set_hexpand(true); - filters.append(&tabs.widget()); - let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - spacer.set_hexpand(true); - filters.append(&spacer); + let tabs_scroll = scroll_sideways(&tabs.widget()); + tabs_scroll.set_hexpand(true); + tabs_scroll.set_propagate_natural_width(false); + filters.append(&tabs_scroll); filters.append(&filter_btn); - // The page's two views, as one segmented control under the title: the - // timeline, and the albums. This is navigation, not a filter — which is why - // it sits above the filter row rather than beside the kind toggles. - // "Timeline", not "Photos": the kind filter below has a Photos tab too. + // The page's views, as the header's title: navigation belongs in the + // header, and the grid gets the rows it used to take. "Timeline", not + // "Photos": the kind filter has a Photos tab too. let view_switch = ToggleGroup::new(&[ (&gettext("Timeline"), None), (&gettext("Albums"), None), (&gettext("Places"), None), ]); + view_switch.set_icons(&[ + "pdfs-photos-symbolic", + "pdfs-album-symbolic", + "mark-location-symbolic", + ]); view_switch.set_round(); let view_switch_widget = view_switch.widget(); view_switch_widget.add_css_class("view-switch"); - view_switch_widget.set_halign(gtk4::Align::Start); - - let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - filter_bar.append(&filters); + view_switch_widget.set_valign(gtk4::Align::Center); // "On this day": a card per earlier year that has photos from today's // date. Shown as the first row of the unfiltered timeline (see @@ -717,12 +755,25 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { // The timeline (plus its pager) or the status page, never both. let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - timeline.append(&select_bar); // The scrubber lies over the grid's right edge and only shows when wanted, // so the grid keeps its full width. let timeline_view = gtk4::Overlay::new(); timeline_view.set_child(Some(&scroll)); let scrubber = Scrubber::new(&timeline_view); + // The section at the top of the grid, once its heading has scrolled off: + // a long day or month otherwise leaves the user guessing where they are. + let float_label = gtk4::Label::new(None); + float_label.add_css_class("gallery-float-heading"); + let float_heading = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::Crossfade) + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Start) + .margin_top(8) + .margin_start(8) + .can_target(false) + .child(&float_label) + .build(); + timeline_view.add_overlay(&float_heading); timeline.append(&timeline_view); timeline.append(&pager); @@ -742,7 +793,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { .child(&albums) .build(); let albums_status = adw::StatusPage::builder() - .icon_name("view-grid-symbolic") + .icon_name("pdfs-album-symbolic") .vexpand(true) .build(); albums_status.add_css_class("compact"); @@ -835,18 +886,29 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { inner.set_margin_bottom(12); inner.set_margin_start(12); inner.set_margin_end(12); - inner.append(&view_switch_widget); - inner.append(&filter_bar); + inner.append(&filters); inner.append(&content); let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0); body.append(&import_banner); body.append(&inner); - let (frame, header, title) = page_frame(&gettext("Photos"), &body); + // The title slot holds the view switch on the page itself, the album's or + // place's name inside one, and the count in selection mode (see + // [`sync_header`]). + let title = adw::WindowTitle::new(&gettext("Photos"), ""); + title.set_visible(false); + let title_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + title_box.append(&view_switch_widget); + title_box.append(&title); + title_box.append(&selection.title); + let (frame, header) = page_frame_with(&title_box, &body); + frame.add_bottom_bar(&selection.bar); + frame.insert_action_group("photos", Some(&page_actions)); header.pack_start(&back); header.pack_start(&upload); header.pack_end(&more); header.pack_end(&select_btn); + header.pack_end(&selection.cancel); ( frame.upcast(), @@ -867,11 +929,12 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { empty_upload, empty_import, select_btn, - select_bar, - select_label, - select_trash, - select_album, - select_done, + selection, + more, + upload_face, + float_heading, + float_label, + square_action, tabs, favorites_row, not_in_album_row, @@ -899,6 +962,95 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { ) } +/// Selection mode's controls, hidden until it starts: the count and Cancel +/// for the header, and the actions along the bottom of the page. +fn build_selection_bar() -> SelectionBar { + let title = adw::WindowTitle::new(&gettext("Select photos"), ""); + title.set_visible(false); + let cancel = gtk4::Button::builder() + .label(gettext("Cancel")) + .tooltip_text(gettext("Leave selection (Esc)")) + .valign(gtk4::Align::Center) + .visible(false) + .build(); + let action = |icon: &str, tooltip: &str| { + let button = gtk4::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + button.add_css_class("flat"); + button + }; + let favorite = action("starred-symbolic", &gettext("Add to Favorites")); + let album = action("pdfs-album-symbolic", &gettext("Add to Album…")); + let remove = action("list-remove-symbolic", &gettext("Remove from Album")); + remove.set_visible(false); + let save = action("folder-download-symbolic", &gettext("Save Copies…")); + let trash = action("user-trash-symbolic", &gettext("Move to Trash…")); + trash.remove_css_class("flat"); + trash.add_css_class("destructive-action"); + let bar = gtk4::ActionBar::new(); + bar.set_revealed(false); + bar.pack_start(&favorite); + bar.pack_start(&album); + bar.pack_start(&remove); + bar.pack_start(&save); + bar.pack_end(&trash); + SelectionBar { + bar, + title, + cancel, + favorite, + album, + remove, + save, + trash, + } +} + +/// Show what the page's state calls for in its header: the view switch on +/// the page itself, the name and Back inside an album or a place, and the +/// count and Cancel in selection mode. The one place that decides it, so +/// entering an album while selecting cannot leave the two half applied. +pub(crate) fn sync_header(ui: &Rc) { + let gallery = &ui.gallery; + let selecting = gallery.selecting.get(); + let collection = gallery.in_collection(); + let timeline = collection || gallery.view_switch.active() == VIEW_TIMELINE; + gallery + .view_switch + .widget() + .set_visible(!selecting && !collection); + gallery.title.set_visible(!selecting && collection); + gallery.back.set_visible(!selecting && collection); + gallery.upload.set_visible(!selecting && !collection); + gallery.more.set_visible(!selecting); + gallery.select_btn.set_visible(!selecting && timeline); + gallery.selection.title.set_visible(selecting); + gallery.selection.cancel.set_visible(selecting); + gallery.selection.bar.set_revealed(selecting); + let owned_album = gallery + .album + .borrow() + .as_ref() + .is_some_and(|album| !album.shared); + gallery.selection.remove.set_visible(owned_album); +} + +/// Follow the window's narrow breakpoint: the view switch shows its icons +/// alone, and Upload its icon, so the header keeps room for the window +/// buttons. +pub(crate) fn set_photos_narrow(ui: &Ui, narrow: bool) { + ui.gallery.view_switch.set_compact(narrow); + ui.gallery.upload_face.set_label(&if narrow { + String::new() + } else { + pgettext("verb", "Upload") + }); +} + /// The [`PhotoKind`] filter a gallery tab index selects: index 0 is All (no /// filter), then Photos / Videos / Raw. Index-aligned with the toggle array. pub(crate) fn kind_for_tab(index: usize) -> Option { @@ -1070,7 +1222,7 @@ pub(crate) fn wire_gallery( ui: &Rc, list: >k4::ListView, scroll: >k4::ScrolledWindow, - select_done: >k4::Button, + square_action: &gio::SimpleAction, ) { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { @@ -1110,15 +1262,9 @@ pub(crate) fn wire_gallery( GalleryRow::Heading(heading) => { row_box.set_margin_top(8); row_box.set_margin_bottom(0); - let label = gtk4::Label::builder() - .label(heading) - .halign(gtk4::Align::Start) - .build(); - label.add_css_class("heading"); - label.add_css_class("gallery-day"); - row_box.append(&label); + row_box.append(§ion_heading_widget(&ui_bind, heading)); } - GalleryRow::Tiles { tiles, last } => { + GalleryRow::Tiles { tiles, last, .. } => { row_box.set_margin_top(0); // The last row of a day carries the gap to the next heading. row_box.set_margin_bottom(if *last { 4 } else { TILE_GAP }); @@ -1217,6 +1363,7 @@ pub(crate) fn wire_gallery( load_gallery(&ui_scroll, true); } sync_scrubber_position(&ui_scroll); + sync_float_heading(&ui_scroll); ui_scroll.gallery.scrubber.flash(); }); wire_scrubber(ui); @@ -1274,22 +1421,110 @@ pub(crate) fn wire_gallery( list.add_controller(zoom_keys); let ui_select = ui.clone(); - ui.gallery.select_btn.clone().connect_toggled(move |btn| { - set_selection_mode(&ui_select, btn.is_active()); - }); - let ui_trash = ui.clone(); + ui.gallery + .select_btn + .connect_clicked(move |_| set_selection_mode(&ui_select, true)); + let ui_view = ui.clone(); + ui.gallery + .view_switch + .connect_changed(move |_| sync_header(&ui_view)); + sync_header(ui); + let selection = ui.gallery.selection.clone(); + let ui_cancel = ui.clone(); + selection + .cancel + .connect_clicked(move |_| set_selection_mode(&ui_cancel, false)); + let ui_favorite = ui.clone(); + selection + .favorite + .connect_clicked(move |_| favorite_selected(&ui_favorite)); let ui_album = ui.clone(); - ui.gallery.select_album.clone().connect_clicked(move |_| { + selection.album.connect_clicked(move |_| { let uids: Vec = ui_album.gallery.selected.borrow().iter().cloned().collect(); prompt_add_to_album(&ui_album, uids); }); - ui.gallery.select_trash.clone().connect_clicked(move |_| { - delete_selected(&ui_trash); + let ui_remove = ui.clone(); + selection.remove.connect_clicked(move |_| { + let album = ui_remove.gallery.album.borrow().clone(); + if let Some(album) = album { + let uids: Vec = ui_remove + .gallery + .selected + .borrow() + .iter() + .cloned() + .collect(); + remove_from_album(&ui_remove, &album, uids); + } }); - let ui_done = ui.clone(); - select_done - .clone() - .connect_clicked(move |_| set_selection_mode(&ui_done, false)); + let ui_save = ui.clone(); + selection + .save + .connect_clicked(move |_| save_selected(&ui_save)); + let ui_trash = ui.clone(); + selection + .trash + .connect_clicked(move |_| delete_selected(&ui_trash)); + + // The grid's shape, as the page menu's check and as saved for next time. + ui.gallery.row_height.set( + ui.dirs + .load_config() + .photos_view + .row_height + .unwrap_or(ROW_DEFAULT) + .clamp(ROW_MIN, ROW_MAX), + ); + let square = ui.dirs.load_config().photos_view.square; + ui.gallery.square.set(square); + square_action.set_state(&square.to_variant()); + let ui_square = ui.clone(); + square_action.connect_activate(move |action, _| { + let square = !ui_square.gallery.square.get(); + action.set_state(&square.to_variant()); + ui_square.gallery.square.set(square); + save_photos_view(&ui_square); + relayout_gallery(&ui_square); + }); + + // Photos and videos dropped on the timeline upload into it, the empty + // timeline's status page included. + let content = ui.gallery.content.clone(); + let drop = gtk4::DropTarget::new( + gtk4::gdk::FileList::static_type(), + gtk4::gdk::DragAction::COPY, + ); + let ui_drop = ui.clone(); + drop.connect_accept(move |_, _| { + let gallery = &ui_drop.gallery; + !gallery.in_collection() + && gallery.view_switch.active() == VIEW_TIMELINE + && gallery.library.get() + && *ui_drop.mounted.borrow() + }); + let content_enter = content.clone(); + drop.connect_enter(move |_, _, _| { + content_enter.add_css_class("files-drop-active"); + gtk4::gdk::DragAction::COPY + }); + let content_leave = content.clone(); + drop.connect_leave(move |_| content_leave.remove_css_class("files-drop-active")); + let ui_dropped = ui.clone(); + let content_drop = content.clone(); + drop.connect_drop(move |_, value, _, _| { + content_drop.remove_css_class("files-drop-active"); + let Ok(files) = value.get::() else { + return false; + }; + let paths: Vec = files + .files() + .iter() + .filter_map(|file| file.path()) + .collect(); + upload_media(&ui_dropped, paths); + true + }); + content.add_controller(drop); // Filter toggles: flipping to a tab reloads the timeline filtered to that // kind. A redundant toggle to the already-current kind is a no-op. @@ -1346,88 +1581,458 @@ pub(crate) fn wire_gallery( let ui_upload = ui.clone(); ui.gallery.upload.connect_clicked(move |_| { let dialog = gtk4::FileDialog::builder() - .title(gettext("Select Photo to Upload")) + .title(gettext("Upload Photos and Videos")) + .accept_label(pgettext("verb", "Upload")) .build(); - let filter = gtk4::FileFilter::new(); - filter.set_name(Some(&gettext("Images"))); + filter.set_name(Some(&gettext("Photos and videos"))); filter.add_mime_type("image/*"); + filter.add_mime_type("video/*"); let filters = gio::ListStore::new::(); filters.append(&filter); dialog.set_filters(Some(&filters)); let ui = ui_upload.clone(); let parent_win = ui.stack.root().and_downcast::(); - dialog.open(parent_win.as_ref(), gio::Cancellable::NONE, move |res| { - if let Ok(file) = res - && let Some(path) = file.path() - { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("photo.jpg") - .to_string(); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("jpg") - .to_lowercase(); - let media_type = match ext.as_str() { - "png" => "image/png", - "gif" => "image/gif", - "webp" => "image/webp", - "tiff" | "tif" => "image/tiff", - _ => "image/jpeg", - }; - // The daemon dates the photo: by its EXIF, or by the file's - // modification time when it has none. - let capture_time = None; - - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - // The daemon opens the file itself: it runs on this machine, - // and a photo's bytes through line-delimited JSON is an OOM - // of both processes (see `Request::UploadPhoto`). - Request::UploadPhoto { - name, - media_type: media_type.to_string(), - source_path: path.display().to_string(), - capture_time, - }, - ); - let ui_clone = ui.clone(); - glib::spawn_future_local(async move { - let res = rx.recv().await; - ui_clone.busy_end(); - match res { - Ok(Ok(Response::Ok { message })) => { - tracing::info!("Photo uploaded: {message}"); - load_gallery(&ui_clone, false); - toast(&ui_clone, &gettext("Photo uploaded")); - } - Ok(Ok(Response::Error { message, kind })) => { - toast_failure( - &ui_clone, - &gettext("Couldn't upload photo"), - &message, - kind, - ); - } - _ => { - toast_error( - &ui_clone, - &gettext("Couldn't upload photo"), - &gettext("The Proton Drive service didn't respond."), - ); - } - } - }); - } + dialog.open_multiple(parent_win.as_ref(), gio::Cancellable::NONE, move |res| { + // An error here is the dialog being dismissed. + let Ok(files) = res else { + return; + }; + let paths: Vec = (0..files.n_items()) + .filter_map(|index| files.item(index).and_downcast::()) + .filter_map(|file| file.path()) + .collect(); + upload_media(&ui, paths); }); }); } +/// A section heading: its label, and a check that picks every photo under it. +/// The check shows on hover, and always in selection mode; picking a section +/// starts selection mode, like Ctrl+click on a tile. +fn section_heading_widget(ui: &Rc, heading: &SectionHeading) -> gtk4::Box { + let label = gtk4::Label::builder() + .label(&heading.label) + .halign(gtk4::Align::Start) + .build(); + label.add_css_class("gallery-day"); + let check = gtk4::Button::builder() + .icon_name(if heading.selected { + "checkbox-checked-symbolic" + } else { + "checkbox-symbolic" + }) + .tooltip_text(if heading.selected { + gettext("Deselect these photos") + } else { + gettext("Select these photos") + }) + .valign(gtk4::Align::Center) + .build(); + check.add_css_class("flat"); + check.add_css_class("circular"); + check.add_css_class("gallery-day-check"); + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + row.add_css_class("gallery-heading"); + if heading.selecting { + row.add_css_class("selecting"); + } + row.append(&label); + row.append(&check); + let ui = ui.clone(); + let uids = heading.uids.clone(); + let selected = heading.selected; + check.connect_clicked(move |_| select_section(&ui, &uids, !selected)); + row +} + +/// Pick or unpick every photo of a section. +fn select_section(ui: &Rc, uids: &[String], pick: bool) { + set_selection_mode(ui, true); + { + let mut selected = ui.gallery.selected.borrow_mut(); + for uid in uids { + if pick { + selected.insert(uid.clone()); + } else { + selected.remove(uid); + } + } + } + *ui.gallery.select_anchor.borrow_mut() = uids.last().cloned(); + sync_selection_bar(ui); + repaint_gallery(ui); +} + +/// Float the heading of the section at the top of the timeline over the grid, +/// while the section's own heading row is scrolled away. +fn sync_float_heading(ui: &Rc) { + let list = &ui.gallery.list; + let scrolled = list.vadjustment().is_some_and(|adj| adj.value() > 1.0); + let top = scrolled + .then(|| { + let bound = ui.gallery.bound.borrow(); + bound.iter().find_map(|(position, (_, row))| { + let rect = row.compute_bounds(list)?; + (row.is_mapped() && rect.y() + rect.height() > 0.0).then_some(*position) + }) + }) + .flatten() + .and_then(|position| ui.gallery.groups.item(position)) + .and_downcast::(); + let section = top.as_ref().and_then(|boxed| { + let row = boxed.borrow::(); + // A heading row at the top already says it. + match &*row { + GalleryRow::Tiles { .. } => row.section().map(str::to_string), + GalleryRow::Memories | GalleryRow::Heading(_) => None, + } + }); + if let Some(section) = §ion { + ui.gallery.float_label.set_label(section); + } + ui.gallery.float_heading.set_reveal_child(section.is_some()); +} + +/// The photos in the loaded model with these uids. +fn photos_by_uid(ui: &Rc, uids: &HashSet) -> Vec { + let model = &ui.gallery.model; + (0..model.n_items()) + .filter_map(|index| model.item(index).and_downcast::()) + .map(|boxed| boxed.borrow::().clone()) + .filter(|photo| uids.contains(&photo.uid)) + .collect() +} + +/// Star every selected photo, or unstar them all when every one already is. +fn favorite_selected(ui: &Rc) { + let photos = photos_by_uid(ui, &ui.gallery.selected.borrow()); + let favorite = !photos.iter().all(|photo| photo.favorite); + let uids: Vec = photos + .into_iter() + .filter(|photo| photo.favorite != favorite) + .map(|photo| photo.uid) + .collect(); + if uids.is_empty() { + return; + } + let total = uids.len(); + ui.busy_begin(); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let mut changed = 0; + let mut failure = None; + for uid in uids { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::SetPhotoFavorite { + uid: uid.clone(), + favorite, + }, + ); + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + set_gallery_favorite(&ui, &uid, favorite); + changed += 1; + } + Ok(Ok(Response::Error { message, .. })) => failure = Some(message), + _ => failure = Some(gettext("The Proton Drive service didn't respond.")), + } + } + ui.busy_end(); + repaint_gallery(&ui); + sync_selection_bar(&ui); + if let Some(message) = failure { + toast_error( + &ui, + &ngettext_f( + "Couldn't change {failed} of {n} favorite", + "Couldn't change {failed} of {n} favorites", + total as u64, + &[("failed", &(total - changed).to_string())], + ), + &message, + ); + } else { + let n = changed as u64; + toast( + &ui, + &if favorite { + ngettext_f( + "Added {n} photo to Favorites", + "Added {n} photos to Favorites", + n, + &[], + ) + } else { + ngettext_f( + "Removed {n} photo from Favorites", + "Removed {n} photos from Favorites", + n, + &[], + ) + }, + ); + } + }); +} + +/// Save a copy of every selected photo into a folder the user picks, under +/// its Drive name. +fn save_selected(ui: &Rc) { + let photos = photos_by_uid(ui, &ui.gallery.selected.borrow()); + if photos.is_empty() { + return; + } + let dialog = gtk4::FileDialog::builder() + .title(gettext("Save Copies")) + .accept_label(gettext("Save Here")) + .build(); + let ui = ui.clone(); + let parent = ui_window(&ui); + dialog.select_folder(parent.as_ref(), gio::Cancellable::NONE, move |res| { + // An error here is the dialog being dismissed. + let Some(folder) = res.ok().and_then(|file| file.path()) else { + return; + }; + save_copies(&ui, photos, folder); + }); +} + +/// Fetch each photo and copy it into `folder`, one at a time so a large +/// selection does not ask the daemon for everything at once. +fn save_copies(ui: &Rc, photos: Vec, folder: PathBuf) { + let total = photos.len(); + ui.busy_begin(); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let mut saved = 0; + let mut failure = None; + for photo in photos { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenPhoto { + uid: photo.uid.clone(), + }, + ); + let source = match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => path, + Ok(Ok(Response::Error { message, .. })) => { + failure = Some(message); + continue; + } + _ => { + failure = Some(gettext("The Proton Drive service didn't respond.")); + continue; + } + }; + let name = photo.name.clone().unwrap_or_else(|| photo.uid.clone()); + let folder = folder.clone(); + let copied = + gio::spawn_blocking(move || copy_without_overwrite(&source, &folder, &name)).await; + match copied { + Ok(Ok(())) => saved += 1, + Ok(Err(e)) => failure = Some(e.to_string()), + Err(_) => failure = Some(gettext("The copy was interrupted.")), + } + } + ui.busy_end(); + match failure { + Some(message) => toast_error( + &ui, + &ngettext_f( + "Couldn't save {failed} of {n} copy", + "Couldn't save {failed} of {n} copies", + total as u64, + &[("failed", &(total - saved).to_string())], + ), + &message, + ), + None => toast( + &ui, + &ngettext_f("Saved {n} copy", "Saved {n} copies", saved as u64, &[]), + ), + } + }); +} + +/// Copy `source` into `folder` as `name`, or as "name (2)" and so on when that +/// name is taken: a copy never replaces a file that is already there. +pub(crate) fn copy_without_overwrite( + source: &str, + folder: &Path, + name: &str, +) -> std::io::Result<()> { + let name = Path::new(name).file_name().map_or_else( + || "photo".into(), + |name| name.to_string_lossy().into_owned(), + ); + let (stem, ext) = match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem.to_string(), format!(".{ext}")), + _ => (name.clone(), String::new()), + }; + let mut reader = std::fs::File::open(source)?; + for n in 1.. { + let candidate = if n == 1 { + folder.join(&name) + } else { + folder.join(format!("{stem} ({n}){ext}")) + }; + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(mut out) => { + std::io::copy(&mut reader, &mut out)?; + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + unreachable!("some numbered name is free") +} + +/// Upload photos and videos from this computer into the timeline, one after +/// the other, then reload it once. +pub(crate) fn upload_media(ui: &Rc, paths: Vec) { + let uploads: Vec<(PathBuf, String)> = paths + .into_iter() + .filter_map(|path| upload_media_type(&path).map(|media_type| (path, media_type))) + .collect(); + if uploads.is_empty() { + toast(ui, &gettext("Only photos and videos can go into Photos")); + return; + } + let total = uploads.len(); + ui.busy_begin(); + if total > 1 { + toast( + ui, + &ngettext_f( + "Uploading {n} item…", + "Uploading {n} items…", + total as u64, + &[], + ), + ); + } + let ui = ui.clone(); + glib::spawn_future_local(async move { + let mut uploaded = 0; + let mut failure = None; + for (path, media_type) in uploads { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let rx = spawn_request( + ui.dirs.control_socket(), + // The daemon opens the file itself: it runs on this machine, + // and a photo's bytes through line-delimited JSON is an OOM + // of both processes (see `Request::UploadPhoto`). It also + // dates the photo: by its EXIF, or by the file's modification + // time when it has none. + Request::UploadPhoto { + media_type, + name, + source_path: path.display().to_string(), + capture_time: None, + }, + ); + match rx.recv().await { + Ok(Ok(Response::Ok { message })) => { + tracing::info!("Photo uploaded: {message}"); + uploaded += 1; + } + Ok(Ok(Response::Error { message, kind })) => failure = Some((message, kind)), + _ => { + failure = Some(( + gettext("The Proton Drive service didn't respond."), + ErrorKind::Remote, + )) + } + } + } + ui.busy_end(); + if uploaded > 0 { + load_gallery(&ui, false); + } + match failure { + Some((message, kind)) if total == 1 => { + toast_failure(&ui, &gettext("Couldn't upload photo"), &message, kind) + } + Some((message, kind)) => toast_failure( + &ui, + &ngettext_f( + "Couldn't upload {failed} of {n} item", + "Couldn't upload {failed} of {n} items", + total as u64, + &[("failed", &(total - uploaded).to_string())], + ), + &message, + kind, + ), + None if total == 1 => toast(&ui, &gettext("Photo uploaded")), + None => toast( + &ui, + &ngettext_f("Uploaded {n} item", "Uploaded {n} items", total as u64, &[]), + ), + } + }); +} + +/// The media type Drive records for an upload, or `None` for a file that is +/// neither a photo nor a video. The desktop's MIME database decides, except +/// for a transport stream: it takes `.ts` for TypeScript, so the app's own +/// rule (a `.ts` of a recording's size is a video) goes first. +pub(crate) fn upload_media_type(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + let size = std::fs::metadata(path).ok().map(|meta| meta.len()); + let (guess, _) = gio::content_type_guess(Some(name), None); + let mime = gio::content_type_get_mime_type(&guess) + .map(|mime| mime.to_string()) + .unwrap_or_default(); + if pdfs_core::control::is_video_file_name(name, size) { + return Some(if mime.starts_with("video/") { + mime + } else { + "video/mp2t".to_string() + }); + } + if mime.starts_with("image/") { + return Some(mime); + } + pdfs_core::control::is_raw_image_name(name).then(|| "application/octet-stream".to_string()) +} + +/// Save the zoom level and the tile shape for the next run, shortly after the +/// last change. +fn save_photos_view(ui: &Rc) { + if let Some(source) = ui.gallery.zoom_save.borrow_mut().take() { + source.remove(); + } + let ui_save = ui.clone(); + let source = glib::timeout_add_local_once(Duration::from_millis(500), move || { + ui_save.gallery.zoom_save.borrow_mut().take(); + let mut config = ui_save.dirs.load_config(); + let view = PhotosView { + row_height: Some(ui_save.gallery.row_height.get()), + square: ui_save.gallery.square.get(), + }; + if config.photos_view == view { + return; + } + config.photos_view = view; + if let Err(e) = ui_save.dirs.save_config(&config) { + tracing::warn!("cannot save the Photos view: {e}"); + } + }); + *ui.gallery.zoom_save.borrow_mut() = Some(source); +} + /// One tile of the grid: the photo, and the box [`justify_rows`] sized it to. #[derive(Clone)] pub(crate) struct Tile { @@ -1443,35 +2048,130 @@ pub(crate) struct Tile { pub(crate) grouping: Grouping, } -/// Break one section's photos into rows of square tiles spanning `width`. +/// Break one section's photos into rows spanning `width`. /// -/// Squares, because a wide window and a day of a few photos do not mix with -/// rows justified to each photo's shape: a short row stays short, and portrait -/// phone shots come out as thin slivers. Every tile of a square grid is the -/// same size whatever it shows, and the thumbnail is cropped to fill it. +/// Each photo keeps its own shape (see [`plan_justified`]), so a portrait shot +/// is not cropped to a sliver of itself. Two cases stay square: the Year zoom, +/// where the tiles are too small for shape to show and a dense even sheet reads +/// better, and the "Square tiles" choice in the page menu. pub(crate) fn justify_rows(ui: &Rc, photos: &[PhotoItem], width: i32) -> Vec> { let selecting = ui.gallery.selecting.get(); let selected = ui.gallery.selected.borrow(); let target = ui.gallery.row_height.get(); let grouping = grouping_for(target); - let (columns, size) = plan_grid(width, target); - photos - .chunks(columns) + let tile = |photo: &PhotoItem, (width, height): (i32, i32)| Tile { + selecting, + selected: selecting && selected.contains(&photo.uid), + grouping, + photo: photo.clone(), + width, + height, + }; + if ui.gallery.square.get() || grouping == Grouping::Year { + let (columns, size) = plan_grid(width, target); + return photos + .chunks(columns) + .map(|row| row.iter().map(|photo| tile(photo, (size, size))).collect()) + .collect(); + } + let ratios: Vec = photos.iter().map(|photo| tile_ratio(photo.ratio)).collect(); + let mut photos = photos.iter(); + plan_justified(&ratios, width, target) + .into_iter() .map(|row| { - row.iter() - .map(|photo| Tile { - selecting, - selected: selecting && selected.contains(&photo.uid), - grouping, - photo: photo.clone(), - width: size, - height: size, - }) + row.into_iter() + .zip(photos.by_ref()) + .map(|(size, photo)| tile(photo, size)) .collect() }) .collect() } +/// The narrowest and the widest shape a tile may take, as width over height. +/// A panorama at its own shape would be a ribbon, and a screenshot of a phone +/// a thread; past these the thumbnail is cropped to fit. +pub(crate) const TILE_RATIO_MIN: f64 = 0.5; +pub(crate) const TILE_RATIO_MAX: f64 = 2.5; + +/// The shape a photo's tile takes: its own, clamped, or square while the +/// daemon has not seen a thumbnail of it yet. +pub(crate) fn tile_ratio(ratio: Option) -> f64 { + ratio + .filter(|ratio| ratio.is_finite() && *ratio > 0.0) + .unwrap_or(1.0) + .clamp(TILE_RATIO_MIN, TILE_RATIO_MAX) +} + +/// The justified layout math: tiles of the given shapes, in order, broken into +/// rows that each span `width` exactly, gaps included, at a height near +/// `target`. Returns each row's `(width, height)` sizes. +/// +/// A row takes photos until it is at least as wide as `width` at the target +/// height, then keeps or gives back its last photo, whichever leaves the row +/// height nearer the target. The last row of the section is not stretched: +/// two photos from one day stay two photos at the target height, not two +/// photos as tall as the window. +pub(crate) fn plan_justified(ratios: &[f64], width: i32, target: i32) -> Vec> { + let width = width.max(1); + let target = f64::from(target.clamp(ROW_MIN, ROW_MAX)); + let gap = f64::from(TILE_GAP); + // The height that makes `ratios` span the width exactly. + let fit = |ratios: &[f64]| { + let gaps = gap * (ratios.len() as f64 - 1.0); + (f64::from(width) - gaps).max(1.0) / ratios.iter().sum::() + }; + let mut rows = Vec::new(); + let mut start = 0; + while start < ratios.len() { + let mut end = start + 1; + while end < ratios.len() && fit(&ratios[start..end]) > target { + end += 1; + } + let full = fit(&ratios[start..end]) <= target; + // Giving the last photo back leaves the row taller than the target; + // keeping it, shorter. Whichever misses by less wins. + if full + && end - start > 1 + && fit(&ratios[start..end - 1]) - target < target - fit(&ratios[start..end]) + { + end -= 1; + } + let row = &ratios[start..end]; + rows.push(if full { + size_row(row, fit(row), Some(width)) + } else { + size_row(row, target, None) + }); + start = end; + } + rows +} + +/// Tile sizes for one row of shapes at `height`. With `span`, the widths are +/// rounded so the row, gaps included, is exactly that wide: each tile ends +/// where its share of the row ends, so no rounding error piles up at the end. +fn size_row(ratios: &[f64], height: f64, span: Option) -> Vec<(i32, i32)> { + let tile_height = (height.round() as i32).max(1); + let Some(span) = span else { + return ratios + .iter() + .map(|ratio| (((ratio * height).round() as i32).max(1), tile_height)) + .collect(); + }; + let content = f64::from(span - TILE_GAP * (ratios.len() as i32 - 1)); + let total: f64 = ratios.iter().sum(); + let mut sizes = Vec::with_capacity(ratios.len()); + let mut done = 0.0; + let mut left = 0; + for ratio in ratios { + done += ratio; + let right = (content * done / total).round() as i32; + sizes.push(((right - left).max(1), tile_height)); + left = right; + } + sizes +} + /// The layout math: how many square tiles of about `target` px fit across /// `width`, and the size that makes them span it exactly, gaps included. /// @@ -1511,8 +2211,10 @@ pub(crate) fn gallery_width(ui: &Rc) -> i32 { pub(crate) struct TileWidget { pub(crate) button: gtk4::Button, picture: gtk4::Picture, + placeholder: gtk4::Image, caption: gtk4::Label, video: gtk4::Image, + favorite: gtk4::Image, group: gtk4::Label, check: gtk4::Image, photo: Rc>>, @@ -1521,9 +2223,10 @@ pub(crate) struct TileWidget { impl TileWidget { pub(crate) fn new(ui: &Rc) -> Self { let picture = gtk4::Picture::builder() - // Every tile is square, so Cover crops the photo to fill it. - // Contain would letterbox it, and the bars would read as gaps in - // the sheet. The expands are what make the picture take the whole + // Cover fills the tile: a tile at the photo's own shape shows it + // whole, and a square tile or a clamped panorama crops it. Contain + // would letterbox it, and the bars would read as gaps in the + // sheet. The expands are what make the picture take the whole // overlay. .content_fit(gtk4::ContentFit::Cover) .can_shrink(true) @@ -1534,7 +2237,7 @@ impl TileWidget { picture.add_css_class("photo-thumb"); let placeholder = gtk4::Image::builder() - .icon_name("image-x-generic-symbolic") + .icon_name("pdfs-photos-symbolic") .pixel_size(24) .halign(gtk4::Align::Center) .valign(gtk4::Align::Center) @@ -1572,6 +2275,18 @@ impl TileWidget { .build(); group.add_css_class("photo-group-badge"); + // A favorite says so in the corner nothing else uses. + let favorite = gtk4::Image::builder() + .icon_name("starred-symbolic") + .pixel_size(14) + .halign(gtk4::Align::End) + .valign(gtk4::Align::End) + .margin_end(6) + .margin_bottom(6) + .can_target(false) + .build(); + favorite.add_css_class("photo-favorite-badge"); + let check = gtk4::Image::builder() .pixel_size(16) .halign(gtk4::Align::Start) @@ -1587,6 +2302,7 @@ impl TileWidget { overlay.add_overlay(&caption); overlay.add_overlay(&video); overlay.add_overlay(&group); + overlay.add_overlay(&favorite); overlay.add_overlay(&check); let button = gtk4::Button::builder().child(&overlay).build(); @@ -1665,8 +2381,10 @@ impl TileWidget { Self { button, picture, + placeholder, caption, video, + favorite, group, check, photo, @@ -1688,7 +2406,15 @@ impl TileWidget { // Zoomed out, the play glyph shrinks with the tile so it marks the // video without covering it. let small = tile.grouping != Grouping::Day; - self.video.set_visible(tile.photo.kind == PhotoKind::Video); + let video = tile.photo.kind == PhotoKind::Video; + self.video.set_visible(video); + self.placeholder.set_icon_name(Some(if video { + "video-x-generic-symbolic" + } else { + "pdfs-photos-symbolic" + })); + self.favorite + .set_visible(tile.photo.favorite && tile.grouping != Grouping::Year); self.video.set_pixel_size(if small { 12 } else { 28 }); set_css_class(&self.video, "photo-video-badge-small", small); @@ -1758,9 +2484,7 @@ pub(crate) fn set_selection_mode(ui: &Rc, selecting: bool) { ui.gallery.selected.borrow_mut().clear(); ui.gallery.select_anchor.borrow_mut().take(); } - if ui.gallery.select_btn.is_active() != selecting { - ui.gallery.select_btn.set_active(selecting); - } + sync_header(ui); sync_selection_bar(ui); repaint_gallery(ui); } @@ -1982,17 +2706,37 @@ fn tile_at(ui: &Rc, x: f64, y: f64) -> Option { /// Reflect the selection in the bar above the grid. fn sync_selection_bar(ui: &Rc) { + let selection = &ui.gallery.selection; let count = ui.gallery.selected.borrow().len(); - ui.gallery.select_label.set_label(&match count { + selection.title.set_title(&match count { 0 => gettext("Select photos"), // Translators: {n} is how many items are selected: files and folders in My Files, photos here. n => ngettext_f("{n} selected", "{n} selected", n as u64, &[]), }); - ui.gallery.select_trash.set_sensitive(count > 0); - ui.gallery.select_album.set_sensitive(count > 0); - ui.gallery - .select_bar - .set_reveal_child(ui.gallery.selecting.get()); + for button in [ + &selection.favorite, + &selection.album, + &selection.remove, + &selection.save, + &selection.trash, + ] { + button.set_sensitive(count > 0); + } + // Favorite stars the selection, or unstars it once every photo is one. + let all_favorite = count > 0 + && photos_by_uid(ui, &ui.gallery.selected.borrow()) + .iter() + .all(|p| p.favorite); + selection.favorite.set_icon_name(if all_favorite { + "non-starred-symbolic" + } else { + "starred-symbolic" + }); + selection.favorite.set_tooltip_text(Some(&if all_favorite { + gettext("Remove from Favorites") + } else { + gettext("Add to Favorites") + })); } /// Confirm, then move every selected photo to Proton trash. @@ -2570,6 +3314,7 @@ pub(crate) fn set_gallery_tile(ui: &Rc, tile: i32) { return; } ui.gallery.row_height.set(tile); + save_photos_view(ui); schedule_relayout(ui); } @@ -2617,12 +3362,24 @@ fn build_rows(ui: &Rc) -> Vec { if shows_memories(ui) { rows.push(GalleryRow::Memories); } + let selecting = ui.gallery.selecting.get(); for group in group_photos(&ui.gallery.model, grouping) { - rows.push(GalleryRow::Heading(group.heading)); + let uids: Vec = group.photos.iter().map(|photo| photo.uid.clone()).collect(); + let selected = selecting && { + let picked = ui.gallery.selected.borrow(); + uids.iter().all(|uid| picked.contains(uid)) + }; + rows.push(GalleryRow::Heading(SectionHeading { + label: group.heading.clone(), + uids, + selecting, + selected, + })); let grid = justify_rows(ui, &group.photos, width); let last_index = grid.len().saturating_sub(1); for (index, tiles) in grid.into_iter().enumerate() { rows.push(GalleryRow::Tiles { + section: group.heading.clone(), tiles, last: index == last_index, }); @@ -2635,7 +3392,12 @@ fn clone_row(row: &GalleryRow) -> GalleryRow { match row { GalleryRow::Memories => GalleryRow::Memories, GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()), - GalleryRow::Tiles { tiles, last } => GalleryRow::Tiles { + GalleryRow::Tiles { + section, + tiles, + last, + } => GalleryRow::Tiles { + section: section.clone(), tiles: tiles.clone(), last: *last, }, @@ -2944,7 +3706,7 @@ fn memory_card(ui: &Rc, years_ago: u32, photos: Vec) -> gtk4::But .can_shrink(true) .build(); let placeholder = gtk4::Image::builder() - .icon_name("image-x-generic-symbolic") + .icon_name("pdfs-photos-symbolic") .pixel_size(24) .build(); placeholder.add_css_class("photo-placeholder"); @@ -3279,7 +4041,7 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { ui.gallery.model.remove_all(); gallery_status( ui, - "image-x-generic-symbolic", + "pdfs-photos-symbolic", &gettext("Loading photos…"), &gettext("Reading your Proton Drive timeline."), false, @@ -3406,7 +4168,7 @@ pub(crate) fn load_gallery(ui: &Rc, append: bool) { if ui.gallery.favorites.get() { "starred-symbolic" } else { - "image-x-generic-symbolic" + "pdfs-photos-symbolic" }, &title, &description, @@ -3488,9 +4250,10 @@ pub(crate) fn play_external(path: &str) { #[cfg(test)] mod tests { use super::{ - Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, active_filters, - empty_timeline_text, grouping_for, memory_years, month_index, month_range, plan_grid, - section_heading, + Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX, + TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for, + memory_years, month_index, month_range, plan_grid, plan_justified, section_heading, + tile_ratio, }; use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth}; @@ -3529,6 +4292,90 @@ mod tests { } } + /// Every row but the last spans the width to the pixel, gaps included, + /// and no row is wider than the width. + #[test] + fn justified_rows_span_the_content_width() { + let ratios: Vec = (0..60) + .map(|i| [1.5, 0.75, 1.0, 1.78, 0.5625, 2.5, 1.33][i % 7]) + .collect(); + for width in [360, 640, 901, 1440, 2560] { + for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] { + let rows = plan_justified(&ratios, width, target); + assert_eq!(rows.iter().map(Vec::len).sum::(), ratios.len()); + for (index, row) in rows.iter().enumerate() { + let span: i32 = + row.iter().map(|(w, _)| w).sum::() + TILE_GAP * (row.len() as i32 - 1); + assert!(span <= width, "{span}px overflows {width}px"); + if index + 1 < rows.len() { + assert_eq!(span, width, "row {index} at {width}px, {target}px"); + } + let height = row[0].1; + assert!(row.iter().all(|(_, h)| *h == height), "one height per row"); + } + } + } + } + + /// A row lands near the zoom level: never more than half a tile's height + /// off, whichever way it rounds. + #[test] + fn justified_rows_stay_near_the_zoom_level() { + let ratios = vec![1.33; 40]; + for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] { + let rows = plan_justified(&ratios, 1440, target); + for row in &rows[..rows.len() - 1] { + let height = row[0].1; + assert!( + (height - target).abs() <= target / 2, + "{height}px row for a {target}px zoom" + ); + } + } + } + + /// Two photos from one day stay at the zoom level rather than being + /// stretched to fill a wide window. + #[test] + fn the_last_row_is_not_stretched() { + let rows = plan_justified(&[1.5, 0.75], 1920, ROW_DEFAULT); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0], vec![(270, ROW_DEFAULT), (135, ROW_DEFAULT)]); + } + + #[test] + fn a_tile_takes_the_shape_of_its_photo_within_bounds() { + assert_eq!(tile_ratio(None), 1.0); + assert_eq!(tile_ratio(Some(f64::NAN)), 1.0); + assert_eq!(tile_ratio(Some(0.0)), 1.0); + assert_eq!(tile_ratio(Some(1.5)), 1.5); + assert_eq!(tile_ratio(Some(0.1)), TILE_RATIO_MIN); + assert_eq!(tile_ratio(Some(9.0)), TILE_RATIO_MAX); + // A portrait photo is narrower than a landscape one in the same row. + let rows = plan_justified(&[1.5, 0.75, 1.5, 0.75, 1.5], 900, ROW_DEFAULT); + assert!(rows[0][0].0 > rows[0][1].0); + } + + /// Saving copies never replaces a file: a taken name gets a number. + #[test] + fn a_saved_copy_takes_a_free_name() { + let dir = std::env::temp_dir().join(format!("pdfs-copy-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source"); + std::fs::write(&source, b"new").unwrap(); + std::fs::write(dir.join("photo.jpg"), b"old").unwrap(); + let source = source.to_str().unwrap(); + copy_without_overwrite(source, &dir, "photo.jpg").unwrap(); + copy_without_overwrite(source, &dir, "photo.jpg").unwrap(); + copy_without_overwrite(source, &dir, "../escape").unwrap(); + assert_eq!(std::fs::read(dir.join("photo.jpg")).unwrap(), b"old"); + assert_eq!(std::fs::read(dir.join("photo (2).jpg")).unwrap(), b"new"); + assert_eq!(std::fs::read(dir.join("photo (3).jpg")).unwrap(), b"new"); + assert_eq!(std::fs::read(dir.join("escape")).unwrap(), b"new"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn zooming_in_puts_fewer_tiles_in_a_row() { assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0); diff --git a/crates/pdfs-gui/src/app/pages/places.rs b/crates/pdfs-gui/src/app/pages/places.rs index 4c817ae..e2bb9ed 100644 --- a/crates/pdfs-gui/src/app/pages/places.rs +++ b/crates/pdfs-gui/src/app/pages/places.rs @@ -422,9 +422,7 @@ fn open_place(ui: &Rc, place: PlaceInfo) { // As in an album: no filters, no Upload, no view switch — back leads out. ui.gallery.filters.set_visible(false); - ui.gallery.view_switch.widget().set_visible(false); - ui.gallery.upload.set_visible(false); - ui.gallery.back.set_visible(true); + sync_header(ui); ui.gallery.kind.set(None); ui.gallery.range.set(None); @@ -439,9 +437,7 @@ pub(crate) fn close_place(ui: &Rc) { return; } ui.gallery.title.set_title(&gettext("Photos")); - ui.gallery.view_switch.widget().set_visible(true); - ui.gallery.upload.set_visible(true); - ui.gallery.back.set_visible(false); + sync_header(ui); } /// Show the place grid's own status page (loading, empty, or an error). diff --git a/crates/pdfs-gui/src/app/widgets/compat.rs b/crates/pdfs-gui/src/app/widgets/compat.rs index f746660..c019ba2 100644 --- a/crates/pdfs-gui/src/app/widgets/compat.rs +++ b/crates/pdfs-gui/src/app/widgets/compat.rs @@ -10,6 +10,12 @@ use crate::*; pub(crate) struct ToggleGroup { widget: gtk4::Widget, options: Options, + /// Each option's label, kept while [`Self::set_compact`] shows the icons + /// alone. + labels: Rc>>, + /// Each option's own tooltip, as given. + tooltips: Rc<[Option]>, + compact: Rc>, } #[derive(Clone)] @@ -47,6 +53,9 @@ impl ToggleGroup { Self { widget, options: Options::Toggles(toggles), + labels: Rc::new(RefCell::new(labels_of(options))), + tooltips: tooltips_of(options), + compact: Rc::default(), } } else { let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); @@ -71,6 +80,9 @@ impl ToggleGroup { Self { widget: bar.upcast(), options: Options::Buttons(buttons), + labels: Rc::new(RefCell::new(labels_of(options))), + tooltips: tooltips_of(options), + compact: Rc::default(), } } } @@ -115,15 +127,69 @@ impl ToggleGroup { } pub(crate) fn set_label(&self, index: u32, label: &str) { + if let Some(stored) = self.labels.borrow_mut().get_mut(index as usize) { + *stored = label.to_string(); + } + self.show_label(index, label); + } + + /// Put an icon before each option's label, in option order. + pub(crate) fn set_icons(&self, icons: &[&str]) { + match &self.options { + Options::Toggles(toggles) => { + for (toggle, icon) in toggles.iter().zip(icons) { + toggle.set_property("icon-name", *icon); + } + } + Options::Buttons(buttons) => { + let labels = self.labels.borrow(); + for ((button, icon), label) in buttons.iter().zip(icons).zip(labels.iter()) { + let face = adw::ButtonContent::builder() + .icon_name(*icon) + .label(label.as_str()) + .build(); + button.set_child(Some(&face)); + } + } + } + } + + /// Show the icons alone, for a narrow window, with each label moved to + /// the option's tooltip; or bring the labels back. + pub(crate) fn set_compact(&self, compact: bool) { + if self.compact.replace(compact) == compact { + return; + } + let labels = self.labels.borrow().clone(); + for (index, label) in labels.iter().enumerate() { + self.show_label(index as u32, label); + } + } + + /// Show `label` on option `index`, or only in its tooltip while compact. + fn show_label(&self, index: u32, label: &str) { + let compact = self.compact.get(); + let shown = if compact { "" } else { label }; + let given = self.tooltips.get(index as usize).cloned().flatten(); + let tooltip = if compact { + Some(label.to_string()) + } else { + given + }; match &self.options { Options::Toggles(toggles) => { if let Some(toggle) = toggles.get(index as usize) { - toggle.set_property("label", label); + toggle.set_property("label", shown); + toggle.set_property("tooltip", tooltip.unwrap_or_default()); } } Options::Buttons(buttons) => { if let Some(button) = buttons.get(index as usize) { - button.set_label(label); + match button.child().and_downcast::() { + Some(face) => face.set_label(shown), + None => button.set_label(shown), + } + button.set_tooltip_text(tooltip.as_deref()); } } } @@ -271,6 +337,19 @@ fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog { .build() } +/// The labels of `(label, tooltip)` options. +fn labels_of(options: &[(&str, Option<&str>)]) -> Vec { + options.iter().map(|(label, _)| label.to_string()).collect() +} + +/// The tooltips of `(label, tooltip)` options. +fn tooltips_of(options: &[(&str, Option<&str>)]) -> Rc<[Option]> { + options + .iter() + .map(|(_, tooltip)| tooltip.map(str::to_string)) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pdfs-gui/src/app/widgets/file_list.rs b/crates/pdfs-gui/src/app/widgets/file_list.rs index dac98cf..06d2d46 100644 --- a/crates/pdfs-gui/src/app/widgets/file_list.rs +++ b/crates/pdfs-gui/src/app/widgets/file_list.rs @@ -478,7 +478,7 @@ pub(crate) fn text_column( fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) { resize_file_thumbnail(thumbnail, size, grid_fallback_size(size)); - let name_width = (size / 6 + 1).clamp(8, 24); + let name_width = (size / 6 + 1).clamp(8, 40); label.set_width_chars(name_width); label.set_max_width_chars(name_width); } diff --git a/crates/pdfs-gui/src/app/widgets/thumbnails.rs b/crates/pdfs-gui/src/app/widgets/thumbnails.rs index d874940..ad9abd4 100644 --- a/crates/pdfs-gui/src/app/widgets/thumbnails.rs +++ b/crates/pdfs-gui/src/app/widgets/thumbnails.rs @@ -1,4 +1,4 @@ -//! Shared thumbnails for ordinary Drive image files. +//! Shared thumbnails for ordinary Drive image and video files. //! //! The Photos gallery has timeline-specific batching and aspect-ratio state. //! File listings need a smaller, reusable surface: one loader backs My files, @@ -6,7 +6,7 @@ //! so a replaced image never flashes its previous contents. use crate::*; -use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name}; +use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_file_name}; const THUMB_BATCH: usize = 32; const THUMB_CACHE_MAX: usize = 256; @@ -125,6 +125,18 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over .build(); picture.add_css_class("file-thumbnail"); + // Over a video's frame, so it does not pass for a photo. + let play = gtk4::Image::builder() + .icon_name("media-playback-start-symbolic") + .pixel_size(16) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .can_target(false) + .visible(false) + .build(); + play.add_css_class("photo-video-badge"); + play.add_css_class("photo-video-badge-small"); + let overlay = gtk4::Overlay::new(); overlay.set_width_request(size); overlay.set_height_request(size); @@ -133,9 +145,21 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over overlay.set_overflow(gtk4::Overflow::Hidden); overlay.set_child(Some(&fallback)); overlay.add_overlay(&picture); + overlay.add_overlay(&play); overlay } +/// The play badge [`file_thumbnail_widget`] puts after `picture`. +fn play_badge(picture: >k4::Picture) -> Option { + picture + .next_sibling() + .and_downcast::() + .filter(|image| image.has_css_class("photo-video-badge")) +} + +/// Marks a play badge to show once its video's frame is painted. +const VIDEO_BADGE: &str = "video"; + /// Resize an existing thumbnail surface. Grid rows are recycled, so changing /// these requests in place and rebinding the model is enough to implement the /// status-bar zoom without downloading or decoding an image again. @@ -179,6 +203,16 @@ pub(crate) fn bind_file_thumbnail( fallback.set_visible(true); picture.set_visible(false); picture.set_paintable(gtk4::gdk::Paintable::NONE); + // List rows are too small for a badge; their icon says video already. + let video = !entry.is_dir && is_video_file_name(&entry.name, Some(entry.size)); + if let Some(play) = play_badge(&picture) { + play.set_visible(false); + play.set_widget_name(if video && !symbolic_fallback { + VIDEO_BADGE + } else { + "" + }); + } // The widget name is a cheap binding token. List factories recycle their // children; a reply for the previous entry must not paint into the new one. @@ -186,7 +220,10 @@ pub(crate) fn bind_file_thumbnail( picture.set_widget_name(&key); remove_waiter(ui, &picture); - if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() { + if entry.is_dir + || !is_thumbnail_media_name(&entry.name, Some(entry.size)) + || entry.uid.is_empty() + { return; } if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) { @@ -214,12 +251,6 @@ pub(crate) fn bind_file_thumbnail( schedule_file_thumbs(ui); } -/// Whether the file name denotes an image format for which Proton or the local -/// decoder can reasonably provide a thumbnail. -pub(crate) fn is_image_name(name: &str) -> bool { - is_thumbnail_image_name(name) -} - fn thumbnail_key(uid: &str, modified: i64) -> String { format!("{uid}:{modified}") } @@ -238,6 +269,9 @@ fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) { } picture.set_paintable(Some(texture)); picture.set_visible(true); + if let Some(play) = play_badge(picture) { + play.set_visible(play.widget_name() == VIDEO_BADGE); + } if let Some(overlay) = picture.parent().and_downcast::() && let Some(fallback) = overlay.child().and_downcast::() { @@ -413,7 +447,11 @@ fn retry_file_thumbs(ui: &Rc, items: Vec) { #[cfg(test)] mod tests { - use super::{ReplyDisposition, is_image_name, reply_disposition}; + use super::{ReplyDisposition, reply_disposition}; + + fn is_image_name(name: &str) -> bool { + pdfs_core::control::is_thumbnail_image_name(name) + } #[test] fn image_extensions_are_case_insensitive_and_specific() { diff --git a/crates/pdfs-gui/src/hud/preview.rs b/crates/pdfs-gui/src/hud/preview.rs index 7314dad..e14677a 100644 --- a/crates/pdfs-gui/src/hud/preview.rs +++ b/crates/pdfs-gui/src/hud/preview.rs @@ -10,7 +10,7 @@ use std::time::Duration; use adw::prelude::*; use gtk4::{gio, glib}; -use super::row::{fill_slot, thumb_slot}; +use super::row::{content_type_for, fill_slot, thumb_slot}; use super::thumbs::Thumbs; use crate::{Hit, dates, gettext, human_bytes, pgettext}; @@ -235,7 +235,7 @@ impl Preview { let content_type = if hit.is_dir() { glib::GString::from("inode/directory") } else { - gio::functions::content_type_guess(Some(hit.name()), None).0 + content_type_for(hit.name(), hit.size()) }; self.kind .set_label(&gio::functions::content_type_get_description(&content_type)); diff --git a/crates/pdfs-gui/src/hud/row.rs b/crates/pdfs-gui/src/hud/row.rs index 719d64a..e1c26dd 100644 --- a/crates/pdfs-gui/src/hud/row.rs +++ b/crates/pdfs-gui/src/hud/row.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use adw::prelude::*; -use gtk4::{gdk, gio}; +use gtk4::{gdk, gio, glib}; use super::highlight; use super::thumbs::Thumbs; @@ -53,7 +53,7 @@ pub(crate) fn fill_slot(slot: >k4::Overlay, hit: &Hit, thumbs: &Rc, mo let Some(picture) = fallback.next_sibling().and_downcast::() else { return; }; - fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir())); + fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir(), hit.size())); fallback.set_visible(true); picture.set_visible(false); picture.set_paintable(gdk::Paintable::NONE); @@ -63,7 +63,7 @@ pub(crate) fn fill_slot(slot: >k4::Overlay, hit: &Hit, thumbs: &Rc, mo /// The desktop's own full-colour icon for a file name, so results look like /// the user's file manager. Folders are Proton's purple ones while the Proton /// theme is on, as in the app's browser. -pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon { +pub(crate) fn icon_for(name: &str, is_dir: bool, size: u64) -> gio::Icon { if is_dir { let folder = if proton_theme_active() { "pdfs-proton-folder" @@ -72,8 +72,21 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon { }; return gio::ThemedIcon::new(folder).upcast(); } - let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None); - gio::functions::content_type_get_icon(&content_type) + gio::functions::content_type_get_icon(&content_type_for(name, size)) +} + +/// The content type of a file named `name` of `size` bytes, guessed from the +/// name. GIO takes every `.ts` for TypeScript (or Qt translations); a +/// recording's size says otherwise (see +/// [`is_video_file_name`](pdfs_core::control::is_video_file_name)). +pub(crate) fn content_type_for(name: &str, size: u64) -> glib::GString { + let transport_stream = std::path::Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("ts")); + if transport_stream && pdfs_core::control::is_video_file_name(name, Some(size)) { + return glib::GString::from("video/mp2t"); + } + gio::functions::content_type_guess(Some(name), None).0 } /// The sync badge of a Drive file, as in the app's browser: pinned, cached, diff --git a/crates/pdfs-gui/src/hud/thumbs.rs b/crates/pdfs-gui/src/hud/thumbs.rs index 1e66df4..93bcbe1 100644 --- a/crates/pdfs-gui/src/hud/thumbs.rs +++ b/crates/pdfs-gui/src/hud/thumbs.rs @@ -9,8 +9,8 @@ //! matches the file's; //! 2. for local images, decoding the file itself, and writing the result back //! to that cache for the next time and for every other application; -//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet -//! period. +//! 3. for Drive images and videos, the daemon's `FileThumbs`, batched after a +//! short quiet period. //! //! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross //! threads. Each job carries the render epoch it was queued in, and workers @@ -30,7 +30,9 @@ use gtk4::gdk_pixbuf::Pixbuf; use gtk4::prelude::*; use gtk4::{gdk, glib}; -use pdfs_core::control::{FileThumbRequest, Request, Response, is_thumbnail_image_name}; +use pdfs_core::control::{ + FileThumbRequest, Request, Response, is_thumbnail_image_name, is_thumbnail_media_name, +}; use crate::{Hit, spawn_request}; @@ -165,7 +167,13 @@ impl Thumbs { // The widget name binds the picture to one key: a late reply for // another hit must not paint into it. picture.set_widget_name(&key); - if hit.is_dir() || !is_thumbnail_image_name(hit.name()) { + // A video's frame comes from the daemon, which has ffmpeg's help; a + // local video has only what another application cached. + let thumbnailed = match hit { + Hit::Drive(drive) => is_thumbnail_media_name(&drive.name, Some(drive.size)), + Hit::Local(_) => is_thumbnail_image_name(hit.name()), + }; + if hit.is_dir() || !thumbnailed { return; } if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) { diff --git a/crates/pdfs-gui/src/query.rs b/crates/pdfs-gui/src/query.rs index e91e85a..5e6696e 100644 --- a/crates/pdfs-gui/src/query.rs +++ b/crates/pdfs-gui/src/query.rs @@ -94,7 +94,7 @@ pub(crate) fn icon_name(hit: &Hit) -> String { let name = hit.name(); if is_image(name) { "image-x-generic" - } else if is_media(name) { + } else if is_media(name) || pdfs_core::control::is_video_file_name(name, Some(hit.size())) { "video-x-generic" } else if is_document(name) { "text-x-generic" diff --git a/crates/pdfs-gui/tests/icon_names.rs b/crates/pdfs-gui/tests/icon_names.rs index 5e46db8..8753565 100644 --- a/crates/pdfs-gui/tests/icon_names.rs +++ b/crates/pdfs-gui/tests/icon_names.rs @@ -52,6 +52,7 @@ const STOCK: &[&str] = &[ "image-missing-symbolic", "image-x-generic-symbolic", "list-add-symbolic", + "list-remove-symbolic", "mark-location-symbolic", "media-playback-pause-symbolic", "media-playback-start-symbolic", @@ -61,10 +62,12 @@ const STOCK: &[&str] = &[ "object-select-symbolic", "preferences-system-symbolic", "process-stop-symbolic", + "selection-mode-symbolic", "starred-symbolic", "system-search-symbolic", "text-x-generic-symbolic", "user-trash-symbolic", + "video-x-generic-symbolic", "view-grid-symbolic", "view-list-symbolic", "view-more-symbolic", diff --git a/docs/BUGS.md b/docs/BUGS.md index 78053ec..c500d28 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -109,6 +109,7 @@ a live account since. | Entry | Title | |---|---| +| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code | | [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere | | [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline | | [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed | @@ -385,6 +386,49 @@ a live account since. Newest first. +### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code + +**Status:** Fixed, not verified live +**Found:** 2026-11-14, from a user report: large `.ts` video recordings in My files showed a text +icon, and no video in My files had a thumbnail although the same videos had one in Photos. +**Where:** `is_video_file_name` in `crates/pdfs-core/src/control.rs`; `generate_thumbs` and +`sample_video` in `crates/pdfs-fuse/src/photos.rs`; `bind_file_thumbnail` in +`crates/pdfs-gui/src/app/widgets/thumbnails.rs` + +**Repro:** Put an `.mp4` and a recorded `.ts` of a few GB in My files and open the folder in the +app's grid view. Both show a generic icon, and the `.ts` shows a text one. + +**Cause.** Four things: +- File views asked the daemon for thumbnails of images only (`is_thumbnail_image_name`), so a + video was never asked for. +- `.ts` is TypeScript as often as it is an MPEG transport stream. Drive stores both as + `application/octet-stream`, and every place that names a file's type went by the extension: + the browser's icon list had no `.ts`, GIO guesses text, and the opener's `@text` class listed + `.ts`. +- The daemon downloaded a video whole into memory for one frame, so it refused anything over + 256 MiB, and the size check behind that refusal asked the Photos client, which knows nothing + of a My files node. +- `file_thumbs` never asked for the thumbnail an uploader attached, on the belief that only + Photos nodes have one. This client attaches one to every upload it can make one for. + +**Fix.** `is_video_file_name(name, size)` is the one rule: a video extension, and for `.ts` a +size of at least 1 MiB. The browser icon, the thumbnail gate, the launcher's icon and type, and +the opener's classes use it. For a file view, the daemon first asks Drive for the file's own +thumbnail. Without one, a video of up to 24 MiB is downloaded whole. A larger one brings its first +16 MiB, and for an MP4 or MOV whose `moov` box lies further in, the box headers on the way and +that box. The parts are laid out at their offsets in a sparse staging file, which ffmpeg reads. +A `.ts` whose head lacks the 0x47 sync byte is recorded as having no thumbnail. + +**Test:** `a_ts_file_is_a_video_only_at_the_size_of_one` and +`listings_thumbnail_images_and_videos` in `crates/pdfs-core/src/control.rs`; +`a_ts_file_opens_as_media_or_text_by_its_size` in `crates/pdfs-core/src/opener.rs`; +`a_large_mp4_downloads_its_head_and_its_index` and the other `sample_video` tests in +`crates/pdfs-fuse/src/photos.rs`; the simulation +`a_video_thumbnail_downloads_a_small_part_of_the_video`, which reads under 34 MiB for two +videos of over 70 MiB and takes nothing from a video with an uploader's thumbnail. +`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its +index at the end, an MPEG-TS and an MKV. + ### B282 — A file kept offline is downloaded again after a change made elsewhere **Status:** Fixed, not verified live diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b6bdffe..68c4887 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,7 +11,7 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] -## [3.8.0] — 2026-11-12 +## [3.8.0] — 2026-11-14 Schema: **45**. Synced folders now tell device backups apart from folders attached to My files. Existing folders stay device backups. Older builds cannot open the upgraded database. @@ -39,6 +39,41 @@ Existing folders stay device backups. Older builds cannot open the upgraded data `pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before sending the request, and `pdfs public-link` does the same for `viewer` and `editor`. +- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play + badge in the grid. A video's thumbnail is the one its uploader attached when it has one. + Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's + index, and never downloads more than about 16 MiB of a video for it, however large the + video is. **Build Thumbnails** covers videos too (B283). + +- **A new layout for Photos.** + - The grid shows every photo at its own shape, in rows that fill the width (portraits + are no longer cropped to squares). **Square Tiles** in the page menu brings the even + grid back, and the Year zoom stays square. + - Timeline, Albums and Places switch in the header. The filters take one row, which + scrolls sideways in a narrow window. + - The section you are in floats over the top of the grid once its heading has scrolled + away. Headings are larger, and a check beside each one selects the whole day, month or + year. + - Selection mode works as in other GNOME apps: the header shows the count and + **Cancel**, and a bar along the bottom has these actions: + - **Add to Favorites**, or remove them when every selected photo is already one; + - **Add to Album**; + - **Remove from Album**; + - **Save Copies**, into a folder you pick, never overwriting a file; + - **Move to Trash**. + - Favorites carry a star on their tile, and a video waiting for its thumbnail shows a + video glyph. + - New icons for Photos, albums and the slideshow. + - **Upload** takes several photos and videos at once, and you can drop files onto the + timeline to upload them. + - The zoom level and the tile shape are remembered between runs. + - In the photo viewer, the details panel lies over the photo when the window is + narrow instead of squeezing it. + +- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to + tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0 + change it too, and the size is remembered between runs. + ### Changed - **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter @@ -72,6 +107,14 @@ Existing folders stay device backups. Older builds cannot open the upgraded data version is downloaded, which happens on its own. Files kept offline are also checked at startup and when the connection returns, and failed downloads are tried again. +- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the + text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail + and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays + TypeScript source, and the daemon checks the stream's sync byte before it takes a frame. + +- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was + downloaded whole into memory for one frame. + ## [3.7.0] — 2026-11-02 Schema: **44**, unchanged. diff --git a/po/de.po b/po/de.po index bf0a2c0..e72ce10 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-11-01 17:51+0100\n" +"POT-Creation-Date: 2026-11-14 02:47+0100\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -31,7 +31,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:2772 +#: crates/pdfs-gui/src/app/pages/photos.rs:3534 msgid "%H:%M" msgstr "%H:%M" @@ -57,87 +57,87 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:860 +#: crates/pdfs-gui/src/app/main.rs:865 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 -#: crates/pdfs-gui/src/app/pages/browser.rs:454 -#: crates/pdfs-gui/src/app/pages/browser.rs:833 -#: crates/pdfs-gui/src/app/pages/browser.rs:1249 -#: crates/pdfs-gui/src/app/pages/browser.rs:2167 -#: crates/pdfs-gui/src/app/pages/browser.rs:2246 -#: crates/pdfs-gui/src/app/pages/locations.rs:1717 +#: crates/pdfs-gui/src/app/pages/browser.rs:458 +#: crates/pdfs-gui/src/app/pages/browser.rs:838 +#: crates/pdfs-gui/src/app/pages/browser.rs:1254 +#: crates/pdfs-gui/src/app/pages/browser.rs:2213 +#: crates/pdfs-gui/src/app/pages/browser.rs:2292 +#: crates/pdfs-gui/src/app/pages/locations.rs:1721 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:863 crates/pdfs-gui/src/app/main.rs:1532 -#: crates/pdfs-gui/src/app/pages/albums.rs:638 -#: crates/pdfs-gui/src/app/pages/photos.rs:845 -#: crates/pdfs-gui/src/app/pages/places.rs:441 +#: crates/pdfs-gui/src/app/main.rs:866 crates/pdfs-gui/src/app/main.rs:1536 +#: crates/pdfs-gui/src/app/pages/albums.rs:636 +#: crates/pdfs-gui/src/app/pages/photos.rs:898 +#: crates/pdfs-gui/src/app/pages/places.rs:439 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:868 +#: crates/pdfs-gui/src/app/main.rs:869 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:873 +#: crates/pdfs-gui/src/app/main.rs:874 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:876 +#: crates/pdfs-gui/src/app/main.rs:877 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:493 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:877 +#: crates/pdfs-gui/src/app/main.rs:878 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:879 +#: crates/pdfs-gui/src/app/main.rs:880 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1229 +#: crates/pdfs-gui/src/app/main.rs:1230 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1254 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1254 +#: crates/pdfs-gui/src/app/main.rs:1255 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1256 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1256 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1343 +#: crates/pdfs-gui/src/app/main.rs:1344 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1351 +#: crates/pdfs-gui/src/app/main.rs:1352 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1356 +#: crates/pdfs-gui/src/app/main.rs:1357 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1360 +#: crates/pdfs-gui/src/app/main.rs:1361 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1364 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1369 +#: crates/pdfs-gui/src/app/main.rs:1370 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1373 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -190,7 +190,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -34,7 +34,7 @@ msgstr[1] "" #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:2772 +#: crates/pdfs-gui/src/app/pages/photos.rs:3534 msgid "%H:%M" msgstr "" @@ -60,87 +60,87 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:860 +#: crates/pdfs-gui/src/app/main.rs:865 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 -#: crates/pdfs-gui/src/app/pages/browser.rs:454 -#: crates/pdfs-gui/src/app/pages/browser.rs:833 -#: crates/pdfs-gui/src/app/pages/browser.rs:1249 -#: crates/pdfs-gui/src/app/pages/browser.rs:2167 -#: crates/pdfs-gui/src/app/pages/browser.rs:2246 -#: crates/pdfs-gui/src/app/pages/locations.rs:1717 +#: crates/pdfs-gui/src/app/pages/browser.rs:458 +#: crates/pdfs-gui/src/app/pages/browser.rs:838 +#: crates/pdfs-gui/src/app/pages/browser.rs:1254 +#: crates/pdfs-gui/src/app/pages/browser.rs:2213 +#: crates/pdfs-gui/src/app/pages/browser.rs:2292 +#: crates/pdfs-gui/src/app/pages/locations.rs:1721 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:863 crates/pdfs-gui/src/app/main.rs:1532 -#: crates/pdfs-gui/src/app/pages/albums.rs:638 -#: crates/pdfs-gui/src/app/pages/photos.rs:845 -#: crates/pdfs-gui/src/app/pages/places.rs:441 +#: crates/pdfs-gui/src/app/main.rs:866 crates/pdfs-gui/src/app/main.rs:1536 +#: crates/pdfs-gui/src/app/pages/albums.rs:636 +#: crates/pdfs-gui/src/app/pages/photos.rs:898 +#: crates/pdfs-gui/src/app/pages/places.rs:439 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:868 +#: crates/pdfs-gui/src/app/main.rs:869 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:484 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:873 +#: crates/pdfs-gui/src/app/main.rs:874 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:876 +#: crates/pdfs-gui/src/app/main.rs:877 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:493 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:877 +#: crates/pdfs-gui/src/app/main.rs:878 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:879 +#: crates/pdfs-gui/src/app/main.rs:880 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1229 +#: crates/pdfs-gui/src/app/main.rs:1230 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1254 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1254 +#: crates/pdfs-gui/src/app/main.rs:1255 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1255 +#: crates/pdfs-gui/src/app/main.rs:1256 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1256 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1343 +#: crates/pdfs-gui/src/app/main.rs:1344 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1351 +#: crates/pdfs-gui/src/app/main.rs:1352 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1356 +#: crates/pdfs-gui/src/app/main.rs:1357 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1360 +#: crates/pdfs-gui/src/app/main.rs:1361 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1364 +#: crates/pdfs-gui/src/app/main.rs:1365 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1369 +#: crates/pdfs-gui/src/app/main.rs:1370 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1373 +#: crates/pdfs-gui/src/app/main.rs:1374 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -177,21 +177,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1381 +#: crates/pdfs-gui/src/app/main.rs:1382 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:1390 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1397 +#: crates/pdfs-gui/src/app/main.rs:1398 msgid "" "The street map, when turned on, comes from OpenFreeMap in the