Compare commits

...
Author SHA1 Message Date
nvrl 602f1c9577 feat(gui): play videos and music in the app
Opening a video or a song in My files, by a double-click or Open, now
plays it in an in-app player window, streamed from the mount as before.
Open With… is the way to another player, and the separate Play item is
gone. A file GStreamer can't play shows the error with Open With….

Both that player and the Photos lightbox share the new widgets/player.rs:
a fullscreen button, fullscreen on a double-click, Repeat in the ⋮ menu,
K to play and pause, J/L or Shift+arrows to jump 10 seconds, M to mute,
and a volume that carries over between videos. The lightbox's thumbnail
strip stays off a video, where it covered the seek bar.
2026-11-14 15:57:10 +01:00
nvrl 9132c7ebd5 Merge pull request #90 from narrrl/feat/photos-drag-out
feat(gui): drag photos out of the timeline, upload progress bar
2026-11-14 15:44:55 +01:00
nvrl 94304ccc1a Merge pull request #89 from narrrl/fix/78-retain-unsaved-session-tokens
fix(core): retain unsaved session tokens
2026-11-14 15:37:44 +01:00
nvrl 74cd6fd2a9 fix(gui): keep paging the timeline when zoomed out onto one screen
Zoomed all the way out, a 200-photo page fits on one screen. With
nothing to scroll, the scroll handler never asked for the next page and
the timeline stopped after the first rows. Ask for more whenever the
content no longer fills a screen and a half, also after a zoom step or
a resize, not only when a page lands.
2026-11-14 15:33:41 +01:00
nvrl 4b669168ee feat(gui): drag photos out of the timeline, upload progress bar
- A drag that starts on a selected tile carries the selection out as a
  text/uri-list. PhotoDrag, a gdk::ContentProvider, downloads the photos
  only when the receiver reads the list, and hands over hard links (or
  copies) under their Drive names in the user cache; copies older than
  a day are pruned. Drag-select steps aside for such a drag, and the
  timeline's own drop target ignores drags from inside the app.
- Uploading several photos shows "Uploading 3 of 12 items…" with a
  progress bar under the Photos header, replacing the start toast.
  Photo uploads report no byte progress from the daemon, so the bar
  counts files; per-tile rings would need that first.
2026-11-14 15:25:26 +01:00
nvrl ba5b6a8bf2 fix(core): retain unsaved session tokens
Retry token persistence when the keyring reports a missing session.
oo7 can hide locked entries, so discarding the pending rotation leaves
a spent refresh token in the saved session.

Refs #78
2026-11-14 15:18:27 +01:00
nvrl 3295eda1f0 Merge pull request #88 from narrrl/feat/photos-drag-upload
Feat/photos drag upload
2026-11-14 14:38:24 +01:00
nvrl 41212efdf3 feat(gui,fuse,core): share photos, copy image, thumbnail strip in the viewer
- Share… in the grid's photo menu and the viewer's ⋮ menu opens the Share
  dialog by uid. The daemon accepts a uid from the Photos timeline for
  sharing (resolve_shareable), since the photos volume is in no mount.
  Photos in an album someone else shared are not offered.
- Copy Image (Ctrl+C) puts the shown still on the clipboard. The viewer
  has its own toast overlay, so this and Save a Copy confirm over it
  instead of behind it.
- A thumbnail strip along the viewer's bottom edge, on the gallery model,
  marks the shown photo and jumps on click. Several pictures can now
  wait for one thumbnail, so the strip and the grid tile don't steal it
  from each other.
- PhotoItem carries place_id (serde default); the line under the
  viewer's title reads "Berlin · 12 of 340".
2026-11-14 13:58:25 +01:00
nvrl 5f0b15fe70 fix(gui): a photo viewer bar that reads on any photo
The bar's white-on-translucent-white buttons vanished over a bright photo,
and eight look-alike icons, a floppy disk and an open-document glyph among
them, left the user guessing. The bar now holds the favorite, the details
and a menu with Slideshow, Save a Copy, Open With, Add to Album, Show on
Map and Move to Trash, on dark discs that read over white and black
alike.

A shot stored as more than one file shows a labelled pill ("RAW", "Live",
"2 of 3") instead of a paged icon, lit while the second file is shown.
The title is the localized full date instead of a raw timestamp.

Delete asks first, as the grid does: the dialog opens over the viewer,
and the album prompts can open there too. Alt+Left closes the viewer and
F10 opens the menu; the shortcuts window lists F5, F10 and Alt+Left.
2026-11-14 13:37:50 +01:00
nvrl 033c8d3d71 Merge pull request #86 from narrrl/fix/keep-place-when-navigating
fix(gui): keep the user's place when navigating
2026-11-14 12:57:35 +01:00
nvrl 5c184013f1 feat(gui): restore folder places and add keyboard Back
Back, Forward and Up in My files restore the folder's scroll offset and
selection as they were when it was left; opening a folder any other way
still starts at the top.

Closing the photo viewer scrolls the grid to the photo it showed last.

Alt+Left and Backspace go back a level in Shared with me and Computers
and leave an open album or place; Escape leaves an album or place too,
after ending selection mode. The shortcuts window lists it.

A fresh Photos load, or leaving the grid for Albums or Places, ends a
scrubber jump that was still paging.
2026-11-14 12:53:27 +01:00
nvrl 6be8a8f9bc fix(gui): keep the user's place when navigating
A folder opened in My files, Shared with me or Computers kept the scroll
offset of the folder before it, because the rows are swapped in place to
keep a refresh steady. Another folder, a new search and a fresh Photos
load (filter, album, place) now start at the top; a refresh still keeps
its place.

Closing a photo opened from the Places map returns to the map instead of
leaving the town's timeline behind, and the pending open is dropped when
the user leaves the town before it loads.

load_gallery no longer swaps Albums or Places for the timeline when a
page lands late (viewer paging, upload, trash Undo); the timeline is
marked stale and read when the user switches back. Coming back to Photos
reuses the timeline unless it is stale, and the timeline returns
filtered the way the kind tabs and date show after an album or place.

A search in My files is no longer replaced by the folder listing on file
changes, F5 or returning to the page. A favorite set in the viewer shows
on its tile at once.
2026-11-14 12:53:04 +01:00
nvrl 57acb0b849 chore(po): refresh source references after the merges 2026-11-14 03:08:17 +01:00
nvrl 68ff8c9e80 Merge remote-tracking branch 'origin/feat/photos-redesign' into release-prep
# Conflicts:
#	docs/BUGS.md
#	docs/CHANGELOG.md
2026-11-14 02:57:11 +01:00
nvrl 4552093c53 Merge remote-tracking branch 'origin/main' into release-prep
# Conflicts:
#	docs/CHANGELOG.md
2026-11-14 02:55:06 +01:00
nvrl f2a9a0f64b feat(gui): Ctrl+scroll changes the icon size in My files
Scrolling with Ctrl held over the icon grid steps the icon size, as it
does in Photos. Touchpad deltas add up to a whole notch before each
step, so a small swipe does not jump through every size.
2026-11-14 02:49:41 +01:00
nvrl e571f22d5d feat(gui,core): larger icon sizes in My files
The icon grid's size slider stops at 256 px instead of 144 px, which is
big enough to tell photos apart by their thumbnail. Thumbnails are
512 px, so they stay sharp at that size on a 2x screen. Names may wrap
wider on the larger tiles.

Ctrl+plus, Ctrl+minus and Ctrl+0 step and reset the size while the grid
is shown, as they do in Photos. The size is saved in the config as
files_view.icon_size once the slider rests; configs without it keep the
72 px default.
2026-11-14 02:48:29 +01:00
nvrl 1c50797772 feat(gui,core): modern Photos layout
Lay the timeline out in justified rows. Each photo keeps its own shape,
clamped between 1:2 and 5:2, and every row but a section's last spans
the width to the pixel. "Square Tiles" in the page menu restores the
even grid, and the Year zoom stays square. The zoom level and the
tile shape persist in a new `photos_view` config section.

Move Timeline/Albums/Places into the header as an icon-and-label
switch that shows only icons in a narrow window. The filters take a
single row whose kind tabs scroll sideways.

Selection mode follows GNOME apps:
- the header shows "N selected" and Cancel;
- an ActionBar along the bottom offers Favorite (or unfavorite), Add
  to Album, Remove from Album, Save Copies and Move to Trash;
- each section heading gets a check that selects the whole section.
One function, sync_header, decides the header for the page, an open
album or place, and selection mode.

The heading of the section at the top of the grid floats over the
grid. Favorites get a star badge, and video placeholders a video
glyph. New bundled icons: pdfs-photos, pdfs-album and pdfs-slideshow.

Upload takes several photos and videos at once, and files dropped on
the timeline upload too. The media type comes from the MIME database,
except that a recording-sized .ts counts as video.

The viewer's details panel moves into an OverlaySplitView that lies
over the photo below 800sp.
2026-11-14 02:40:38 +01:00
nvrl 0183f17435 feat(gui,fuse,core): video thumbnails in file views
My files, Shared, Trash and the launcher now ask the daemon for video
thumbnails, not only image ones. Grid tiles with a video thumbnail show
a small play badge.

A `.ts` file is a video from 1 MiB up and a TypeScript source below
that. One rule in pdfs-core (`is_video_file_name`) decides it for the
file icon, the thumbnail gate, the launcher icon and preview, and the
opener's `@media` / `@text` classes. The daemon also checks the MPEG-TS
sync byte before it runs ffmpeg, so a large TypeScript bundle gets no
retries.

A video thumbnail no longer downloads the whole file into memory (B283):
- the server thumbnail is tried first, at no content cost;
- files up to 24 MiB come whole;
- larger ones bring a 16 MiB head, plus the MP4/MOV `moov` box when it
  lies past the head, into a sparse staging file that ffmpeg reads.
The 256 MiB cap is gone. Without ffmpeg, a video answers "no thumbnail"
at once. The folder thumbnail build includes videos.
2026-11-14 02:17:18 +01:00
nvrl b49631e18f Merge pull request #82 from narrrl/feat/shell-completions
feat(cli,core): shell completions with live values from the service
2026-11-12 11:48:12 +01:00
nvrl f25c2459a0 feat(cli,core): shell completions with live values from the service
`pdfs completions <shell>` prints a completion script for bash, zsh, fish,
elvish or PowerShell, built on clap_complete's dynamic engine: on Tab the
shell calls `pdfs` back, which completes commands and options and also the
values the running service knows, each with a description: paths in My
files, synced-folder and queue ids, computers, trash, albums, items shared
with me, invitations, bookmarks, conflict copies, and a file's versions or
a share's members and public link once its path is typed.

A completer that gets no answer from the service within two seconds offers
nothing rather than hanging the prompt. Listings that ask Proton are kept
for 30 s in $XDG_RUNTIME_DIR (0600), so repeated Tabs do not wait again.
Completion is triggered by PDFS_COMPLETE rather than clap's generic
COMPLETE, because the same binary runs the daemon.

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:43:53 +01:00
nvrl ce35a83a04 Merge pull request #79 from narrrl/chore/sdk-0.11.0
build(sdk): bump to 0.11.0
2026-11-12 10:25:00 +01:00
nvrl 762d06765e Merge pull request #80 from narrrl/fix/stale-mount-watchdog-startup
fix(fuse,cli): recover the mount after a watchdog kill and bound thumbnail memory
2026-11-12 10:24:43 +01:00
nvrl a0ab243be6 Merge branch 'main' into chore/sdk-0.11.0 2026-11-09 14:36:49 +01:00
nvrl 2930b457d4 fix(docs): exclude SDK review from wiki 2026-11-05 19:18:18 +01:00
nvrl d9da0e2263 build(sdk): bump to 0.11.0 2026-11-05 19:12:21 +01:00
64 changed files with 15734 additions and 10300 deletions

No files matched your search

