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 d1f2d41..4e33268 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -3619,4 +3619,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 9b2155e..2338590 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 | | [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 | | [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back | | [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds | @@ -383,6 +384,51 @@ 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. + +--- + ### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed **Status:** Fixed, not verified live diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d3637e..018fbd8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,47 @@ Existing folders remain device backups; older builds cannot open the upgraded da My files folder. The first sync preserves one-sided files and differing versions (#67). Attachments stay in mirror mode, are excluded from device profiles, and cannot delete the remote root when syncing is stopped. +- **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. + +### Fixed + +- Large `.ts` recordings (MPEG transport streams) show and open as videos: the video icon, a + thumbnail, and the `@media` open rule rather than `@text`. 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 (B283). +- A video over 256 MiB never got a thumbnail, and a smaller one was downloaded whole into + memory for one frame (B283). ## [3.7.0] — 2026-11-02 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