From 602f1c95776be43a58bbd5f6995efbb4521eeb37 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 14 Nov 2026 15:45:44 +0100 Subject: [PATCH] feat(gui): play videos and music in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/pdfs-gui/resources/style.css | 2 + crates/pdfs-gui/src/app/main.rs | 12 +- crates/pdfs-gui/src/app/pages/browser.rs | 60 +- crates/pdfs-gui/src/app/pages/photo_viewer.rs | 50 +- crates/pdfs-gui/src/app/pages/photos.rs | 11 - crates/pdfs-gui/src/app/widgets/mod.rs | 1 + crates/pdfs-gui/src/app/widgets/player.rs | 348 ++++++++ crates/pdfs-gui/tests/icon_names.rs | 3 + docs/CHANGELOG.md | 13 + po/POTFILES.in | 1 + po/de.po | 757 +++++++++--------- po/en_GB.po | 757 +++++++++--------- po/es.po | 757 +++++++++--------- po/fr.po | 757 +++++++++--------- po/it.po | 757 +++++++++--------- po/nl.po | 757 +++++++++--------- po/pdfs.pot | 747 ++++++++--------- po/pl.po | 757 +++++++++--------- po/pt_BR.po | 757 +++++++++--------- 19 files changed, 3966 insertions(+), 3338 deletions(-) create mode 100644 crates/pdfs-gui/src/app/widgets/player.rs diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index d4d5b8a..6f5e7ba 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -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); } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 894c778..453e316 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -41,6 +41,7 @@ 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::*; @@ -1519,7 +1520,7 @@ fn debug_info(ui: &Rc) -> 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"), &[ @@ -1569,6 +1570,15 @@ fn show_shortcuts(window: &adw::ApplicationWindow) { ("Escape 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() diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 4977ecb..18ce6ec 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -898,27 +898,15 @@ fn show_location_column(ui: &Rc, visible: bool) { pub(crate) fn entry_context_menu(ui: &Rc, 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>, 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 `/` 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, 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, entry: &DirEntry) { let rel = entry_rel(ui, entry); if entry.is_dir { @@ -1151,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc, 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 diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index 78b9a79..793c136 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -279,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, 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, viewer: &Rc, 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, viewer: &Rc) { 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, viewer: &Rc) { /// Stop the slideshow, leaving fullscreen only if the slideshow entered it. fn stop_slideshow(viewer: &Rc) { 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, viewer: &Rc, 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) { + 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, 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, 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, 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, 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, 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, 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, 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, viewer: &Rc) { }; 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() { diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index 659b6a5..7d3016b 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -4502,17 +4502,6 @@ pub(crate) fn gallery_unreachable(ui: &Rc) { 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::{ diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index 464b276..d6d60b5 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -4,6 +4,7 @@ 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; diff --git a/crates/pdfs-gui/src/app/widgets/player.rs b/crates/pdfs-gui/src/app/widgets/player.rs new file mode 100644 index 0000000..2676223 --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/player.rs @@ -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> = 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: >k4::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: >k4::Video) { + if let Some(stream) = video.media_stream() { + stream.set_playing(!stream.is_playing()); + } +} + +fn toggle_muted(video: >k4::Video) { + if let Some(stream) = video.media_stream() { + stream.set_muted(!stream.is_muted()); + } +} + +fn change_volume(video: >k4::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: >k4::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: >k4::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: >k4::Video, window: >k4::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: >k4::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: >k4::Window) -> gtk4::Button { + let button = gtk4::Button::builder().valign(gtk4::Align::Center).build(); + let paint = |button: >k4::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, 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, 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, >k_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(¬e); + + 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(>k_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); + } +} diff --git a/crates/pdfs-gui/tests/icon_names.rs b/crates/pdfs-gui/tests/icon_names.rs index 8753565..d61e1ab 100644 --- a/crates/pdfs-gui/tests/icon_names.rs +++ b/crates/pdfs-gui/tests/icon_names.rs @@ -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", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0c0d40c..b76b258 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -95,6 +95,15 @@ Existing folders stay device backups. Older builds cannot open the upgraded data - **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 @@ -103,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 diff --git a/po/POTFILES.in b/po/POTFILES.in index fd55328..585cfa1 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -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 diff --git a/po/de.po b/po/de.po index 288bc29..2863084 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-11-14 15:22+0100\n" +"POT-Creation-Date: 2026-11-14 15:41+0100\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -31,7 +31,7 @@ msgstr[1] "vor {n} Min." #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3707 +#: crates/pdfs-gui/src/app/pages/photos.rs:3714 msgid "%H:%M" msgstr "%H:%M" @@ -57,88 +57,88 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:878 +#: crates/pdfs-gui/src/app/main.rs:879 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 #: crates/pdfs-gui/src/app/pages/browser.rs:473 #: crates/pdfs-gui/src/app/pages/browser.rs:880 -#: crates/pdfs-gui/src/app/pages/browser.rs:1296 -#: crates/pdfs-gui/src/app/pages/browser.rs:2281 -#: crates/pdfs-gui/src/app/pages/browser.rs:2360 +#: crates/pdfs-gui/src/app/pages/browser.rs:1254 +#: crates/pdfs-gui/src/app/pages/browser.rs:2239 +#: crates/pdfs-gui/src/app/pages/browser.rs:2318 #: crates/pdfs-gui/src/app/pages/locations.rs:1721 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:879 crates/pdfs-gui/src/app/main.rs:1549 +#: crates/pdfs-gui/src/app/main.rs:880 crates/pdfs-gui/src/app/main.rs:1550 #: crates/pdfs-gui/src/app/pages/albums.rs:636 #: crates/pdfs-gui/src/app/pages/photos.rs:929 #: crates/pdfs-gui/src/app/pages/places.rs:473 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:882 +#: crates/pdfs-gui/src/app/main.rs:883 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:887 +#: crates/pdfs-gui/src/app/main.rs:888 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:890 +#: crates/pdfs-gui/src/app/main.rs:891 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:891 +#: crates/pdfs-gui/src/app/main.rs:892 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:893 +#: crates/pdfs-gui/src/app/main.rs:894 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1243 +#: crates/pdfs-gui/src/app/main.rs:1244 #: crates/pdfs-gui/src/app/pages/photo_viewer.rs:915 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1266 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1267 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1268 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1270 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1271 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1357 +#: crates/pdfs-gui/src/app/main.rs:1358 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1365 +#: crates/pdfs-gui/src/app/main.rs:1366 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -150,23 +150,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1370 +#: crates/pdfs-gui/src/app/main.rs:1371 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1375 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1378 +#: crates/pdfs-gui/src/app/main.rs:1379 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1383 +#: crates/pdfs-gui/src/app/main.rs:1384 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1387 +#: crates/pdfs-gui/src/app/main.rs:1388 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -191,7 +191,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1395 +#: crates/pdfs-gui/src/app/main.rs:1396 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -211,7 +211,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -34,7 +34,7 @@ msgstr[1] "" #. Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05". #: crates/pdfs-gui/src/app/dates.rs:44 #: crates/pdfs-gui/src/app/pages/activity.rs:840 -#: crates/pdfs-gui/src/app/pages/photos.rs:3707 +#: crates/pdfs-gui/src/app/pages/photos.rs:3714 msgid "%H:%M" msgstr "" @@ -60,88 +60,88 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:878 +#: crates/pdfs-gui/src/app/main.rs:879 #: crates/pdfs-gui/src/app/pages/activity_text.rs:543 #: crates/pdfs-gui/src/app/pages/browser.rs:473 #: crates/pdfs-gui/src/app/pages/browser.rs:880 -#: crates/pdfs-gui/src/app/pages/browser.rs:1296 -#: crates/pdfs-gui/src/app/pages/browser.rs:2281 -#: crates/pdfs-gui/src/app/pages/browser.rs:2360 +#: crates/pdfs-gui/src/app/pages/browser.rs:1254 +#: crates/pdfs-gui/src/app/pages/browser.rs:2239 +#: crates/pdfs-gui/src/app/pages/browser.rs:2318 #: crates/pdfs-gui/src/app/pages/locations.rs:1721 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:879 crates/pdfs-gui/src/app/main.rs:1549 +#: crates/pdfs-gui/src/app/main.rs:880 crates/pdfs-gui/src/app/main.rs:1550 #: crates/pdfs-gui/src/app/pages/albums.rs:636 #: crates/pdfs-gui/src/app/pages/photos.rs:929 #: crates/pdfs-gui/src/app/pages/places.rs:473 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:882 +#: crates/pdfs-gui/src/app/main.rs:883 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:493 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:887 +#: crates/pdfs-gui/src/app/main.rs:888 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:890 +#: crates/pdfs-gui/src/app/main.rs:891 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:505 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:891 +#: crates/pdfs-gui/src/app/main.rs:892 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:893 +#: crates/pdfs-gui/src/app/main.rs:894 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1243 +#: crates/pdfs-gui/src/app/main.rs:1244 #: crates/pdfs-gui/src/app/pages/photo_viewer.rs:915 #: crates/pdfs-gui/src/app/pages/takeout.rs:714 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1266 +#: crates/pdfs-gui/src/app/main.rs:1267 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1267 +#: crates/pdfs-gui/src/app/main.rs:1268 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1268 +#: crates/pdfs-gui/src/app/main.rs:1269 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1269 +#: crates/pdfs-gui/src/app/main.rs:1270 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1271 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1357 +#: crates/pdfs-gui/src/app/main.rs:1358 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1365 +#: crates/pdfs-gui/src/app/main.rs:1366 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -149,23 +149,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1370 +#: crates/pdfs-gui/src/app/main.rs:1371 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1374 +#: crates/pdfs-gui/src/app/main.rs:1375 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1378 +#: crates/pdfs-gui/src/app/main.rs:1379 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1383 +#: crates/pdfs-gui/src/app/main.rs:1384 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1387 +#: crates/pdfs-gui/src/app/main.rs:1388 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -178,21 +178,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1395 +#: crates/pdfs-gui/src/app/main.rs:1396 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -212,7 +212,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1403 +#: crates/pdfs-gui/src/app/main.rs:1404 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1411 +#: crates/pdfs-gui/src/app/main.rs:1412 msgid "" "The street map, when turned on, comes from OpenFreeMap in the