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
20 changed files with 4856 additions and 3749 deletions

No files matched your search

+2
View File
@@ -61,6 +61,8 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
/* Photo viewer: always light-on-dark, whatever the system style */
.photo-viewer-window { background-color: #111014; }
.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); }
+20 -1
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::*;
@@ -525,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(),
@@ -1517,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"),
&[
@@ -1567,6 +1570,15 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("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")),
],
),
];
let groups: Vec<_> = GROUPS
.iter()
@@ -2036,6 +2048,13 @@ 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]
+9 -51
View File
@@ -898,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
@@ -1107,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 {
@@ -1151,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
+33 -17
View File
@@ -279,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);
@@ -356,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());
}
@@ -396,7 +398,7 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
viewer.slide_fullscreened.set(true);
}
hide_chrome(viewer);
viewer.strip_reveal.set_visible(false);
sync_strip(viewer);
if !viewer.loading.get() {
arm_slide(ui, viewer);
}
@@ -405,9 +407,7 @@ 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);
viewer
.strip_reveal
.set_visible(viewer.strip_selection.n_items() > 1);
sync_strip(viewer);
disarm_slide(viewer);
if viewer.slide_fullscreened.replace(false) {
viewer.window.unfullscreen();
@@ -1114,10 +1114,18 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
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
.strip_reveal
.set_visible(loaded > 1 && !viewer.slideshow.get());
mark_in_strip(viewer, index);
.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.
@@ -1239,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
.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"));
@@ -1335,6 +1344,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
// 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");
@@ -1344,6 +1356,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
top_bar.append(&group_btn);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
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);
@@ -1660,7 +1673,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
}
});
let viewer_items = Rc::downgrade(&viewer);
let ui_items = ui.clone();
let items_handler = ui
.gallery
.model
@@ -1671,9 +1683,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
viewer.strip_selection.set_selected(index);
}
viewer
.strip_reveal
.set_visible(ui_items.gallery.model.n_items() > 1 && !viewer.slideshow.get());
sync_strip(&viewer);
});
// The one strong handle on the viewer. Every handler holds a weak one: a
@@ -1873,6 +1883,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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() {
@@ -1896,13 +1912,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
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("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(),
@@ -1934,6 +1944,12 @@ fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<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() {
+185 -26
View File
@@ -37,6 +37,9 @@ pub(crate) struct GalleryState {
pub(crate) burst: Cell<bool>,
/// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner,
/// The bar that counts uploads from this computer through, under the
/// header while any run.
pub(crate) upload_bar: UploadBar,
pub(crate) upload: adw::SplitButton,
/// Upload / Import offered on the *empty timeline* status page, so a fresh
/// account is a place to start rather than a dead end. Hidden on every other
@@ -478,6 +481,9 @@ pub(crate) struct GalleryWidgets {
pub(crate) scrubber: Scrubber,
/// Says a Google Photos import is running, with a way to its page.
pub(crate) import_banner: adw::Banner,
/// The bar that counts uploads from this computer through, under the
/// header while any run.
pub(crate) upload_bar: UploadBar,
pub(crate) list: gtk4::ListView,
pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button,
@@ -903,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.action_name("win.show-import")
.build();
let upload_bar = UploadBar::new();
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
@@ -913,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&import_banner);
body.append(&upload_bar.revealer);
body.append(&inner);
// The title slot holds the view switch on the page itself, the album's or
// place's name inside one, and the count in selection mode (see
@@ -943,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
pager,
scrubber,
import_banner,
upload_bar,
list,
scroll,
retry,
@@ -1384,6 +1394,13 @@ pub(crate) fn wire_gallery(
sync_float_heading(&ui_scroll);
ui_scroll.gallery.scrubber.flash();
});
// Zooming out or making the window taller can leave every loaded photo on
// one screen. Nothing scrolls then, so the scrolling above never asks for
// the next page: ask whenever the content shrinks under the viewport.
let ui_fill = ui.clone();
scroll
.vadjustment()
.connect_changed(move |_| fill_viewport(&ui_fill));
wire_scrubber(ui);
wire_drag_select(ui);
@@ -1513,9 +1530,11 @@ pub(crate) fn wire_gallery(
gtk4::gdk::DragAction::COPY,
);
let ui_drop = ui.clone();
drop.connect_accept(move |_, _| {
drop.connect_accept(move |_, drop| {
let gallery = &ui_drop.gallery;
!gallery.in_collection()
// Photos dragged out of the timeline are already in it.
drop.drag().is_none()
&& !gallery.in_collection()
&& gallery.view_switch.active() == VIEW_TIMELINE
&& gallery.library.get()
&& *ui_drop.mounted.borrow()
@@ -1913,6 +1932,86 @@ pub(crate) fn copy_without_overwrite(
unreachable!("some numbered name is free")
}
/// How far the uploads from this computer have come: a count and a bar, so a
/// drop of a few hundred photos shows it is moving. Batches started while one
/// runs join its count.
#[derive(Clone)]
pub(crate) struct UploadBar {
pub(crate) revealer: gtk4::Revealer,
label: gtk4::Label,
progress: gtk4::ProgressBar,
/// Uploads finished, and uploads asked for, since the bar last emptied.
count: Rc<Cell<(usize, usize)>>,
}
impl UploadBar {
fn new() -> Self {
let label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let progress = gtk4::ProgressBar::builder().hexpand(true).build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
content.set_margin_top(10);
content.set_margin_bottom(4);
content.set_margin_start(16);
content.set_margin_end(16);
content.append(&label);
content.append(&progress);
let revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideDown)
.child(&content)
.build();
Self {
revealer,
label,
progress,
count: Rc::default(),
}
}
/// Count `n` more uploads in.
pub(crate) fn add(&self, n: usize) {
let (done, total) = self.count.get();
self.count.set((done, total + n));
self.paint();
}
/// Count one upload as finished, whether or not it went through.
pub(crate) fn finish(&self) {
let (done, total) = self.count.get();
let done = (done + 1).min(total);
if done == total {
self.count.set((0, 0));
self.revealer.set_reveal_child(false);
return;
}
self.count.set((done, total));
self.paint();
}
fn paint(&self) {
let (done, total) = self.count.get();
if total == 0 {
return;
}
self.label.set_label(&upload_progress_text(done, total));
self.progress.set_fraction(done as f64 / total as f64);
self.revealer.set_reveal_child(true);
}
}
/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`.
pub(crate) fn upload_progress_text(done: usize, total: usize) -> String {
// Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are.
ngettext_f(
"Uploading {position} of {n} item…",
"Uploading {position} of {n} items…",
total as u64,
&[("position", &thousands(done + 1))],
)
}
/// Upload photos and videos from this computer into the timeline, one after
/// the other, then reload it once.
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
@@ -1927,15 +2026,7 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
let total = uploads.len();
ui.busy_begin();
if total > 1 {
toast(
ui,
&ngettext_f(
"Uploading {n} item…",
"Uploading {n} items…",
total as u64,
&[],
),
);
ui.gallery.upload_bar.add(total);
}
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -1973,6 +2064,9 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
))
}
}
if total > 1 {
ui.gallery.upload_bar.finish();
}
}
ui.busy_end();
if uploaded > 0 {
@@ -2381,6 +2475,23 @@ impl TileWidget {
});
button.add_controller(context);
// A picked photo drags out, with the rest of the selection, into a
// file manager or any app that takes files. A drag from anywhere else
// selects instead (see `wire_drag_select`).
let drag_out = gtk4::DragSource::new();
drag_out.set_actions(gtk4::gdk::DragAction::COPY);
let ui_drag = ui.clone();
let drag_uid = uid.clone();
drag_out.connect_prepare(move |source, x, y| {
let photos = dragged_photos(&ui_drag, &drag_uid()?)?;
if let Some(tile) = source.widget() {
let icon = gtk4::WidgetPaintable::new(Some(&tile));
source.set_icon(Some(&icon), x as i32, y as i32);
}
Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast())
});
button.add_controller(drag_out);
// A tile opens in the in-app lightbox, which plays a video in place.
// In selection mode a tile picks instead of opening: the lightbox is
// one Escape away.
@@ -2478,6 +2589,23 @@ impl TileWidget {
}
}
/// The photos a drag that starts on `uid` carries out: the whole selection,
/// in timeline order, as uid and name. `None` unless `uid` is picked.
fn dragged_photos(ui: &Rc<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
let selected = ui.gallery.selected.borrow();
if !ui.gallery.selecting.get() || !selected.contains(uid) {
return None;
}
let model = &ui.gallery.model;
let photos: Vec<(String, Option<String>)> = (0..model.n_items())
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
.filter(|photo| selected.contains(&photo.uid))
.map(|photo| (photo.uid, photo.name))
.collect();
(!photos.is_empty()).then_some(photos)
}
/// Add or remove one CSS class, for widgets that are rebound rather than
/// rebuilt.
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
@@ -2556,6 +2684,9 @@ struct DragSelect {
pointer: (f64, f64),
/// The edge autoscroll, while the pointer is near the top or bottom.
scroll: Option<glib::SourceId>,
/// The drag started on a picked photo, so it carries the selection out
/// of the app rather than selecting.
out: bool,
}
/// Dragging across the timeline selects every photo from where the drag
@@ -2572,21 +2703,33 @@ fn wire_drag_select(ui: &Rc<Ui>) {
let state = Rc::new(RefCell::new(DragSelect::default()));
let state_begin = state.clone();
drag.connect_drag_begin(move |_, x, y| {
let ui_begin = ui.clone();
drag.connect_drag_begin(move |gesture, x, y| {
let mut drag = state_begin.borrow_mut();
if let Some(source) = drag.scroll.take() {
source.remove();
}
let out = ui_begin.gallery.selecting.get()
&& tile_at(&ui_begin, x, y)
.is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid));
*drag = DragSelect {
start: (x, y),
pointer: (x, y),
out,
..DragSelect::default()
};
// Left to the tile's own drag source.
if out {
gesture.set_state(gtk4::EventSequenceState::Denied);
}
});
let ui_update = ui.clone();
let state_update = state.clone();
drag.connect_drag_update(move |gesture, dx, dy| {
if state_update.borrow().out {
return;
}
let (sx, sy) = state_update.borrow().start;
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
if !state_update.borrow().active {
@@ -3954,17 +4097,32 @@ fn continue_jump(ui: &Rc<Ui>) {
/// Load the next page when the timeline does not fill the window yet: with no
/// scrollbar there is no scrolling to trigger it.
fn fill_viewport(ui: &Rc<Ui>) {
if !ui.gallery.has_more.get() || ui.gallery.loading.get() {
return;
}
let ui = ui.clone();
glib::idle_add_local_once(move || {
let Some(adj) = ui.gallery.list.vadjustment() else {
return;
};
if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline");
if timeline
&& ui.gallery.has_more.get()
&& adj.page_size() > 0.0
&& viewport_unfilled(adj.upper(), adj.page_size())
{
load_gallery(&ui, true);
}
});
}
/// Whether content `upper` px tall leaves a viewport `page_size` px tall short
/// of a screen and a half to scroll through, the margin the scrolling pager
/// keeps. Below it the pager would never fire.
pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool {
upper <= page_size * 2.5 + 1.0
}
/// Select every photo loaded so far (Ctrl+A).
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
set_selection_mode(ui, true);
@@ -4344,24 +4502,13 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
}
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
/// user's default handler when mpv isn't installed.
pub(crate) fn play_external(path: &str) {
match Command::new("mpv").arg(path).spawn() {
Ok(child) => pdfs_core::opener::reap(child),
Err(_) => open_path(path),
}
}
#[cfg(test)]
mod tests {
use super::{
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
tile_ratio,
tile_ratio, viewport_unfilled,
};
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
@@ -4484,6 +4631,18 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// A first page zoomed all the way out fits on one screen, with nothing
/// to scroll: the next page has to load without a scroll asking for it.
#[test]
fn a_page_zoomed_out_onto_one_screen_asks_for_more() {
let (columns, size) = plan_grid(1580, ROW_MIN);
let rows = PHOTOS_PAGE.div_ceil(columns) as i32;
let height = f64::from(rows * (size + TILE_GAP) + 40);
assert!(viewport_unfilled(height, 880.0));
assert!(viewport_unfilled(880.0, 880.0));
assert!(!viewport_unfilled(880.0 * 3.0, 880.0));
}
#[test]
fn zooming_in_puts_fewer_tiles_in_a_row() {
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
+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);
}
}
+3
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",
@@ -68,12 +69,14 @@ const STOCK: &[&str] = &[
"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",
+20
View File
@@ -88,6 +88,22 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
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
@@ -96,6 +112,10 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
### 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
+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
+445 -406
View File
File diff suppressed because it is too large. Load diff
+445 -406
View File
File diff suppressed because it is too large. Load diff
+445 -406
View File
File diff suppressed because it is too large. Load diff
+445 -406
View File
File diff suppressed because it is too large. Load diff
+445 -406
View File
File diff suppressed because it is too large. Load diff
+445 -406
View File
File diff suppressed because it is too large. Load diff
+435 -406
View File
File diff suppressed because it is too large. Load diff
+446 -406
View File
File diff suppressed because it is too large. Load diff
+445 -406
View File
File diff suppressed because it is too large. Load diff