Generated
+6 -4
View File
@@ -3562,6 +3562,7 @@ version = "3.8.0"
dependencies = [
"async-trait",
"base64 0.23.1",
"chrono",
"dbus-secret-service-keyring-store",
"directories",
"ignore",
@@ -3847,13 +3848,14 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.10.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8fa2a88130883010b32035e208aaa36fb275f6d7d78ed44a1069b1d33d24481"
checksum = "14a22e621abbc1c61b4640728a7de969c16b66d5d983b59c638d501fda25a7b9"
dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"chrono",
"futures",
"getrandom 0.4.3",
"hex",
@@ -3874,9 +3876,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.10.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af0bf8d50fa829d1a65b09c6855b606578d02734d4089874c971d1063c23c353"
checksum = "bd55a5e8b6600f3aa282ebbdf13ff366431ff9aae84d4eb20b0ef06d598919fe"
dependencies = [
"aes-gcm 0.11.1",
"async-trait",
+3 -2
View File
@@ -15,8 +15,9 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
proton-sdk = { version = "0.10.1" }
proton-drive-rs = { version = "0.10.1" }
proton-sdk = { version = "0.11.0" }
proton-drive-rs = { version = "0.11.0" }
chrono = { version = "0.4", default-features = false, features = ["std"] }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
+1
View File
@@ -12,6 +12,7 @@ description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies]
proton-sdk.workspace = true
proton-drive-rs.workspace = true
chrono.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
+45 -10
View File
@@ -996,21 +996,15 @@ impl TokenSink {
}
}
/// Write the unsaved rotation, if there is one. A blob that moved on, or
/// is gone with a logout, drops it: there is nothing it may still go into.
/// Write the unsaved rotation, if there is one. A blob that moved on drops
/// it. A missing blob is retried: some Secret Service providers also report
/// locked or temporarily hidden items as absent. Never recreate it here.
fn flush(&self) -> Result<()> {
let mut state = self.state.lock();
let Some(tokens) = state.unsaved.clone() else {
return Ok(());
};
let mut stored = match load() {
Ok(stored) => stored,
Err(Error::NotLoggedIn) => {
state.settle();
return Ok(());
}
Err(e) => return Err(e),
};
let mut stored = load()?;
match take_rotation(
&mut stored,
&self.session_id,
@@ -1306,6 +1300,47 @@ mod tests {
}
}
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
/// Treating that as logout loses the only live tokens (B284, issue #78).
#[test]
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
let _turn = mock_keyring();
write_stored(&stored_with("r")).unwrap();
let sink = Arc::new(TokenSink::new("s", "r".into()));
let entry = keyring_entry().unwrap();
entry
.as_any()
.downcast_ref::<keyring_core::mock::Cred>()
.unwrap()
.set_error(keyring_core::Error::NoEntry);
assert!(sink.rotated(tokens("a2", "r2")).is_err());
assert!(sink.state.lock().unsaved.is_some());
assert_eq!(load().unwrap().refresh_token, "r");
sink.flush().unwrap();
assert_eq!(load().unwrap().refresh_token, "r2");
assert!(sink.state.lock().unsaved.is_none());
}
#[test]
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
let _turn = mock_keyring();
let sink = TokenSink::new("s", "r".into());
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
assert!(matches!(load(), Err(Error::NotLoggedIn)));
assert!(sink.state.lock().unsaved.is_some());
let mut login = stored_with("fresh");
login.session_id = "new".into();
write_stored(&login).unwrap();
sink.flush().unwrap();
assert!(sink.state.lock().unsaved.is_none());
assert_eq!(load().unwrap().refresh_token, "fresh");
}
#[test]
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
let unsaved = UnsavedRotation {
+38
View File
@@ -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<i32>,
}
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<i32>,
/// 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]
+68
View File
@@ -1792,6 +1792,11 @@ pub struct PhotoItem {
/// the JPEG it shows is not the whole of what is stored.
#[serde(default)]
pub has_raw: bool,
/// GeoNames id of the town the photo was taken in, when its location is
/// known; [`crate::places::city`] names it. Older daemons omit it, and
/// album entries do not carry it.
#[serde(default)]
pub place_id: Option<u32>,
}
/// A daemon too old to classify a timeline entry is assumed to have served a
@@ -1851,6 +1856,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<u64>) -> 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<u64>) -> 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 +2708,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;
+4
View File
@@ -186,6 +186,8 @@ impl Db {
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
// Album rows carry no location.
place_id: None,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -247,6 +249,8 @@ impl Db {
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
// Album rows carry no location.
place_id: None,
})
})?
.collect::<rusqlite::Result<_>>()?;
+4 -1
View File
@@ -40,6 +40,8 @@ pub struct StoredPhoto {
/// Whether any member of the group is a camera raw file. The grid badges
/// that, because the JPEG it shows is not the whole of what is stored.
pub has_raw: bool,
/// The town the photo was taken in, once its location has been read.
pub place_id: Option<u32>,
}
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
@@ -256,7 +258,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
/// own group: the column is `NULL` until the next refresh computes it, and a
/// `NULL` would otherwise match nothing and drop the photo off the page.
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
p.kind, p.favorite, g.n, g.raw \
p.kind, p.favorite, g.n, g.raw, p.place_id \
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
@@ -272,6 +274,7 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
favorite: r.get::<_, i64>(6)? != 0,
group_size: r.get::<_, i64>(7)?.max(1) as usize,
has_raw: r.get::<_, i64>(8)? != 0,
place_id: r.get(9)?,
})
}
+17
View File
@@ -363,6 +363,23 @@ fn place_photos_carry_their_own_location() {
);
}
/// A timeline photo names the town it was taken in, so the viewer can show it;
/// one without a location names none.
#[test]
fn timeline_photos_name_their_town() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
located("b", 500, "IMG_5.JPG", BERLIN),
TimelineRow::new("n", 400),
])
.unwrap();
let berlin = db.photo_places().unwrap()[0].id;
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
assert_eq!(towns, [Some(berlin), None]);
}
/// A refresh that skips an already-resolved photo keeps where it was taken; one
/// that reads the node again takes what the node now says.
#[test]
+1
View File
@@ -29,6 +29,7 @@ pub mod similar;
pub mod syncignore;
pub mod syncroot;
pub mod takeout;
pub mod timestamp;
pub mod tray;
pub mod update;
+70 -7
View File
@@ -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<String> {
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<String> {
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();
+33
View File
@@ -0,0 +1,33 @@
//! Convert the client's stored epoch seconds at the SDK boundary.
use proton_drive_rs::ModificationTime;
/// A UTC modification time, or `None` when outside the SDK's supported range.
pub fn modification_time(seconds: i64) -> Option<ModificationTime> {
chrono::DateTime::from_timestamp(seconds, 0).map(|time| time.fixed_offset())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_seconds_preserve_the_instant_including_before_1970() {
for seconds in [-1, 0, 1_709_208_000, 1_782_482_460] {
let time = modification_time(seconds).unwrap();
assert_eq!(time.timestamp(), seconds);
assert_eq!(time.offset().local_minus_utc(), 0);
assert_eq!(time.timestamp_subsec_nanos(), 0);
}
assert_eq!(
modification_time(-1).unwrap().to_rfc3339(),
"1969-12-31T23:59:59+00:00"
);
}
#[test]
fn out_of_range_seconds_omit_the_timestamp() {
assert!(modification_time(i64::MIN).is_none());
assert!(modification_time(i64::MAX).is_none());
}
}
+2
View File
@@ -975,6 +975,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
});
let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time,
last_modification_time: capture_time
.and_then(pdfs_core::timestamp::modification_time),
..Default::default()
};
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
+27 -5
View File
@@ -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<Arc<dyn RevisionRead>>;
/// 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<Option<Vec<u8>>>;
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
@@ -352,7 +360,13 @@ impl DriveApi for ProtonDriveClient {
name: &str,
last_modification_time: Option<i64>,
) -> Result<NodeUid> {
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
ProtonDriveClient::create_folder(
self,
parent_uid,
name,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
)
.await
}
async fn upload_file(
@@ -384,7 +398,7 @@ impl DriveApi for ProtonDriveClient {
reader,
intended_size,
thumbnails,
last_modification_time,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
aead,
)
.await
@@ -409,7 +423,7 @@ impl DriveApi for ProtonDriveClient {
reader,
intended_size,
thumbnails,
last_modification_time,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
aead,
)
.await
@@ -429,7 +443,7 @@ impl DriveApi for ProtonDriveClient {
reader,
intended_size,
thumbnails,
last_modification_time,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
)
.await
}
@@ -439,6 +453,14 @@ impl DriveApi for ProtonDriveClient {
Ok(Arc::new(reader))
}
async fn download_thumbnail(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>> {
ProtonDriveClient::download_thumbnail(self, uid, thumbnail_type).await
}
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
+22
View File
@@ -3168,6 +3168,28 @@ impl Core {
)
}
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
///
/// A photo lives on the photos volume, which no location shows, so the
/// residency proof above never finds it. The timeline in the `photos` table
/// is this daemon's proof instead: it lists only photos on the user's own
/// volume, which are theirs to share. A photo in an album someone else
/// shared is not in it and stays refused.
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
resolve_anywhere_with(
uid,
|uid| self.owns_visible_uid(uid),
|uid| {
let uid = uid.to_string();
Ok(self.db.mirror_contains_uid(&uid)?
|| !self
.db
.photos_by_uid(std::slice::from_ref(&uid))?
.is_empty())
},
)
}
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
let state = self.state();
let entry = state
+429 -68
View File
@@ -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<u8>),
/// Nothing: a video with no server thumbnail, and no ffmpeg to take one.
Nothing,
/// A whole image.
Whole(Vec<u8>),
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<u8>,
/// The `moov` box and its offset, when it is not inside `head`.
index: Option<(u64, Vec<u8>)>,
}
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<VideoSample> {
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<Option<(u64, u64)>> {
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.
@@ -885,6 +1027,7 @@ impl Core {
favorite: photo.favorite,
group_size: photo.group_size as u32,
has_raw: photo.has_raw,
place_id: photo.place_id,
}
}
@@ -1406,10 +1549,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 +1703,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 +1755,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 +1820,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 +1833,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 +2030,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 +2456,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 +2701,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<F: std::future::Future>(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<u8>)>,
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<u8>) -> 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<Vec<u8>> {
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<u8> {
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 <seek> -i <path> …
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 +2902,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 +2919,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 +2932,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 +2949,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"),
+1
View File
@@ -200,6 +200,7 @@ impl Core {
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
let metadata = PhotoUploadMetadata {
capture_time: Some(candidate.wanted),
last_modification_time: pdfs_core::timestamp::modification_time(candidate.wanted),
..Default::default()
};
let uploaded = self.rt.block_on(photos.upload_photo_from(
+8 -8
View File
@@ -105,7 +105,7 @@ impl Core {
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.share_node_for_uid(&uid, emails, role, message)
}
@@ -138,7 +138,7 @@ impl Core {
&self,
uid: &str,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.list_share_for_uid(&uid)
}
@@ -215,7 +215,7 @@ impl Core {
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
@@ -278,7 +278,7 @@ impl Core {
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
@@ -349,7 +349,7 @@ impl Core {
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
@@ -391,7 +391,7 @@ impl Core {
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
@@ -460,7 +460,7 @@ impl Core {
/// [`Core::remove_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.remove_public_link_for_uid(&uid, id)
}
@@ -486,7 +486,7 @@ impl Core {
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.stop_sharing_for_uid(&uid)
}
+106
View File
@@ -3731,4 +3731,110 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// A video in My files gets a thumbnail from a frame of its own, but a
/// recording can be gigabytes: only its start, and an MP4's index, are
/// downloaded for it. A video with a thumbnail from its uploader is not
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
eprintln!("skipped: needs ffmpeg");
return;
}
let dir = scratch("video-thumbs");
std::fs::create_dir_all(&dir).unwrap();
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
// An MP4 written this way has its index at the end.
let encode = |name: &str| {
let path = dir.join(name);
let status = std::process::Command::new("ffmpeg")
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
.args(["-c:v", "libx264", "-preset", "ultrafast"])
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
.arg(&path)
.status()
.unwrap();
assert!(status.success());
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
bytes
};
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
let source = b"export const answer = 42;\n".repeat(64 * 1024);
let drive = FakeDrive::new();
drive.device().write("recording.ts", &recording);
drive.device().write("clip.mp4", &clip);
drive.device().write("app.ts", &source);
let phone = drive.device().write("phone.mp4", &clip);
let mut frame = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut frame)
.encode_image(&image::RgbImage::new(320, 180))
.unwrap();
let client = drive.client(1, Faults::lan());
client.set_thumbnail(&phone, frame);
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
path: String::new(),
}) else {
panic!("listing");
};
let items: Vec<control::FileThumbRequest> = 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);
}
}
+43 -3
View File
@@ -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<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for.
hold_download: Mutex<Option<Arc<HoldState>>>,
/// Thumbnails set by [`FakeClient::set_thumbnail`].
thumbnails: Mutex<HashMap<NodeUid, Vec<u8>>>,
/// 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<Option<Arc<HoldState>>>,
/// 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<u8>) {
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<Option<Vec<u8>>> {
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<Vec<u8>> {
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)
}
}
+9 -3
View File
@@ -344,6 +344,9 @@ impl Core {
);
let metadata = PhotoUploadMetadata {
capture_time: photo.capture_time,
last_modification_time: photo
.capture_time
.and_then(pdfs_core::timestamp::modification_time),
..Default::default()
};
// A thumbnail is made from a file, so a photo that can have one
@@ -365,10 +368,13 @@ impl Core {
let thumbnails = self.upload_thumbnails(path, &photo.name);
// No sidecar and no date in the name: the photo's own
// EXIF, before Drive files it under today.
let capture_time = metadata
.capture_time
.or_else(|| pdfs_core::exiftime::capture_time_of(path));
let metadata = PhotoUploadMetadata {
capture_time: metadata
.capture_time
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
capture_time,
last_modification_time: capture_time
.and_then(pdfs_core::timestamp::modification_time),
..metadata
};
std::fs::File::open(path)
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Album: a photo in front of the edge of another, a stack of them. -->
<path fill="#222222" d="M 4,1 H 13.5 A 1.5,1.5 0 0 1 15,2.5 V 12 H 13.5 V 2.5 H 4 Z"/>
<path fill="#222222" fill-rule="evenodd" d="M 2.5,4 H 10.5 A 1.5,1.5 0 0 1 12,5.5 V 13.5 A 1.5,1.5 0 0 1 10.5,15 H 2.5 A 1.5,1.5 0 0 1 1,13.5 V 5.5 A 1.5,1.5 0 0 1 2.5,4 Z M 2.5,5.5 V 13.5 H 10.5 V 5.5 Z"/>
<path fill="#222222" d="M 3.5,12.5 L 5.5,9.2 L 7.2,11.2 L 8.2,10.2 L 9.5,12.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Photos: a framed landscape with a sun. Stock themes only have
image-x-generic, a file type icon, which reads as "an image file"
rather than "your photo library". -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 3.5,11.5 L 6.5,7 L 9,10.2 L 10.5,8.6 L 12.5,11.5 Z"/>
<circle fill="#222222" cx="10.5" cy="5.8" r="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Slideshow: a play mark inside a photo frame, so it is not mistaken for
playing the video on screen. -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 6.5,5.2 L 11,8 L 6.5,10.8 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -15,6 +15,9 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-photos-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-album-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-slideshow-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
+26 -7
View File
@@ -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); }
@@ -61,14 +61,27 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
/* Photo viewer: always light-on-dark, whatever the system style */
.photo-viewer-window { background-color: #111014; }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
.media-player-window video { background-color: black; }
.media-player-note { color: rgba(255, 255, 255, 0.5); }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
.viewer-close-btn { margin-left: 12px; }
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
.viewer-strip { background: none; }
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
.viewer-nav-btn:disabled { opacity: 0; }
@@ -80,7 +93,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 +112,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 +122,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); }
+12 -2
View File
@@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
if changed(Topic::Shares) {
refresh_invitations_badge(ui, Duration::ZERO);
}
match ui.stack.visible_child_name().as_deref() {
Some("browser") if changed(Topic::Files) => load_browser(ui),
let page = ui.stack.visible_child_name();
// A timeline out of sight is read again when the user comes back to it;
// one on screen is left alone (below).
if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) {
ui.gallery.timeline_stale.set(true);
}
match page.as_deref() {
// A search on screen is left alone: reloading would swap its hits for
// the folder's listing while the query stays in the box.
Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => {
load_browser(ui)
}
// The timeline is left alone: reloading it would throw away the place
// the user scrolled to. F5 reloads it on request.
Some("gallery")
+125 -20
View File
@@ -40,6 +40,8 @@ use widgets::crumb::*;
use widgets::details::*;
use widgets::file_list::*;
use widgets::menu::*;
use widgets::photo_drag::*;
use widgets::player::*;
use widgets::scrubber::*;
use widgets::share_dialog::*;
use widgets::thumbnails::*;
@@ -71,7 +73,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,
@@ -457,6 +459,8 @@ fn build_window(app: &adw::Application) {
files: browser_widgets.files.clone(),
history: RefCell::new(Vec::new()),
future: RefCell::new(Vec::new()),
places: RefCell::new(HashMap::new()),
restore: RefCell::new(None),
crumb: browser_widgets.crumb.clone(),
content: browser_widgets.content.clone(),
status: browser_widgets.status.clone(),
@@ -488,6 +492,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,
@@ -522,6 +527,7 @@ fn build_window(app: &adw::Application) {
jump: Cell::new(None),
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(),
upload_bar: gallery_widgets.upload_bar.clone(),
upload: gallery_widgets.upload.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
@@ -587,10 +593,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 +702,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,
@@ -722,17 +731,26 @@ fn build_window(app: &adw::Application) {
cancel_file_thumbnails(&ui_nav);
sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() {
Some("browser") => load_browser(&ui_nav),
Some("browser") => reload_browser_view(&ui_nav),
// The Albums and Places listings are re-read in place. Loading the
// timeline instead would swap it in under the Albums or Places toggle.
// The timeline, an album or a place comes back as it was left,
// scrolled where it was, unless photos changed while it was out of
// sight (see `follow_changes`) or nothing is loaded yet.
Some("gallery") if !ui_nav.gallery.in_collection() => {
match ui_nav.gallery.view_switch.active() {
VIEW_ALBUMS => load_albums(&ui_nav),
VIEW_PLACES => load_places(&ui_nav),
_ => load_gallery(&ui_nav, false),
_ if ui_nav.gallery.timeline_stale.replace(false)
|| ui_nav.gallery.model.n_items() == 0 =>
{
load_gallery(&ui_nav, false)
}
_ => {}
}
}
Some("gallery") => load_gallery(&ui_nav, false),
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false),
Some("gallery") => {}
// Every visit reads the page again. The rows already on screen stay
// up while it does, so arriving costs no "Loading…" flash.
Some("sharedbyme") => load_shared_by_me(&ui_nav),
@@ -795,6 +813,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 +877,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"),
@@ -1065,7 +1080,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() {
Some("browser") => {
let path = ui.browser.path.borrow().clone();
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view);
}
// One scope covers the whole photos view, so Refresh reloads whichever of
// the views — a grid, or the timeline, an album or a place — is on screen.
@@ -1327,7 +1342,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
// The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone();
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
window.add_action(&new_album);
let shortcuts = gio::SimpleAction::new("shortcuts", None);
@@ -1505,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
fn show_shortcuts(window: &adw::ApplicationWindow) {
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
(
gettext_noop("General"),
&[
@@ -1523,6 +1538,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
("<Alt>Up", gettext_noop("Parent folder")),
("<Control>1 <Control>2", gettext_noop("Grid / list")),
("<Control>plus", gettext_noop("Larger thumbnails")),
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("F2", gettext_noop("Rename")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Clear the selection")),
@@ -1535,6 +1553,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("<Control>a", gettext_noop("Select all")),
("Escape <Alt>Left", gettext_noop("Back")),
],
),
(
@@ -1544,8 +1563,20 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("Home End", gettext_noop("First / last photo")),
("i", gettext_noop("Show details")),
("f", gettext_noop("Fullscreen")),
("F5", gettext_noop("Slideshow")),
("F10", gettext_noop("More")),
("<Control>c", gettext_noop("Copy Image")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Close")),
("Escape <Alt>Left", gettext_noop("Close")),
],
),
(
gettext_noop("Video Player"),
&[
("space k", gettext_noop("Play / pause")),
("j l", gettext_noop("Back / forward 10 seconds")),
("m", gettext_noop("Mute")),
("f", gettext_noop("Fullscreen")),
],
),
];
@@ -1571,8 +1602,17 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
controller.connect_key_pressed(move |_, key, _, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
let page = ui.stack.visible_child_name();
let on_browser = page.as_deref() == Some("browser");
let on_trash = page.as_deref() == Some("trash");
let on_gallery = page.as_deref() == Some("gallery");
// Pages with a level to go back to, besides My files' own history.
let in_collection = on_gallery && ui.gallery.in_collection();
let in_shared_folder =
page.as_deref() == Some("shared") && !ui.shared.nav.borrow().is_empty();
let in_device_folder =
page.as_deref() == Some("devices") && !ui.devices.nav.borrow().is_empty();
let back_key = (key == gtk4::gdk::Key::Left && alt) || key == gtk4::gdk::Key::BackSpace;
match key {
// Refresh works on every page, so it is matched before the
// browser-only bindings.
@@ -1588,6 +1628,21 @@ fn install_shortcuts(ui: &Rc<Ui>, 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 {
@@ -1614,6 +1669,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::BackSpace if on_browser => {
ui.browser.actions.activate_action("back", None)
}
// Selection mode ends before Escape leaves anything; the grid
// handles this itself while it has the focus.
gtk4::gdk::Key::Escape if on_gallery && ui.gallery.selecting.get() => {
set_selection_mode(&ui, false)
}
// An album or a place is a level below Photos, as a folder is
// below its parent: Back leaves it, and so does Escape.
_ if in_collection
&& !ui.gallery.selecting.get()
&& (back_key || key == gtk4::gdk::Key::Escape) =>
{
ui.gallery.back.emit_clicked()
}
_ if in_shared_folder && back_key => shared_back(&ui),
_ if in_device_folder && back_key => devices_back(&ui),
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
if let Some(list) = visible_file_list(&ui) {
@@ -1955,6 +2025,7 @@ mod tests {
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
place_id: None,
}
}
@@ -1977,6 +2048,40 @@ mod tests {
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The upload bar counts the upload under way, not the ones finished.
#[test]
fn the_upload_bar_counts_the_upload_under_way() {
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
}
/// The pill names what kind of shot it switches through, and lights up
/// on the file that is not the shot's main one.
#[test]
fn the_group_pill_names_the_kind_of_shot() {
let pair = [
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
];
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
let live = [
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
];
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
let burst = [
member("a", None, PhotoKind::Photo),
member("b", None, PhotoKind::Photo),
member("c", None, PhotoKind::Photo),
];
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
assert_eq!(group_pill(&burst, "elsewhere"), None);
}
/// The switch names what it will show; a file whose name the daemon has not
/// resolved still gets a sentence rather than a blank.
#[test]
+35 -26
View File
@@ -40,7 +40,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
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<Ui>) {
),
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<Ui>, 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)
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::Widget, x: f64
/// while the entry is blank.
fn prompt_album_name(
ui: &Rc<Ui>,
parent: Option<gtk4::Window>,
heading: &str,
action: &str,
current: &str,
on_name: impl Fn(String) + 'static,
) {
let win = ui_window(ui);
let win = parent.or_else(|| ui_window(ui));
let dialog = adw::AlertDialog::builder().heading(heading).build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
@@ -260,10 +261,12 @@ fn prompt_album_name(
}
/// Ask for a name and create an album, then add `photos` to it, if any.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
///
/// The dialogs open over `parent`, or over the main window without one.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
let ui_c = ui.clone();
let (heading, action) = (gettext("New Album"), gettext("Create"));
prompt_album_name(ui, &heading, &action, "", move |name| {
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
let ui = ui_c.clone();
let photos = photos.clone();
@@ -296,7 +299,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone());
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
run_album_request(
&ui_c,
Request::RenameAlbum {
@@ -368,7 +371,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
}
/// Offer our own albums to add `photos` to, plus a new one.
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -390,7 +393,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
}
};
if albums.is_empty() {
prompt_new_album(&ui, photos);
prompt_new_album(&ui, photos, parent);
return;
}
@@ -436,11 +439,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
}
});
let ui_c = ui.clone();
let (ui_c, parent_c) = (ui.clone(), parent.clone());
dialog.connect_response(Some("new"), move |_, _| {
prompt_new_album(&ui_c, photos.clone())
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
});
dialog.present(ui_window(&ui).as_ref());
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
});
}
@@ -595,10 +598,7 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
return;
}
ui.gallery
.thumb_wanted
.borrow_mut()
.insert(uid.clone(), picture.clone());
ui.gallery.await_thumb(&uid, picture);
let mut queue = ui.gallery.thumb_queue.borrow_mut();
if !queue.contains(&uid) {
queue.push_back(uid);
@@ -618,9 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, 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<Ui>) {
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).
@@ -668,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
}
close_album(&ui_photos);
close_place(&ui_photos);
ui_photos.gallery.filters.set_visible(true);
ui_photos.gallery.content.set_visible_child_name("timeline");
let gallery = &ui_photos.gallery;
gallery.filters.set_visible(true);
gallery.content.set_visible_child_name("timeline");
// An album or a place cleared the kind and the month it does not use;
// the filter controls still show them, so the timeline comes back
// filtered the way they say.
let kind = kind_for_tab(gallery.tabs.active() as usize);
let range = gallery
.date_ranges
.borrow()
.get(gallery.dates.selected() as usize)
.copied()
.flatten();
let kind_changed = gallery.kind.replace(kind) != kind;
let range_changed = gallery.range.replace(range) != range;
let refiltered = kind_changed || range_changed;
// The timeline comes back as it was left unless an album took over the
// model since; then it has to be reloaded.
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
{
if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 {
load_gallery(&ui_photos, false);
} else {
update_gallery_subtitle(&ui_photos);
+195 -65
View File
@@ -1,6 +1,14 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
use crate::*;
/// How a folder in My files was left: scrolled `offset` px down, with the
/// entries named `selected` highlighted.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct FolderPlace {
pub(crate) offset: f64,
pub(crate) selected: Vec<String>,
}
pub(crate) struct BrowserState {
// Files (browser) page.
/// The grid and list; the model is repopulated per directory.
@@ -10,6 +18,13 @@ pub(crate) struct BrowserState {
/// breadcrumb.
pub(crate) history: RefCell<Vec<String>>,
pub(crate) future: RefCell<Vec<String>>,
/// Where each folder left by Back, Forward, Up or a descent was scrolled
/// and what was selected in it, by path. Back, Forward and Up put the
/// folder back that way; opening it afresh starts at the top.
pub(crate) places: RefCell<HashMap<String, FolderPlace>>,
/// The folder whose [`Self::places`] entry the next listing restores, set
/// by Back, Forward and Up.
pub(crate) restore: RefCell<Option<String>>,
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
pub(crate) crumb: gtk4::Box,
@@ -77,6 +92,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<Option<glib::SourceId>>,
}
/// Idle pause after the last keystroke before a search query is sent, so typing
@@ -87,9 +104,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 +820,35 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>) {
&[],
)));
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
@@ -851,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
// Open does what a double-click does: a video or a song plays in the app,
// and Open With is the way to another player.
menu.item(&pgettext("verb", "Open"), move || {
// Open always means "download a local copy and hand off", even for a
// media file — `activate_entry` would otherwise stream it.
if is_streamable_media_entry(&entry_c) {
download_and_open(&ui_c, &entry_c);
} else {
activate_entry(&ui_c, &entry_c);
}
activate_entry(&ui_c, &entry_c)
});
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Open With…"), move || {
open_entry_with(&ui_c, &entry_c)
});
// Play streams from the mount, no download; Open above still fetches a
// local copy for anyone who wants one.
if is_streamable_media_entry(entry) {
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&pgettext("verb", "Play"), move || {
stream_entry(&ui_c, &entry_c)
});
}
menu.section();
// Kept through a pinned folder above it, the entry has no pin of its own
@@ -1060,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
Some(entry)
}
/// Whether an entry can be opened through FUSE instead of fully materialized.
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
}
/// Stream media straight from the mount, no download. Drive folders *are* part
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
/// seeks and buffers — instead of waiting for the whole file to land like
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
/// `.mkv` starts playing in seconds.
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let abs = mounted_path(&mountpoint, &rel);
let Some(path) = abs.to_str() else {
toast_error(
ui,
&gettext("Couldn't play media"),
&gettext("The file path isn't valid UTF-8."),
);
return;
};
toast(
ui,
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
);
play_external(path);
}
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
/// other files download-and-open.
/// Open an entry the Nautilus way: folders descend, media plays in the app,
/// streamed from the mount, other files download-and-open.
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
if entry.is_dir {
@@ -1104,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
// Media streams rather than downloads: that is exactly the "play it,
// don't fetch the whole thing" behaviour this is for.
stream_entry(ui, entry);
open_media_player(ui, entry);
} else {
download_and_open(ui, entry);
}
}
/// Hand a file to the user's default application: through the mount when it is
/// there, otherwise downloaded into the cache first. The open path behind both
/// a plain double-click and the context menu's "Open" (including for a video,
/// when the user wants their default application rather than the player).
/// there, otherwise downloaded into the cache first. The open path behind a
/// double-click and the context menu's "Open" for anything but a folder or
/// media, and for media the mount doesn't show.
///
/// The mount comes first because the cache blob is a *copy* keyed by content
/// hash — an application saving into it writes where Drive will never look, and
@@ -1306,6 +1311,11 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::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 +1417,41 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::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<Ui>, 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<Ui>, 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" }
}
@@ -1484,8 +1529,29 @@ pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) {
});
}
/// Remember how the folder on screen is scrolled and what is selected in it,
/// for Back, Forward or Up to bring it back that way. Only a listing of the
/// folder itself counts; search hits are not its rows.
fn remember_place(ui: &Rc<Ui>) {
let path = ui.browser.path.borrow().clone();
if ui.browser.listed.borrow().as_deref() != Some(path.as_str()) {
return;
}
let files = &ui.browser.files;
let place = FolderPlace {
offset: files.scroll_offset(),
selected: files
.selected()
.into_iter()
.map(|entry| entry.name)
.collect(),
};
ui.browser.places.borrow_mut().insert(path, place);
}
/// Open the folder on screen in a new place, remembering where we were.
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
remember_place(ui);
let left = ui.browser.path.replace(target.clone());
if left != target {
ui.browser.history.borrow_mut().push(left);
@@ -1510,6 +1576,8 @@ fn browse_back(ui: &Rc<Ui>) {
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
return;
};
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(previous.clone());
let left = ui.browser.path.replace(previous);
ui.browser.future.borrow_mut().push(left);
load_browser(ui);
@@ -1520,6 +1588,8 @@ fn browse_forward(ui: &Rc<Ui>) {
return;
};
ui.browser.search.set_text("");
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(next.clone());
let left = ui.browser.path.replace(next);
ui.browser.history.borrow_mut().push(left);
load_browser(ui);
@@ -1532,6 +1602,7 @@ fn browse_up(ui: &Rc<Ui>) {
}
ui.browser.search.set_text("");
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
*ui.browser.restore.borrow_mut() = Some(parent.to_string());
browse_to(ui, parent.to_string());
}
@@ -1728,8 +1799,9 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, 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 +1815,7 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, 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 +1828,17 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, 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 +2837,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 +2852,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",
@@ -2844,6 +2920,18 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
});
}
/// Read again what My files shows: the search hits while there is a query,
/// the folder otherwise. [`load_browser`] alone would swap a search's hits
/// for the folder while the query stays in the box.
pub(crate) fn reload_browser_view(ui: &Rc<Ui>) {
let query = ui.browser.search.text().trim().to_string();
if query.is_empty() {
load_browser(ui);
} else {
run_search(ui, &query);
}
}
/// Clear the model and show the daemon's error on the status page. Used for
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
/// (which restarts the service) wouldn't help and isn't offered.
@@ -2882,7 +2970,10 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
show_location_column(ui, false);
*ui.browser.listing.borrow_mut() = entries.to_vec();
*ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
// `listed` still names the folder the rows belong to only on a refresh;
// any other load forgot it (see `load_browser`).
let path = ui.browser.path.borrow().clone();
let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path);
ui.browser.summary.set_label(&listing_summary(
entries.iter().filter(|entry| !entry.is_dir).count(),
entries.iter().filter(|entry| entry.is_dir).count(),
@@ -2911,6 +3002,16 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
// Only the rows that changed are swapped, so a refresh keeps the
// selection, the scroll position and the thumbnails already drawn.
replace_items(&ui.browser.files.model, &sorted);
// Back, Forward and Up return to a folder as it was left; any other way
// in starts at the top.
let restore = ui.browser.restore.take().filter(|target| *target == path);
if another_folder {
let place = restore.and_then(|path| ui.browser.places.borrow().get(&path).cloned());
match place {
Some(place) => ui.browser.files.restore(place.offset, &place.selected),
None => ui.browser.files.scroll_to_top(),
}
}
}
/// Forget which folder the rows belong to and drop the selection, before the
@@ -3089,6 +3190,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
.collect();
sort_entries(&mut entries, ui.browser.view.get());
replace_items(&ui.browser.files.model, &entries);
// Every search starts over (`run_search` forgets the listing), so its hits
// read from the first one.
ui.browser.files.scroll_to_top();
}
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
@@ -3122,7 +3226,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 +3253,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()
}
+16 -4
View File
@@ -190,10 +190,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.devices.nav.borrow_mut().pop();
load_devices(&ui_back);
});
widgets
.back
.connect_clicked(move |_| devices_back(&ui_back));
let ui_retry = ui.clone();
widgets
.retry
@@ -211,6 +210,14 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
ui.devices.content.set_visible_child_name("status");
}
/// Back out of a computer's folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn devices_back(ui: &Rc<Ui>) {
if ui.devices.nav.borrow_mut().pop().is_some() {
load_devices(ui);
}
}
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
/// of another computer's backup.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
@@ -297,6 +304,7 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
};
ui.devices.content.set_visible_child_name("browse");
repaint_device_crumb(&ui, &nav);
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
*ui.devices.listed.borrow_mut() = Some(nav);
if entries.is_empty() {
ui.devices.files.model.remove_all();
@@ -304,6 +312,10 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
} else {
ui.devices.files_content.set_visible_child_name("list");
replace_items(&ui.devices.files.model, &entries);
// A refresh keeps its place; another folder starts at the top.
if another_folder {
ui.devices.files.scroll_to_top();
}
}
});
}
+486 -159
View File
@@ -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.
@@ -50,9 +52,9 @@ pub(crate) struct Viewer {
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
pub(crate) favorite: gtk4::ToggleButton,
pub(crate) favorite_suppress: Cell<bool>,
/// The button that switches between the files of one shot, and the files it
/// The pill that switches between the files of one shot, and the files it
/// switches between (the JPEG and the RAW of a photo, a live photo and its
/// clip). Empty, and the button hidden, for a photo stored as one file.
/// clip). Empty, and the pill hidden, for a photo stored as one file.
pub(crate) group_btn: gtk4::Button,
pub(crate) group: RefCell<Vec<PhotoItem>>,
/// uid of the photo currently on screen.
@@ -79,14 +81,25 @@ pub(crate) struct Viewer {
/// rests; whether the pointer is over one of them (which keeps them up);
/// and the pending fade-out.
pub(crate) chrome: Vec<gtk4::Revealer>,
/// The strip of thumbnails along the bottom, its selection (the photo on
/// screen), and the revealer it fades with. Hidden in a slideshow.
pub(crate) strip: gtk4::ListView,
pub(crate) strip_selection: gtk4::SingleSelection,
pub(crate) strip_reveal: gtk4::Revealer,
pub(crate) chrome_hover: Cell<bool>,
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
/// The slideshow toggle; the timer that moves it on; and whether it put
/// the window in fullscreen, so stopping it only leaves a fullscreen it
/// The ⋮ button, and whether its menu is open, which keeps the controls
/// up the way resting the pointer on them does.
pub(crate) more: gtk4::Button,
pub(crate) menu_open: Cell<bool>,
/// Whether a slideshow runs; the timer that moves it on; and whether it
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
/// entered itself.
pub(crate) slideshow: gtk4::ToggleButton,
pub(crate) slideshow: Cell<bool>,
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
pub(crate) slide_fullscreened: Cell<bool>,
/// Toasts over the lightbox. The main window's would land behind it.
pub(crate) toasts: adw::ToastOverlay,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
@@ -161,6 +174,14 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
.group_btn
.set_tooltip_text(Some(&member_tooltip(next)));
}
if let Some((label, lit)) = group_pill(&items, &uid) {
viewer.group_btn.set_label(&label);
if lit {
viewer.group_btn.add_css_class("lit");
} else {
viewer.group_btn.remove_css_class("lit");
}
}
*viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true);
});
@@ -173,6 +194,34 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
members.get((at + 1) % members.len())
}
/// What the group pill reads for the shot whose files are `members`, with
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
/// a burst counts its frames.
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
let at = members.iter().position(|item| item.uid == uid)?;
let shown = members[at].kind;
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
// Translators: badge on a photo tile whose shot includes a raw camera file.
return Some((gettext("RAW"), shown == PhotoKind::Raw));
}
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
// Translators: the switch between a live photo and its short clip in the photo viewer.
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
}
Some((
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
gettext_f(
"{position} of {total}",
&[
("position", &thousands(at + 1)),
("total", &thousands(members.len())),
],
),
false,
))
}
/// What to call one file of a shot: its name, or its kind when the daemon has
/// not resolved a name for it.
pub(crate) fn member_label(item: &PhotoItem) -> String {
@@ -230,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
clear_info(viewer);
stop_video(viewer);
viewer.media.set_visible_child_name("photo");
sync_strip(viewer);
reset_zoom(viewer);
// A slideshow waits for this photo to land before it starts counting.
disarm_slide(viewer);
@@ -307,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
None => {
viewer.video.set_filename(Some(&path));
viewer.media.set_visible_child_name("video");
sync_strip(&viewer);
*viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, size, ExifInfo::default());
}
@@ -339,14 +390,15 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
/// screen counted down once it has loaded.
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.replace(true) {
return;
}
if !viewer.window.is_fullscreen() {
viewer.window.fullscreen();
viewer.slide_fullscreened.set(true);
}
viewer
.slideshow
.set_icon_name("media-playback-pause-symbolic");
hide_chrome(viewer);
sync_strip(viewer);
if !viewer.loading.get() {
arm_slide(ui, viewer);
}
@@ -354,15 +406,20 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
fn stop_slideshow(viewer: &Rc<Viewer>) {
viewer.slideshow.set(false);
sync_strip(viewer);
disarm_slide(viewer);
if viewer.slide_fullscreened.replace(false) {
viewer.window.unfullscreen();
}
viewer
.slideshow
.set_icon_name("media-playback-start-symbolic");
if viewer.slideshow.is_active() {
viewer.slideshow.set_active(false);
}
/// Start the slideshow, or stop the one running.
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.get() {
stop_slideshow(viewer);
} else {
start_slideshow(ui, viewer);
}
}
@@ -370,7 +427,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
disarm_slide(viewer);
if !viewer.slideshow.is_active() {
if !viewer.slideshow.get() {
return;
}
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
@@ -790,7 +847,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
};
// This timer has fired, so its id must not be removed again.
viewer.chrome_source.borrow_mut().take();
if !viewer.chrome_hover.get() {
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
hide_chrome(&viewer);
}
});
@@ -808,14 +865,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
}
/// Copy the photo on screen to where the user picks, under its Drive name.
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &str, name: &str) {
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
let dialog = gtk4::FileDialog::builder()
.title(gettext("Save a Copy"))
.initial_name(name)
.build();
let source = source.to_string();
let ui = ui.clone();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
let toasts = viewer.toasts.clone();
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
// An error here is the dialog being dismissed.
let Some(dest) = res.ok().and_then(|file| file.path()) else {
return;
@@ -824,26 +881,67 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &st
glib::spawn_future_local(async move {
let copied =
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
match copied {
let message = match copied {
Ok(Ok(dest)) => {
let saved = dest.file_name().map_or_else(
|| dest.display().to_string(),
|name| name.to_string_lossy().into_owned(),
);
// Translators: {name} is the saved file's name.
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
gettext_f("Saved “{name}”", &[("name", &saved)])
}
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => toast_error(
&ui,
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => failure(
&gettext("Couldn't save the copy"),
&gettext("The copy was interrupted."),
),
}
};
toasts.add_toast(
adw::Toast::builder()
.title(&message)
.use_markup(false)
.build(),
);
});
});
}
/// A failed action's toast text, worded like [`toast_error`]'s.
fn failure(what: &str, detail: &str) -> String {
tracing::warn!("{what}: {detail}");
// 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.
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
}
/// Put the photo on screen on the clipboard as an image, for pasting into a
/// chat or a document.
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let Some(texture) = viewer
.picture
.paintable()
.and_downcast::<gtk4::gdk::Texture>()
else {
return;
};
viewer.window.clipboard().set_texture(&texture);
viewer.toasts.add_toast(
adw::Toast::builder()
// Translators: toast after the photo viewer put the photo on the clipboard.
.title(gettext("Image copied"))
.use_markup(false)
.build(),
);
}
/// Open the Share dialog for the photo on screen, over the lightbox.
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
let name = viewer.name.borrow().clone().unwrap_or_default();
share_photo(ui, uid, name, Some(viewer.window.clone()));
}
/// A symlink to the cached file `path` under its Drive `name`, in a directory
/// of its own below `base`. The cache names files by content hash, with no
/// extension; the app chooser and the app it launches both need the real name
@@ -983,16 +1081,124 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
}
}
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
/// 2026, 13:42".
fn photo_title(capture_time: i64) -> String {
let title = dates::full(capture_time);
if title.is_empty() {
gettext("Unknown Date")
} else {
title
}
}
/// Set the lightbox's title (the photo's date) and the line under it: the town
/// it was taken in, when known, and its "12 of 340" counter.
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
let loaded = ui.gallery.model.n_items();
let more = ui.gallery.has_more.get();
viewer.prev.set_sensitive(index > 0);
viewer.next.set_sensitive(index + 1 < loaded || more);
viewer.title.set_label(&format_capture_time(capture_time));
viewer.title.set_label(&photo_title(capture_time));
let position = position_label(index, loaded, more, gallery_total(ui));
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
.and_then(|item| item.place_id)
.and_then(pdfs_core::places::city)
.map(|city| city.name);
let line = match place {
Some(place) => {
let args = [("place", place), ("position", position.as_str())];
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
gettext_f("{place} · {position}", &args)
}
None => position,
};
viewer.counter.set_label(&line);
sync_strip(viewer);
mark_in_strip(viewer, index);
}
/// Show the strip of thumbnails when there is more than one photo to pick
/// from. Not in a slideshow, and not over a video, where it would cover the
/// player's seek bar.
fn sync_strip(viewer: &Rc<Viewer>) {
let video = viewer.media.visible_child_name().as_deref() == Some("video");
viewer
.counter
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
.strip_reveal
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
}
/// Mark the photo at `index` in the strip and bring it into view.
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
if index >= viewer.strip_selection.n_items() {
return;
}
viewer.strip_selection.set_selected(index);
viewer
.strip
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
}
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
/// photos in its order, the one on screen marked. A click steps straight to a
/// photo; the thumbnails come through the gallery's own pipeline and cache.
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
selection.set_autoselect(false);
selection.set_can_unselect(true);
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Cover)
.width_request(56)
.height_request(56)
.can_shrink(true)
.build();
picture.add_css_class("viewer-strip-thumb");
// The arrow keys belong to the lightbox, not to the strip.
item.set_focusable(false);
item.set_child(Some(&picture));
});
let ui_bind = ui.clone();
factory.connect_bind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
return;
};
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
return;
};
let photo = boxed.borrow::<PhotoItem>().clone();
picture.set_paintable(gtk4::gdk::Paintable::NONE);
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
want_thumb(&ui_bind, &photo, &picture);
schedule_thumbs(&ui_bind);
});
let ui_unbind = ui.clone();
factory.connect_unbind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let (Some(picture), Some(boxed)) = (
item.child().and_downcast::<gtk4::Picture>(),
item.item().and_downcast::<BoxedAnyObject>(),
) else {
return;
};
let uid = boxed.borrow::<PhotoItem>().uid.clone();
ui_unbind
.gallery
.release_thumb(&uid, |waiting| waiting == &picture);
});
let list = gtk4::ListView::builder()
.model(&selection)
.factory(&factory)
.orientation(gtk4::Orientation::Horizontal)
.focusable(false)
.build();
list.add_css_class("viewer-strip");
(list, selection)
}
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
@@ -1001,16 +1207,22 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
///
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
/// button, or a click on the backdrop beside the photo all dismiss it.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
///
/// Returns the lightbox's window, for a caller that has to put the page
/// behind it back once it closes.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Window {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().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();
@@ -1035,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.hexpand(true)
.vexpand(true)
.build();
wire_player(&video, &window);
let media = gtk4::Stack::new();
media.add_named(&scroller, Some("photo"));
media.add_named(&video, Some("video"));
@@ -1119,22 +1332,21 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let slideshow_btn = gtk4::ToggleButton::builder()
.icon_name("media-playback-start-symbolic")
.tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center)
.build();
slideshow_btn.add_css_class("flat");
slideshow_btn.add_css_class("viewer-action-btn");
// Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo.
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
group_btn.set_visible(false);
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
let group_btn = gtk4::Button::builder()
.valign(gtk4::Align::Center)
.visible(false)
.build();
group_btn.add_css_class("flat");
group_btn.add_css_class("viewer-group-pill");
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
// Everything used less often than the favorite and the details sits in
// one menu, named in words rather than in icons.
let more_btn = action("view-more-symbolic", &gettext("More"));
let fullscreen_btn = fullscreen_button(&window);
fullscreen_btn.add_css_class("flat");
fullscreen_btn.add_css_class("viewer-action-btn");
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
close_btn.add_css_class("viewer-close-btn");
@@ -1144,14 +1356,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.append(&group_btn);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&slideshow_btn);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&fullscreen_btn);
top_bar.append(&more_btn);
top_bar.append(&close_btn);
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
overlay.add_overlay(&top_reveal);
let (strip, strip_selection) = build_strip(ui);
let strip_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::External)
.vscrollbar_policy(gtk4::PolicyType::Never)
.child(&strip)
.build();
strip_scroll.add_css_class("viewer-strip-bar");
let strip_reveal = fade(
strip_scroll.upcast_ref(),
gtk4::Align::Fill,
gtk4::Align::End,
);
overlay.add_overlay(&strip_reveal);
// Details panel: docked beside the image as a real surface, so opening it
// narrows the photo instead of covering part of it.
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
@@ -1205,17 +1429,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, 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 +1447,25 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, 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();
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&body));
adw_window.set_content(Some(&toasts));
let viewer = Rc::new(Viewer {
window: window.clone(),
@@ -1250,7 +1480,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, 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(),
@@ -1266,12 +1496,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
pinch_start: Cell::new(1.0),
drag_start: Cell::new((0.0, 0.0)),
pointer: Cell::new((0.0, 0.0)),
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
chrome: vec![
top_reveal.clone(),
prev_reveal.clone(),
next_reveal.clone(),
strip_reveal.clone(),
],
strip: strip.clone(),
strip_selection: strip_selection.clone(),
strip_reveal: strip_reveal.clone(),
chrome_hover: Cell::new(false),
chrome_source: RefCell::new(None),
slideshow: slideshow_btn.clone(),
more: more_btn.clone(),
menu_open: Cell::new(false),
slideshow: Cell::new(false),
slide_source: RefCell::new(None),
slide_fullscreened: Cell::new(false),
toasts,
});
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
@@ -1297,7 +1538,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
body.add_controller(motion);
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
let hover = gtk4::EventControllerMotion::new();
let viewer_enter = Rc::downgrade(&viewer);
hover.connect_enter(move |_, _, _| {
@@ -1413,13 +1654,52 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
w_close.close();
});
// A click on a thumbnail selects it; step there. Every selection the
// lightbox sets itself is the photo on screen, and the model's own changes
// (a page landing, a favorite rewritten in place) keep the selected photo
// or drop it, so a selection elsewhere can only be the user's.
let ui_strip = ui.clone();
let viewer_strip = Rc::downgrade(&viewer);
strip_selection.connect_selection_changed(move |selection, _, _| {
let Some(viewer) = viewer_strip.upgrade() else {
return;
};
let picked = selection.selected();
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
return;
};
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
}
});
let viewer_items = Rc::downgrade(&viewer);
let items_handler = ui
.gallery
.model
.connect_items_changed(move |model, _, _, _| {
let Some(viewer) = viewer_items.upgrade() else {
return;
};
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
viewer.strip_selection.set_selected(index);
}
sync_strip(&viewer);
});
// The one strong handle on the viewer. Every handler holds a weak one: a
// strong one in a handler of a widget the viewer holds is a cycle, which
// kept every closed lightbox, its window and its full-size photo in memory
// for the life of the app.
let keep = RefCell::new(Some(viewer.clone()));
let ui_close = ui.clone();
let items_handler = RefCell::new(Some(items_handler));
window.connect_close_request(move |_| {
// The gallery's model outlives the lightbox.
if let Some(handler) = items_handler.borrow_mut().take() {
ui_close.gallery.model.disconnect(handler);
}
if let Some(viewer) = keep.borrow_mut().take() {
reveal_photo(&ui_close, &viewer.uid.borrow());
stop_video(&viewer);
disarm_slide(&viewer);
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
@@ -1435,21 +1715,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, 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());
});
let ui_slides = ui.clone();
let viewer_slides = Rc::downgrade(&viewer);
slideshow_btn.connect_toggled(move |toggle| {
let Some(viewer_slides) = viewer_slides.upgrade() else {
return;
};
if toggle.is_active() {
start_slideshow(&ui_slides, &viewer_slides);
} else {
stop_slideshow(&viewer_slides);
// 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());
}
});
@@ -1458,18 +1730,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let ui_map = ui.clone();
let viewer_map = Rc::downgrade(&viewer);
let w_map = window.downgrade();
info_map.connect_clicked(move |_| {
let Some(w_map) = w_map.upgrade() else {
return;
};
let Some(viewer_map) = viewer_map.upgrade() else {
return;
};
let coords = *viewer_map.coords.borrow();
if let Some((lat, lon)) = coords {
w_map.close();
show_on_map(&ui_map, lat, lon);
if let Some(viewer_map) = viewer_map.upgrade() {
show_photo_on_map(&ui_map, &viewer_map);
}
});
@@ -1498,6 +1761,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let failed = match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
set_gallery_favorite(&ui_result, &uid, favorite);
// The tiles hold their own copy of the photo, so the star
// shows on the grid only once it is laid out again.
repaint_gallery(&ui_result);
None
}
Ok(Ok(Response::Error { message, .. })) => Some(message),
@@ -1518,24 +1784,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
});
});
// Deleting the photo on screen closes the lightbox: what it was showing is
// gone, and leaving it open on a trashed photo invites a second Delete on
// something the user cannot see.
let ui_delete = ui.clone();
let viewer_delete = Rc::downgrade(&viewer);
let w_delete = window.downgrade();
delete_btn.connect_clicked(move |_| {
let Some(w_delete) = w_delete.upgrade() else {
return;
};
let Some(viewer_delete) = viewer_delete.upgrade() else {
return;
};
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid], false);
});
let ui_group = ui.clone();
let viewer_group = Rc::downgrade(&viewer);
group_btn.connect_clicked(move |_| {
@@ -1551,38 +1799,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
let ui_download = ui.clone();
let w_download = window.downgrade();
let viewer_download = Rc::downgrade(&viewer);
download_btn.connect_clicked(move |_| {
let Some(w_download) = w_download.upgrade() else {
return;
};
let Some(viewer_download) = viewer_download.upgrade() else {
return;
};
if let Some(path) = viewer_download.path.borrow().as_deref() {
let name = viewer_download.name.borrow();
save_photo_to_disk(
&ui_download,
&w_download,
path,
name.as_deref().unwrap_or("photo"),
);
}
});
let w_ext = window.downgrade();
let viewer_ext = Rc::downgrade(&viewer);
open_ext_btn.connect_clicked(move |_| {
let Some(w_ext) = w_ext.upgrade() else {
return;
};
let Some(viewer_ext) = viewer_ext.upgrade() else {
return;
};
if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
let ui_more = ui.clone();
let viewer_more = Rc::downgrade(&viewer);
more_btn.connect_clicked(move |_| {
if let Some(viewer_more) = viewer_more.upgrade() {
open_viewer_menu(&ui_more, &viewer_more);
}
});
@@ -1660,13 +1881,22 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
return glib::Propagation::Proceed;
};
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
if playing_video
&& let Some(name) = key.name()
&& player_key(&viewer_key.video, &name, state)
{
return glib::Propagation::Stop;
}
match key.name().as_deref() {
Some("space") if playing_video => {
if let Some(stream) = viewer_key.video.media_stream() {
stream.set_playing(!stream.is_playing());
}
}
// Alt+Left is Back everywhere else in the app.
Some("Left") if alt => w_key.close(),
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
@@ -1675,30 +1905,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
Some("0" | "KP_0") => reset_zoom(&viewer_key),
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
Some("F5") => viewer_key
.slideshow
.set_active(!viewer_key.slideshow.is_active()),
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
// Escape ends a slideshow before it closes anything.
Some("Escape") if viewer_key.slideshow.is_active() => {
viewer_key.slideshow.set_active(false)
}
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
Some("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
Some("f" | "F11") => {
if w_key.is_fullscreen() {
w_key.unfullscreen();
} else {
w_key.fullscreen();
}
}
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid], false);
}
Some("f" | "F11") => toggle_fullscreen(&w_key),
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
copy_shown_photo(&ui_key, &viewer_key)
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
@@ -1706,6 +1926,113 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
window.add_controller(key_controller);
window.present();
window
}
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
/// starring it and showing its details. Built when it opens, so it offers
/// only what applies to this photo.
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let weak = Rc::downgrade(viewer);
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
let (ui, weak) = (ui.clone(), weak.clone());
move || {
if let Some(viewer) = weak.upgrade() {
run(&ui, &viewer);
}
}
};
let mut menu = ActionMenu::new();
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
if viewer.media.visible_child_name().as_deref() == Some("video") {
let video = viewer.video.clone();
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
video.set_loop(on)
});
}
menu.section();
// These need the full-size file, which may still be downloading.
if viewer.path.borrow().is_some() {
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
if still {
// Translators: menu item that puts the photo on the clipboard as an image.
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
}
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
}
if photo_shareable(ui) {
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
}
// A RAW is not on the timeline, and an album holds the shot it belongs to.
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
menu.item(
&gettext("Add to Album…"),
with_viewer(add_shown_photo_to_album, ui),
);
}
if viewer.coords.borrow().is_some() {
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
}
menu.section();
menu.item(
&gettext("Move to Trash…"),
with_viewer(trash_shown_photo, ui),
);
let button = &viewer.more;
let popover = menu.popup_at(
button,
f64::from(button.width()) / 2.0,
f64::from(button.height()),
);
viewer.menu_open.set(true);
let weak = Rc::downgrade(viewer);
popover.connect_closed(move |_| {
if let Some(viewer) = weak.upgrade() {
viewer.menu_open.set(false);
// A slideshow started from the menu keeps the controls hidden.
if !viewer.slideshow.get() {
show_chrome(&viewer);
}
}
});
}
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if let Some(path) = viewer.path.borrow().as_deref() {
let name = viewer.name.borrow();
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
}
}
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if let Some(path) = viewer.path.borrow().as_deref() {
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
}
}
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
}
/// Close the lightbox and show where the photo was taken.
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let coords = *viewer.coords.borrow();
if let Some((lat, lon)) = coords {
viewer.window.close();
show_on_map(ui, lat, lon);
}
}
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
/// closes first: what it was showing is gone, and leaving it open on a
/// trashed photo invites a second Delete on something the user cannot see.
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
let window = viewer.window.clone();
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
}
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
File diff suppressed because it is too large. Load diff
+45 -15
View File
@@ -267,7 +267,12 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
}
// A town's card may be waiting for the same photo: share its picture
// rather than take its place in the queue.
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
let waiting = ui
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
let picture = waiting.unwrap_or_else(|| {
let picture = gtk4::Picture::new();
want_thumb(ui, photo, &picture);
@@ -362,7 +367,12 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
/// Show photo's thumbnail in `picture`, sharing one another widget is
/// already waiting for.
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
let waiting = ui
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
match waiting {
Some(waiting) => {
waiting
@@ -392,21 +402,45 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
});
}
/// Open `place` and then photo `uid` in it, paging through the town until
/// the photo has loaded.
/// Open photo `uid` of `place` from the map or a spot's list, paging through
/// the town until the photo has loaded.
///
/// The lightbox steps through the gallery's model, so the town is opened
/// behind it. Closing the lightbox goes back to the map rather than leaving
/// the user in a town timeline they never asked for.
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
let id = place.id;
open_place(ui, place);
view_when_loaded(ui, uid);
view_when_loaded(ui, id, uid);
}
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
/// The open place's id, if a place is open.
fn open_place_id(ui: &Ui) -> Option<u32> {
ui.gallery.place.borrow().as_ref().map(|place| place.id)
}
fn view_when_loaded(ui: &Rc<Ui>, id: u32, uid: String) {
let ui_page = ui.clone();
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
let ui = ui_page;
// The user went elsewhere while the town loaded: whatever page lands
// now belongs to that, and must not pop the lightbox up over it.
if open_place_id(&ui) != Some(id) {
return;
}
if find_photo_index(&ui.gallery.model, &uid).is_some() {
open_photo_viewer(&ui, uid);
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
view_when_loaded(&ui, uid);
let window = open_photo_viewer(&ui, uid);
let ui_close = ui.clone();
window.connect_close_request(move |_| {
// Only if the town is still what the lightbox was showing: "Show
// on map" in the lightbox has already moved on by itself.
if open_place_id(&ui_close) == Some(id) {
show_place_grid(&ui_close);
}
glib::Propagation::Proceed
});
} else if ui.gallery.has_more.get() {
view_when_loaded(&ui, id, uid);
ui.gallery.burst.set(true);
load_gallery(&ui, true);
}
@@ -422,9 +456,7 @@ fn open_place(ui: &Rc<Ui>, 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 +471,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
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).
+14 -5
View File
@@ -279,10 +279,7 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.shared.nav.borrow_mut().pop();
load_shared(&ui_back);
});
widgets.back.connect_clicked(move |_| shared_back(&ui_back));
let ui_retry = ui.clone();
widgets
.retry
@@ -318,6 +315,14 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
ui.shared.content.set_visible_child_name("status");
}
/// Back out of a shared folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn shared_back(ui: &Rc<Ui>) {
if ui.shared.nav.borrow_mut().pop().is_some() {
load_shared(ui);
}
}
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
/// and repaint the page once all three land.
///
@@ -443,6 +448,7 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
let nav = ui.shared.nav.borrow().clone();
repaint_shared_crumb(ui, &nav);
let top = nav.is_empty();
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
*ui.shared.listed.borrow_mut() = Some(nav);
if entries.is_empty() {
ui.shared.files.model.remove_all();
@@ -467,8 +473,11 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
}
ui.shared.files_content.set_visible_child_name("list");
// Only the rows that changed are swapped, so a refresh keeps the selection
// and the scroll position.
// and the scroll position. Another folder starts from its first entry.
replace_items(&ui.shared.files.model, entries);
if another_folder {
ui.shared.files.scroll_to_top();
}
}
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
+81 -2
View File
@@ -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<RefCell<Vec<String>>>,
/// Each option's own tooltip, as given.
tooltips: Rc<[Option<String>]>,
compact: Rc<Cell<bool>>,
}
#[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::<adw::ButtonContent>() {
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<String> {
options.iter().map(|(label, _)| label.to_string()).collect()
}
/// The tooltips of `(label, tooltip)` options.
fn tooltips_of(options: &[(&str, Option<&str>)]) -> Rc<[Option<String>]> {
options
.iter()
.map(|(_, tooltip)| tooltip.map(str::to_string))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+44 -1
View File
@@ -224,6 +224,49 @@ impl FileList {
.set_visible_child_name(if list { "list" } else { "grid" });
}
/// Bring both views back to the first entry. For a listing of something
/// else: [`replace_items`] keeps the scroll position, which is right for a
/// refresh, but another folder would open where the last one was left —
/// often at its bottom.
pub(crate) fn scroll_to_top(&self) {
if self.model.n_items() == 0 {
return;
}
self.grid.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
self.column_view
.scroll_to(0, None, gtk4::ListScrollFlags::NONE, None);
}
/// How far the view on screen is scrolled, in px from the top.
pub(crate) fn scroll_offset(&self) -> f64 {
self.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
.map_or(0.0, |scroll| scroll.vadjustment().value())
}
/// Select the entries named `selected` and scroll the view on screen to
/// `offset`, as they were when the folder was left. The scroll waits for
/// an idle, when the views have measured the rows just put in.
pub(crate) fn restore(&self, offset: f64, selected: &[String]) {
self.selection.unselect_all();
for i in 0..self.selection.n_items() {
if entry_at(Some(&self.selection), i)
.is_some_and(|entry| selected.contains(&entry.name))
{
self.selection.select_item(i, false);
}
}
let Some(scroll) = self
.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
else {
return;
};
glib::idle_add_local_once(move || scroll.vadjustment().set_value(offset));
}
/// Whether the list, rather than the grid, is on screen.
pub(crate) fn shows_list(&self) -> bool {
self.views.visible_child_name().as_deref() == Some("list")
@@ -478,7 +521,7 @@ pub(crate) fn text_column(
fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::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);
}
+9 -2
View File
@@ -101,8 +101,14 @@ impl ActionMenu {
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
/// popover removes itself once dismissed; it waits for an idle turn, so the
/// item that closed it still finds its action.
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
/// item that closed it still finds its action. Returned for a caller that
/// has to know when it closes.
pub(crate) fn popup_at(
self,
anchor: &impl IsA<gtk4::Widget>,
x: f64,
y: f64,
) -> gtk4::PopoverMenu {
let (menu, group) = self.finish();
let anchor = anchor.as_ref();
let host = popover_host(anchor);
@@ -122,6 +128,7 @@ impl ActionMenu {
glib::idle_add_local_once(move || p.unparent());
});
popover.popup();
popover
}
/// A flat ⋮ button that opens this menu.
+2
View File
@@ -3,6 +3,8 @@ pub(crate) mod crumb;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
pub(crate) mod photo_drag;
pub(crate) mod player;
pub(crate) mod scrubber;
pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
@@ -0,0 +1,237 @@
//! Dragging photos out of the timeline into a file manager or another app.
//!
//! A photo is not a file anywhere the receiver can read until it has been
//! downloaded, and a drag has to start the moment the pointer moves. So the
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
//! the photos only when the receiver asks for the list, which it does once the
//! photos are dropped on it, and then hands over copies under their own names.
use std::future::Future;
use std::pin::Pin;
use std::time::{Duration, SystemTime};
use gtk4::gdk;
use gtk4::gdk::subclass::prelude::*;
use crate::*;
/// The one format a drag of photos offers. GTK turns it into a file list for
/// a receiver in this app, and every file manager reads it.
const URI_LIST: &str = "text/uri-list";
/// How long the copies handed to a receiver are kept. The receiver copies
/// them on drop, so a day is far more than it needs.
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
mod imp {
use super::*;
#[derive(Default)]
pub struct PhotoDrag {
/// The photos dragged, as uid and Drive name.
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
pub(super) socket: RefCell<PathBuf>,
}
#[glib::object_subclass]
impl ObjectSubclass for PhotoDrag {
const NAME: &'static str = "PdfsPhotoDrag";
type Type = super::PhotoDrag;
type ParentType = gdk::ContentProvider;
}
impl ObjectImpl for PhotoDrag {}
impl ContentProviderImpl for PhotoDrag {
fn formats(&self) -> gdk::ContentFormats {
gdk::ContentFormats::new(&[URI_LIST])
}
fn write_mime_type_future(
&self,
mime_type: &str,
stream: &gio::OutputStream,
io_priority: glib::Priority,
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
let photos = self.photos.borrow().clone();
let socket = self.socket.borrow().clone();
let stream = stream.clone();
let wanted = mime_type == URI_LIST;
Box::pin(async move {
if !wanted {
return Err(glib::Error::new(
gio::IOErrorEnum::NotSupported,
"photos are offered as a list of files only",
));
}
let files = fetch_dragged(socket, photos).await?;
let list: String = files
.iter()
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
.collect();
stream
.write_all_future(list.into_bytes(), io_priority)
.await
.map_err(|(_, e)| e)?;
Ok(())
})
}
}
}
glib::wrapper! {
/// The content of a drag of photos: their files, fetched when dropped.
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
@extends gdk::ContentProvider;
}
impl PhotoDrag {
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
let drag: Self = glib::Object::new();
*drag.imp().photos.borrow_mut() = photos;
*drag.imp().socket.borrow_mut() = socket;
drag
}
}
/// Download each dragged photo and put a copy under its Drive name where the
/// receiver can read it. A photo that fails is left out; only when every one
/// fails does the drop fail.
async fn fetch_dragged(
socket: PathBuf,
photos: Vec<(String, Option<String>)>,
) -> Result<Vec<PathBuf>, glib::Error> {
let base = glib::user_cache_dir().join("pdfs-drag");
let mut files = Vec::new();
let mut last_error = None;
for (uid, name) in photos {
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
let source = match rx.recv().await {
Ok(Ok(Response::FilePath { path })) => path,
Ok(Ok(Response::Error { message, .. })) => {
last_error = Some(message);
continue;
}
_ => {
last_error = Some("the Proton Drive service didn't respond".to_string());
continue;
}
};
let base = base.clone();
let copied = gio::spawn_blocking(move || {
prune_dragged(&base, SystemTime::now());
let name = name.unwrap_or_else(|| "photo".to_string());
hand_over(&base, Path::new(&source), &uid, &name)
})
.await;
match copied {
Ok(Ok(path)) => files.push(path),
Ok(Err(e)) => last_error = Some(e.to_string()),
Err(_) => last_error = Some("the copy was interrupted".to_string()),
}
}
if files.is_empty() {
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
tracing::warn!("dragging photos out failed: {message}");
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
}
Ok(files)
}
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
/// copies it from.
///
/// A hard link when the cache is on the same file system, so a large video is
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
/// symlink as a link, which would leave the user a link into the cache.
pub(crate) fn hand_over(
base: &Path,
source: &Path,
uid: &str,
name: &str,
) -> std::io::Result<PathBuf> {
let file_name = Path::new(name)
.file_name()
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
let dir = base.join(uid.replace('/', "_"));
std::fs::create_dir_all(&dir)?;
let dest = dir.join(file_name);
let _ = std::fs::remove_file(&dest);
if std::fs::hard_link(source, &dest).is_err() {
std::fs::copy(source, &dest)?;
}
Ok(dest)
}
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let old = entry
.metadata()
.and_then(|meta| meta.modified())
.is_ok_and(|modified| {
now.duration_since(modified)
.is_ok_and(|age| age > KEEP_DRAGGED)
});
if old {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_dragged_photo_is_handed_over_under_its_own_name() {
let dir = scratch("name");
let source = dir.join("0f3a9c");
std::fs::write(&source, b"jpeg bytes").unwrap();
let base = dir.join("out");
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
assert!(!path.is_symlink());
// Dragged again, it replaces the copy rather than failing on it.
std::fs::write(&source, b"new bytes").unwrap();
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_name_cannot_climb_out_of_its_folder() {
let dir = scratch("climb");
let source = dir.join("blob");
std::fs::write(&source, b"x").unwrap();
let base = dir.join("out");
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
assert_eq!(path, base.join("u").join("escape.jpg"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_old_handovers_are_pruned() {
let dir = scratch("prune");
std::fs::create_dir_all(dir.join("fresh")).unwrap();
prune_dragged(&dir, SystemTime::now());
assert!(dir.join("fresh").exists());
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
assert!(!dir.join("fresh").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Playing videos and music inside the app.
//!
//! The pieces both players share live here: the one My files opens a video or
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
//! fullscreen, the keys every video player answers to, a repeat switch and a
//! volume that carries over from one video to the next.
use crate::activation::{mounted_path, present_within};
use crate::*;
/// How far `J` and `L` (or Shift+←/→) jump.
const SEEK_LONG: i64 = 10;
/// How far ← and → jump in the My files player, where they don't move to
/// another photo.
const SEEK_SHORT: i64 = 5;
/// How much ↑ and ↓ change the volume in the My files player.
const VOLUME_STEP: f64 = 0.1;
thread_local! {
/// The volume and mute the user last set, applied to the next stream so
/// muting one video doesn't leave the next one blaring. Kept for the run
/// of the app only.
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
}
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
/// means the stream doesn't know its length yet, which bounds only the start.
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
let target = now
.saturating_add(delta_secs.saturating_mul(1_000_000))
.max(0);
if duration > 0 {
target.min(duration)
} else {
target
}
}
/// Jump the stream `delta_secs` forward, or back when negative.
pub(crate) fn seek_by(video: &gtk4::Video, delta_secs: i64) {
if let Some(stream) = video.media_stream()
&& stream.is_seekable()
{
stream.seek(seek_target(
stream.timestamp(),
stream.duration(),
delta_secs,
));
}
}
/// Play a paused stream, pause a playing one.
pub(crate) fn toggle_playing(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_playing(!stream.is_playing());
}
}
fn toggle_muted(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_muted(!stream.is_muted());
}
}
fn change_volume(video: &gtk4::Video, delta: f64) {
if let Some(stream) = video.media_stream() {
stream.set_muted(false);
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
}
}
/// Enter fullscreen, or leave it.
pub(crate) fn toggle_fullscreen(window: &gtk4::Window) {
if window.is_fullscreen() {
window.unfullscreen();
} else {
window.fullscreen();
}
}
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
/// was one of them.
pub(crate) fn player_key(video: &gtk4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
let plain = !state
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
if !plain {
return false;
}
match key {
"k" | "K" => toggle_playing(video),
"j" | "J" => seek_by(video, -SEEK_LONG),
"l" | "L" => seek_by(video, SEEK_LONG),
"Left" if shift => seek_by(video, -SEEK_LONG),
"Right" if shift => seek_by(video, SEEK_LONG),
"m" | "M" => toggle_muted(video),
_ => return false,
}
true
}
/// Make `video` carry the volume over between streams and toggle `window`'s
/// fullscreen on a double-click.
pub(crate) fn wire_player(video: &gtk4::Video, window: &gtk4::Window) {
video.connect_media_stream_notify(|video| {
let Some(stream) = video.media_stream() else {
return;
};
if let Some((volume, muted)) = LEVEL.get() {
stream.set_volume(volume);
stream.set_muted(muted);
}
let remember = |stream: &gtk4::MediaStream| {
LEVEL.set(Some((stream.volume(), stream.is_muted())));
};
stream.connect_volume_notify(remember);
stream.connect_muted_notify(remember);
});
// The controls' own buttons and seek bar claim their clicks, so this only
// sees a double-click on the picture.
let click = gtk4::GestureClick::new();
let window = window.downgrade();
click.connect_pressed(move |_, n_press, _, _| {
if n_press == 2
&& let Some(window) = window.upgrade()
{
toggle_fullscreen(&window);
}
});
video.add_controller(click);
}
/// A button that enters and leaves `window`'s fullscreen, showing which one
/// it will do.
pub(crate) fn fullscreen_button(window: &gtk4::Window) -> gtk4::Button {
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
let paint = |button: &gtk4::Button, fullscreen: bool| {
if fullscreen {
button.set_icon_name("view-restore-symbolic");
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
} else {
button.set_icon_name("view-fullscreen-symbolic");
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
}
};
paint(&button, window.is_fullscreen());
let weak = button.downgrade();
window.connect_fullscreened_notify(move |window| {
if let Some(button) = weak.upgrade() {
paint(&button, window.is_fullscreen());
}
});
let target = window.downgrade();
button.connect_clicked(move |_| {
if let Some(window) = target.upgrade() {
toggle_fullscreen(&window);
}
});
button
}
/// Play a video or a song from My files in a window of its own, streamed from
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
/// long video starts in seconds instead of after a full download. A file the
/// mount doesn't show, or the service is down, opens in the default
/// application instead, after a download.
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let (ui, entry) = (ui.clone(), entry.clone());
glib::spawn_future_local(async move {
match present_within(mounted_path(&mountpoint, &rel)).await {
Some(path) => build_media_player(&ui, &entry, &path),
None => download_and_open(&ui, &entry),
}
});
}
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
let window = adw::Window::builder()
.title(&entry.name)
.default_width(960)
.default_height(600)
.build();
if let Some(parent) = ui_window(ui) {
window.set_transient_for(Some(&parent));
}
window.add_css_class("media-player-window");
let gtk_window: gtk4::Window = window.clone().upcast();
let video = gtk4::Video::builder()
.autoplay(true)
.hexpand(true)
.vexpand(true)
.build();
wire_player(&video, &gtk_window);
// A song has no picture, which would leave a black window above the
// controls: a note stands in for it.
let note = gtk4::Image::builder()
.icon_name("audio-x-generic-symbolic")
.pixel_size(128)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.can_target(false)
.visible(false)
.build();
note.add_css_class("media-player-note");
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&video));
overlay.add_overlay(&note);
let failed = adw::StatusPage::builder()
.icon_name("dialog-warning-symbolic")
.title(gettext("Couldn't play this file"))
.build();
let open_with = gtk4::Button::builder()
.label(gettext("Open With…"))
.halign(gtk4::Align::Center)
.build();
open_with.add_css_class("pill");
open_with.add_css_class("suggested-action");
failed.set_child(Some(&open_with));
let stack = gtk4::Stack::new();
stack.add_named(&overlay, Some("player"));
stack.add_named(&failed, Some("failed"));
let header = adw::HeaderBar::new();
header.pack_end(&fullscreen_button(&gtk_window));
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Open With…"), move || {
open_entry_with(&ui_c, &entry_c)
});
let video_c = video.clone();
// Translators: menu item of the video player that plays the file again
// and again.
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
header.pack_end(&menu.button());
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
// Fullscreen is the video alone, its own controls still at hand.
window
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
.invert_boolean()
.sync_create()
.build();
window.set_content(Some(&toolbar));
let (ui_c, entry_c) = (ui.clone(), entry.clone());
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
video.connect_media_stream_notify(move |video| {
let Some(stream) = video.media_stream() else {
return;
};
let (stack, failed) = (stack_c.clone(), failed_c.clone());
stream.connect_error_notify(move |stream| {
if let Some(error) = stream.error() {
tracing::warn!("in-app player failed: {error}");
failed.set_description(Some(&error.to_string()));
stack.set_visible_child_name("failed");
}
});
let note = note_c.clone();
stream.connect_prepared_notify(move |stream| {
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
});
});
video.set_filename(Some(path));
let keys = gtk4::EventControllerKey::new();
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
keys.connect_key_pressed(move |_, key, _code, state| {
let Some(window) = window_k.upgrade() else {
return glib::Propagation::Proceed;
};
let Some(name) = key.name() else {
return glib::Propagation::Proceed;
};
// The open menu keeps its arrows and Enter.
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
if in_menu {
return glib::Propagation::Proceed;
}
if player_key(&video_k, &name, state) {
return glib::Propagation::Stop;
}
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
match name.as_str() {
"space" => toggle_playing(&video_k),
"Left" => seek_by(&video_k, -SEEK_SHORT),
"Right" => seek_by(&video_k, SEEK_SHORT),
"Up" => change_volume(&video_k, VOLUME_STEP),
"Down" => change_volume(&video_k, -VOLUME_STEP),
"f" | "F11" => toggle_fullscreen(&window),
// Escape leaves fullscreen before it closes anything.
"Escape" if window.is_fullscreen() => window.unfullscreen(),
"Escape" | "q" => window.close(),
"w" if ctrl => window.close(),
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
// Capture, so the arrows and Space reach the player rather than moving the
// focus between its controls.
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
window.add_controller(keys);
// Stop the sound with the window, not when the window is finally freed.
let video_c = video.clone();
window.connect_close_request(move |_| {
video_c.set_media_stream(gtk4::MediaStream::NONE);
glib::Propagation::Proceed
});
window.present();
}
#[cfg(test)]
mod tests {
use super::*;
const SEC: i64 = 1_000_000;
#[test]
fn a_jump_stays_inside_the_video() {
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
}
#[test]
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
}
}
@@ -334,6 +334,13 @@ pub(crate) struct ShareDialog {
/// Open the per-node Share dialog: invite Proton/external users, manage who has
/// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
open_share_dialog_over(ui, entry, None);
}
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
/// viewer is a window of its own, and a dialog on the main window would open
/// behind it.
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
if !*ui.mounted.borrow() {
toast_error(
ui,
@@ -491,7 +498,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
}
});
share_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
}
/// Re-fetch the node's share and rebuild the people + public-link sections.
+48 -10
View File
@@ -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: &gtk4::Picture) -> Option<gtk4::Image> {
picture
.next_sibling()
.and_downcast::<gtk4::Image>()
.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: &gtk4::Picture, key: &str, texture: &gtk4::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::<gtk4::Overlay>()
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
{
@@ -413,7 +447,11 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[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() {
+2 -2
View File
@@ -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));
+18 -5
View File
@@ -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: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() 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: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, 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,
+12 -4
View File
@@ -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()) {
+1 -1
View File
@@ -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"
+6
View File
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
// GTK's built-in icons
"info-outline-symbolic",
// Adwaita
"audio-x-generic-symbolic",
"checkbox-checked-symbolic",
"checkbox-symbolic",
"computer-symbolic",
@@ -52,6 +53,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,16 +63,20 @@ 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-fullscreen-symbolic",
"view-grid-symbolic",
"view-list-symbolic",
"view-more-symbolic",
"view-paged-symbolic",
"view-pin-symbolic",
"view-refresh-symbolic",
"view-restore-symbolic",
"window-close-symbolic",
"zoom-fit-best-symbolic",
"zoom-in-symbolic",
+73
View File
@@ -109,6 +109,8 @@ a live account since.
| Entry | Title |
|---|---|
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
@@ -385,6 +387,77 @@ a live account since.
Newest first.
### B284 — A keyring entry reported missing discards refreshed session tokens
**Status:** Fixed (unverified)
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
The running session can still work, but restarting it exposes the stale saved credentials.
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
Persistence still requires the matching existing blob; a retry never recreates a deleted
session or overwrites a new login. This does not make a locked keyring writable, and pending
tokens still need to reach it before power-off to survive a cold boot.
**Verified:** The two regression tests
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
has not been reproduced; issue #78's precise trigger remains unconfirmed.
---
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
**Status:** Fixed, not verified live
**Found:** 2026-11-14, from a user report: large `.ts` video recordings in My files showed a text
icon, and no video in My files had a thumbnail although the same videos had one in Photos.
**Where:** `is_video_file_name` in `crates/pdfs-core/src/control.rs`; `generate_thumbs` and
`sample_video` in `crates/pdfs-fuse/src/photos.rs`; `bind_file_thumbnail` in
`crates/pdfs-gui/src/app/widgets/thumbnails.rs`
**Repro:** Put an `.mp4` and a recorded `.ts` of a few GB in My files and open the folder in the
app's grid view. Both show a generic icon, and the `.ts` shows a text one.
**Cause.** Four things:
- File views asked the daemon for thumbnails of images only (`is_thumbnail_image_name`), so a
video was never asked for.
- `.ts` is TypeScript as often as it is an MPEG transport stream. Drive stores both as
`application/octet-stream`, and every place that names a file's type went by the extension:
the browser's icon list had no `.ts`, GIO guesses text, and the opener's `@text` class listed
`.ts`.
- The daemon downloaded a video whole into memory for one frame, so it refused anything over
256 MiB, and the size check behind that refusal asked the Photos client, which knows nothing
of a My files node.
- `file_thumbs` never asked for the thumbnail an uploader attached, on the belief that only
Photos nodes have one. This client attaches one to every upload it can make one for.
**Fix.** `is_video_file_name(name, size)` is the one rule: a video extension, and for `.ts` a
size of at least 1 MiB. The browser icon, the thumbnail gate, the launcher's icon and type, and
the opener's classes use it. For a file view, the daemon first asks Drive for the file's own
thumbnail. Without one, a video of up to 24 MiB is downloaded whole. A larger one brings its first
16 MiB, and for an MP4 or MOV whose `moov` box lies further in, the box headers on the way and
that box. The parts are laid out at their offsets in a sparse staging file, which ffmpeg reads.
A `.ts` whose head lacks the 0x47 sync byte is recorded as having no thumbnail.
**Test:** `a_ts_file_is_a_video_only_at_the_size_of_one` and
`listings_thumbnail_images_and_videos` in `crates/pdfs-core/src/control.rs`;
`a_ts_file_opens_as_media_or_text_by_its_size` in `crates/pdfs-core/src/opener.rs`;
`a_large_mp4_downloads_its_head_and_its_index` and the other `sample_video` tests in
`crates/pdfs-fuse/src/photos.rs`; the simulation
`a_video_thumbnail_downloads_a_small_part_of_the_video`, which reads under 34 MiB for two
videos of over 70 MiB and takes nothing from a video with an uploader's thumbnail.
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
index at the end, an MPEG-TS and an MKV.
### B282 — A file kept offline is downloaded again after a change made elsewhere
**Status:** Fixed, not verified live
+111 -1
View File
@@ -11,7 +11,7 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
## [3.8.0] — 2026-11-12
## [3.8.0] — 2026-11-14
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
Existing folders stay device backups. Older builds cannot open the upgraded database.
@@ -39,8 +39,83 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
index, and never downloads more than about 16 MiB of a video for it, however large the
video is. **Build Thumbnails** covers videos too (B283).
- **A new layout for Photos.**
- The grid shows every photo at its own shape, in rows that fill the width (portraits
are no longer cropped to squares). **Square Tiles** in the page menu brings the even
grid back, and the Year zoom stays square.
- Timeline, Albums and Places switch in the header. The filters take one row, which
scrolls sideways in a narrow window.
- The section you are in floats over the top of the grid once its heading has scrolled
away. Headings are larger, and a check beside each one selects the whole day, month or
year.
- Selection mode works as in other GNOME apps: the header shows the count and
**Cancel**, and a bar along the bottom has these actions:
- **Add to Favorites**, or remove them when every selected photo is already one;
- **Add to Album**;
- **Remove from Album**;
- **Save Copies**, into a folder you pick, never overwriting a file;
- **Move to Trash**.
- Favorites carry a star on their tile, and a video waiting for its thumbnail shows a
video glyph.
- New icons for Photos, albums and the slideshow.
- **Upload** takes several photos and videos at once, and you can drop files onto the
timeline to upload them.
- The zoom level and the tile shape are remembered between runs.
- In the photo viewer, the details panel lies over the photo when the window is
narrow instead of squeezing it.
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
change it too, and the size is remembered between runs.
- **Back takes you to where you were.** Back, Forward and Up in My files return to a folder
scrolled where it was left, with the same items selected, and closing the photo viewer scrolls
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
and Computers and leave an open album or place in Photos, as Escape does too.
- **Share photos, and more in the photo viewer.**
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
people or make a public link. It is not offered for a photo in an album someone else shared.
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
screen; click one to jump to it. It fades with the other controls and stays hidden during a
slideshow.
- The line under the date names the town the photo was taken in, when it is known.
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
or any app that takes files. The photos download when dropped and arrive under their own
names. A drag that starts on a photo that is not selected still selects, as before.
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
items…" with a progress bar above the timeline, instead of a single toast at the start.
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
still hands it to another player, and the separate **Play** item is gone. When the file can't
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
thumbnail strip in Photos stays off a video, where it covered the seek bar.
### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
from the SDK, and a photo keeps its own modification time now that it no longer follows the
capture time. [The SDK review](SDK-0.11.0.md) lists the new features and breaking changes.
### Fixed
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
don't fill the window, also after a zoom or a resize.
- **My files comes back after the service was killed for hanging** (B279). A killed service left
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
forever without opening anything, until the mounts were cleared by hand. The service now clears
@@ -66,6 +141,41 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
version is downloaded, which happens on its own. Files kept offline are also checked at
startup and when the connection returns, and failed downloads are tried again.
- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the
text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail
and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays
TypeScript source, and the daemon checks the stream's sync byte before it takes a frame.
- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
downloaded whole into memory for one frame.
- **A folder opens at the top.** A folder opened in My files, Shared with me or Computers could
open scrolled to where its parent was left, often at the bottom. Another folder, a new search
and a new Photos filter, album or place now start at the top, and a refresh keeps its place.
- **Closing a photo opened from the Places map goes back to the map**, not to the town's
timeline.
- **Albums and Places stay on screen.** They could switch to the timeline by themselves when a
timeline page arrived late, after an upload or after a trash Undo. Coming back to Photos no
longer reloads the timeline and loses its scroll position, unless photos changed while it was
out of sight. After an album or a place, the timeline came back unfiltered while the kind tab
and the date still showed the filter.
- **A search in My files stays.** It was replaced by the folder's listing whenever files
changed, on refresh and on coming back to the page, while the query stayed in the box.
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
the grid, and Alt+Left closes the viewer.
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
kept going after another filter, album or place was opened.
## [3.7.0] — 2026-11-02
Schema: **44**, unchanged.
+91
View File
@@ -0,0 +1,91 @@
# Issue #78: session loss on Fedora 45
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
## What is established
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
is present in that release. In an isolated test, three successive binary `SetSecret` updates
survived reopening the keyring file. Repeating the original persistence explanation is therefore
insufficient.
A different, reproducible failure exists at the boundary between oo7 and pdfs:
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
is reported.
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
logout, cleared the pending rotation and returned success. It skipped the error path that
retains the new tokens in the kernel keyring and retries their persistence. The code was
unchanged between v3.7.0 and this investigation.
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
running process can keep working with the new tokens in memory; a restart then exposes the
stale saved credentials. A regression test failed before the fix and passes afterward.
The same unsafe assumption also applies to transient empty lookups such as the race reported in
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
replacement race was not independently reproduced here; pdfs normally updates existing items
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
## Change and limits
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
session is never recreated: flushing still requires a readable blob with the matching session
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
entry is unreadable.
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
or credential format is introduced.
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
can resolve the default alias to a locked collection. Both have fixes in
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
matching runtime evidence.
## Reproduce without a Proton account or desktop keyring
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
From the pdfs repository, choose an unused temporary checkout path:
```sh
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
```
Observed output:
```text
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
```
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
`a_temporarily_missing_session_does_not_discard_a_rotation` and
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
daemon's package version rather than just the CLI version. The investigation did not use a real
Proton account, alter a desktop keyring, or post to either GitHub issue.
+60
View File
@@ -0,0 +1,60 @@
# SDK 0.11.0 review
Reviewed the sibling `../proton-sdk-rs` checkout at `84039a1` against 0.10.1
(`48dd410`), including its changelog and source diff.
## Features
- Folder creation, streaming uploads, draft replacement, and revision uploads
preserve the supplied timestamp's UTC offset and fractional seconds.
- `ModificationTime` is exported as an alias for
`chrono::DateTime<chrono::FixedOffset>`.
- Photo modification time can be set independently of capture time. Downloaded
claimed modification timestamps remain verbatim strings.
- Neither SDK crate defines Cargo feature flags. No feature opt-in is needed.
The Drive crate now depends directly on `chrono` 0.4; the SDK already used it.
The minimum Rust version remains 1.96, matching this workspace.
## Breaking changes and adaptation
The modification-time argument changes from `Option<i64>` to
`Option<ModificationTime>` on `create_folder`, `upload_file_from`,
`upload_file_replacing_draft_from`, `upload_new_revision`, and
`upload_new_revision_from`. `None` remains valid.
The real `DriveApi` adapter converts the client's stored epoch seconds to UTC
through `pdfs_core::timestamp::modification_time`. Internal storage, the trait,
and the simulated Drive continue using epoch seconds. Out-of-range timestamps
are omitted rather than panicking. Existing upload paths still supply whole
seconds; this bump alone does not add subsecond storage or offset tracking.
`PhotoUploadMetadata` gains `last_modification_time`, and the SDK no longer
copies `capture_time` into it. Our literals already use `Default`, so this would
compile while silently changing uploaded metadata. Control uploads, Takeout
imports (including the EXIF fallback), and redating now set both explicitly to
preserve the previous behavior when a capture time is known. Without a known
capture time, modification time stays omitted as before.
No other public API changes were found in the reviewed diff. No database or
recovery migration is required by this SDK bump.
## Dependencies
The workspace requires the published 0.11.0 versions of both crates from
crates.io. No local patches or sibling checkout are needed to build it.
## Verification
The published crates' source files match the reviewed sibling checkout at
`84039a1`. On the final branch based on `main`, these checks passed:
```sh
cargo test --workspace --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
```
The test suite reported 1,135 passed and 67 ignored; ignored tests were not run.
Tests ran outside the sandbox because control-socket tests require socket
permissions. No live account or mounted-filesystem acceptance testing was
performed.
+53
View File
@@ -0,0 +1,53 @@
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
index 99c25dc..2ba705d 100644
--- a/server/src/item/tests.rs
+++ b/server/src/item/tests.rs
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
use crate::{service::PrompterType, tests::TestServiceSetup};
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
+// no desktop keyring or Proton account is used.
+#[tokio::test]
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
+ let setup =
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
+ .await?;
+ let collection = setup.default_collection().await?;
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
+ collection
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
+ .await?;
+
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
+ setup.lock_collection(collection).await?;
+ assert!(collection.is_locked().await?);
+
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
+ // This is the problematic reply, not the Secret Service behavior expected
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
+
+ setup.unlock_collection(collection).await?;
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
+
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
+ // the disk keyring after each update, rather than trusting a memory read.
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
+ unlocked[0].set_secret(&secret).await?;
+ let items = setup.login_keyring().await?.items().await?;
+ assert_eq!(items[0].secret().as_bytes(), refresh);
+ }
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
+ Ok(())
+}
+
#[tokio::test]
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
let setup = TestServiceSetup::plain_session(true).await?;
+1
View File
@@ -24,6 +24,7 @@ crates/pdfs-gui/src/app/widgets/compat.rs
crates/pdfs-gui/src/app/widgets/details.rs
crates/pdfs-gui/src/app/widgets/file_list.rs
crates/pdfs-gui/src/app/widgets/menu.rs
crates/pdfs-gui/src/app/widgets/player.rs
crates/pdfs-gui/src/app/widgets/share_dialog.rs
crates/pdfs-gui/src/app/widgets/thumbnails.rs
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
+1252 -1067
View File
File diff suppressed because it is too large. Load diff
+1248 -1063
View File
File diff suppressed because it is too large. Load diff
+1251 -1066
View File
File diff suppressed because it is too large. Load diff
+1252 -1067
View File
File diff suppressed because it is too large. Load diff
+1250 -1067
View File
File diff suppressed because it is too large. Load diff
+1252 -1067
View File
File diff suppressed because it is too large. Load diff
+1222 -1064
View File
File diff suppressed because it is too large. Load diff
+1267 -1075
View File
File diff suppressed because it is too large. Load diff
+1251 -1066
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -91,7 +91,7 @@ SECTIONS = [
]
# Too long or too internal for the wiki; links to them go to GitHub.
EXCLUDED = {"BUGS.md", "CHANGELOG.md"}
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md"}
# Files outside docs/ whose links are checked but not published.
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]