diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70a3f52..2904917 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Install the development dependencies: ```sh sudo dnf install ImageMagick cargo desktop-file-utils gcc git glycin-devel \ glycin-loaders gstreamer1-devel gstreamer1-plugin-gtk4 \ - gstreamer1-plugins-base gstreamer1-plugins-good gtk4-devel rust xdg-utils + gstreamer1-plugins-base gstreamer1-plugins-good gtk4-devel rust wayland-devel xdg-utils ``` Clone and run the project: diff --git a/Cargo.lock b/Cargo.lock index eb70777..4661a77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,7 +236,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -353,6 +353,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -601,7 +607,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -633,7 +639,34 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", +] + +[[package]] +name = "gdk4-wayland" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cc5266dc4e8745a4c4683b26a0c54977a452809d572c940e39c6844663c0ec" +dependencies = [ + "gdk4", + "gdk4-wayland-sys", + "gio", + "glib", + "libc", + "wayland-backend", + "wayland-client", + "wayland-protocols", +] + +[[package]] +name = "gdk4-wayland-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb55bc5225519d34fdf1412db3755af88d1388a1c2a053d92e6751db13d96e68" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", ] [[package]] @@ -673,7 +706,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys", ] @@ -717,7 +750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -811,7 +844,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -832,7 +865,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -863,7 +896,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -901,7 +934,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -953,7 +986,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1257,12 +1290,15 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "open-mpv" version = "0.1.4" dependencies = [ + "gdk4-wayland", "glycin", "gstreamer", "gtk4", "gufo-common", "rustix", "tempfile", + "wayland-client", + "wayland-protocols", ] [[package]] @@ -1304,7 +1340,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1344,9 +1380,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "polling" @@ -1395,6 +1431,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -1506,6 +1551,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "semver" version = "1.0.28" @@ -1631,6 +1682,19 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-deps" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1832,6 +1896,66 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 23591b0..b4d3077 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,14 @@ repository = "https://github.com/TheRealShek/open-mpv" [dependencies] glycin = { version = "3.1.0", features = ["gdk4", "async-io"] } +gdk4-wayland = { version = "0.11.5", features = ["wayland_crate"] } gstreamer = "0.25.3" gtk4 = { version = "0.11.4", features = ["gio_v2_80", "v4_18", "v4_22"] } gufo-common = "1.1.0" rustix = { version = "1.1.4", features = ["fs"] } tempfile = "3.27.0" +wayland-client = "0.31.15" +wayland-protocols = { version = "0.32.13", features = ["client", "unstable"] } [profile.release] lto = "thin" diff --git a/README.md b/README.md index cb36c71..8ed76bb 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ when you choose to save a rotation, move them to trash or restore them. ## Install Currently supported on **Fedora 44 Workstation, GNOME, Wayland and x86-64**. -For Arch Linux / Omarchy on Hyprland, see the experimental +For Arch Linux and Omarchy on Hyprland, use the experimental [native Arch package instructions](docs/DISTRIBUTION.md#arch-linux-and-omarchy). Other environments have not completed the full support checklist. +### Fedora + Previously installed from source? Follow the [source-to-RPM migration guide](docs/DISTRIBUTION.md#migrate-a-source-installation-to-rpm) first so an old binary or desktop launcher does not hide the RPM. Install the latest release: @@ -148,9 +150,10 @@ keyboard shortcuts, follow the [configuration guide](docs/CONFIGURATION.md). ## Having trouble? -If images do not open, check that `glycin-loaders` is installed. If a video -will not play, you may need additional codecs. The optional -`gstreamer1-plugin-libav` package adds software decoding for more video formats. +If images do not open, check that Fedora's `glycin-loaders` or Arch's `glycin` +package is installed. If a video will not play, you may need additional codecs. +Fedora's `gstreamer1-plugin-libav` and Arch's `gst-libav` add software decoding +for more video formats. The [troubleshooting guide](docs/TROUBLESHOOTING.md) explains how to check video support, understand configuration failures, and capture crash backtraces. If you opened the app from diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 89cf177..09c43a1 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -24,7 +24,8 @@ the local transaction and rollback behavior; GitHub hosts the RPM. - The packaged platform today is Fedora 44 Workstation on x86-64 with GNOME and Wayland. - The code currently requires GTK 4.22, glycin and its matching loader - protocol, GStreamer, and `gtk4paintablesink`. + protocol, GStreamer, `gtk4paintablesink`, and Wayland client development + files for native video idle inhibition. - A package must satisfy [REQUIREMENTS.md](REQUIREMENTS.md), including glycin isolation, the video decoding path, file operations, desktop integration and the performance budgets. @@ -125,6 +126,9 @@ for software decoding. Arch's `glycin` includes the image loaders; `libheif` supplies HEIF/AVIF support. Additional codecs and hardware decoders are available through `gst-plugins-bad` and `gst-plugins-ugly`. Hardware acceleration must be verified on the actual GPU; software playback is the fallback. +The native Wayland client library is needed to inhibit compositor idle while +video plays. GTK's session inhibit remains the fallback if the compositor does +not expose the Wayland idle-inhibit protocol. Omarchy normally supplies `xdg-desktop-portal`, `xdg-desktop-portal-hyprland` and `xdg-desktop-portal-gtk`. Keep Hyprland's portal selection; GTK provides @@ -155,6 +159,22 @@ portal availability alone does not prove that Hyprland's idle behavior works. Verify install, reinstall/update and removal, software decoding and the actual hardware path, and measure the requirements' startup and PSS limits. +After building this checkout, run the targeted idle check in a Hyprland session +with no other open-mpv instance. It uses `ffmpeg`, `hyprctl` and `wtype`, creates +a temporary local video and isolated `loop=no` configuration, and checks that +playback inhibits idle while pause, natural end and exit release it. Seeking +after natural end keeps the video paused until playback is requested again, +and looping playback stays inhibited: + +```sh +python3 packaging/arch/check-idle.py target/release/open-mpv +``` + +The current local package recipe still builds the published 0.1.4 tag. These +source changes enter that recipe when the next release is tagged and its version +and checksum are updated. A checkout build and an installed 0.1.4 package are +different binaries; close the installed instance before testing the checkout. + On 20 September 2026, the patched 0.1.4 package passed all 192 default release tests on Omarchy x86-64 (GTK 4.22.4, Glycin 2.1.5 and GStreamer 1.28.6). Installation, same-version reinstallation, removal, desktop launch, native diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index fe0aad8..d491a09 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -223,6 +223,10 @@ new product decisions rather than incidental implementation. plain language. - **FR-10.9** Frame stepping, playlists, network playback, subtitle downloading and video editing are outside the current direction. +- **FR-10.10** Playing video inhibits compositor idle blanking and locking where + the desktop supports it. Pause, natural end, navigation away, playback failure + and close release that inhibition. A session API reporting success is + insufficient if the compositor does not honor the request. ### FR-11 — Quick Markup diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 792d045..17b670b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -2,8 +2,9 @@ ## Images do not open -Check that `glycin-loaders` is installed. open-mpv uses the image loaders -available through glycin, so a missing loader can leave a format unavailable. +Check that Fedora's `glycin-loaders` or Arch's `glycin` is installed. open-mpv +uses the image loaders available through glycin, so a missing loader can leave +a format unavailable. ## A video does not play @@ -12,6 +13,8 @@ codecs. On Fedora, `gstreamer1-plugins-bad-free` supplies hardware-decoder plugins. The optional `gstreamer1-plugin-libav` package supplies software fallbacks for more formats, and pitch-preserving playback speed needs `gstreamer1-plugins-good`. +On Arch, the corresponding software-decoder and common playback packages are +`gst-libav` and `gst-plugins-good`. The Reference environment is verified with Intel QSV. Other systems may expose VA-API or NVIDIA NVDEC, but those paths are not currently supported claims. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index ffabf0b..51b408e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -9,7 +9,7 @@ url='https://github.com/TheRealShek/open-mpv' license=('MIT' 'Apache-2.0' 'Apache-2.0 WITH LLVM-exception' 'ISC' 'MPL-2.0' 'Unicode-3.0') depends=('gtk4>=4.22' 'glycin>=2.1' 'libheif' 'gstreamer' 'gst-plugin-gtk4' 'gst-plugins-base' 'gst-plugins-good' 'gst-libav' 'libseccomp' - 'lcms2' 'fontconfig' 'glib2' 'glibc' 'libgcc') + 'lcms2' 'fontconfig' 'glib2' 'glibc' 'libgcc' 'wayland') makedepends=('cargo' 'pkgconf') checkdepends=('imagemagick') optdepends=('gst-plugins-bad: additional codecs and hardware decoding' diff --git a/packaging/arch/check-idle.py b/packaging/arch/check-idle.py new file mode 100644 index 0000000..a383083 --- /dev/null +++ b/packaging/arch/check-idle.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Check video idle inhibition against a live Hyprland session.""" + +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +def hyprland(command): + return json.loads(subprocess.check_output(["hyprctl", "-j", command])) + + +def window(pid): + matches = [client for client in hyprland("clients") if client.get("pid") == pid] + if len(matches) > 1: + raise RuntimeError(f"expected one open-mpv window, found {len(matches)}") + return matches[0] if matches else None + + +def wait_for_inhibition(pid, expected, timeout=5): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + client = window(pid) + if client is not None and client["inhibitingIdle"] is expected: + return + time.sleep(0.1) + raise RuntimeError(f"Hyprland did not report inhibitingIdle={expected}") + + +def require_focus(pid): + if hyprland("activewindow").get("pid") != pid: + client = window(pid) + if client is None: + raise RuntimeError("test window disappeared before it could be focused") + dispatcher = f'hl.dsp.focus({{ window = "address:{client["address"]}" }})' + subprocess.run(["hyprctl", "dispatch", dispatcher], + check=True, capture_output=True) + if hyprland("activewindow").get("pid") != pid: + raise RuntimeError("focus the test window and run the check again") + + +def make_video(path, seconds): + subprocess.run([ + "ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i", + "color=c=black:s=320x240:r=10", "-t", str(seconds), + "-c:v", "libx264", "-pix_fmt", "yuv420p", str(path), + ], check=True) + + +def main(binary): + if any(client.get("class") == "io.github.TheRealShek.OpenMpv" + for client in hyprland("clients")): + raise RuntimeError("close the existing open-mpv window before this check") + + with tempfile.TemporaryDirectory(prefix="open-mpv-idle-") as directory: + config_dir = Path(directory) / "config" + config_file = config_dir / "open-mpv" / "open-mpv.conf" + config_file.parent.mkdir(parents=True) + config_file.write_text("loop=no\n") + app_env = {**os.environ, "XDG_CONFIG_HOME": str(config_dir)} + video = Path(directory) / "video.mp4" + make_video(video, 30) + + with (Path(directory) / "open-mpv.log").open("w+") as log: + app = subprocess.Popen([str(binary.resolve()), str(video)], + stdout=log, stderr=log, env=app_env) + try: + wait_for_inhibition(app.pid, True) + require_focus(app.pid) + + subprocess.run(["wtype", "-k", "space"], check=True) + wait_for_inhibition(app.pid, False) + subprocess.run(["wtype", "-k", "space"], check=True) + wait_for_inhibition(app.pid, True) + subprocess.run(["wtype", "-k", "Escape"], check=True) + app.wait(timeout=5) + if app.returncode != 0: + raise RuntimeError(f"open-mpv exited with status {app.returncode}") + if any(client.get("pid") == app.pid for client in hyprland("clients")): + raise RuntimeError("open-mpv window remains after exit") + print("Idle inhibition follows play, pause, resume and exit") + except Exception: + log.seek(0) + for line in log: + if "idle inhibit" in line or "player:" in line or "error" in line.lower(): + print(line.rstrip(), file=sys.stderr) + raise + finally: + if app.poll() is None: + app.terminate() + app.wait(timeout=5) + + short_video = Path(directory) / "finished.mp4" + make_video(short_video, 12) + eos_log = Path(directory) / "eos.log" + with eos_log.open("w+") as log: + app = subprocess.Popen([str(binary.resolve()), str(short_video)], + stdout=log, stderr=log, env=app_env) + try: + wait_for_inhibition(app.pid, True) + wait_for_inhibition(app.pid, False, timeout=18) + require_focus(app.pid) + subprocess.run(["wtype", "-M", "shift", "-k", "Left", "-m", "shift"], + check=True) + time.sleep(0.5) + wait_for_inhibition(app.pid, False) + log.flush() + if "player: seek to" not in eos_log.read_text(): + raise RuntimeError("seek after natural end did not reach open-mpv") + require_focus(app.pid) + subprocess.run(["wtype", "-k", "space"], check=True) + wait_for_inhibition(app.pid, True) + log.flush() + if "player: seek to 0.0s" in eos_log.read_text(): + raise RuntimeError("playback rewound after an end-of-stream seek") + print("Idle inhibition releases at video end and after an end-of-stream seek") + except Exception: + for line in eos_log.read_text().splitlines(): + if "idle inhibit" in line or "player:" in line or "error" in line.lower(): + print(line.rstrip(), file=sys.stderr) + raise + finally: + if app.poll() is None: + app.terminate() + app.wait(timeout=5) + + config_file.write_text("loop=yes\n") + looping_video = Path(directory) / "looping.mp4" + make_video(looping_video, 2) + with (Path(directory) / "loop.log").open("w+") as log: + app = subprocess.Popen([str(binary.resolve()), str(looping_video)], + stdout=log, stderr=log, env=app_env) + try: + wait_for_inhibition(app.pid, True) + time.sleep(3) + wait_for_inhibition(app.pid, True) + print("Idle inhibition persists when video loops") + except Exception: + log.seek(0) + for line in log: + if "idle inhibit" in line or "error" in line.lower(): + print(line.rstrip(), file=sys.stderr) + raise + finally: + if app.poll() is None: + app.terminate() + app.wait(timeout=5) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: check-idle.py /path/to/open-mpv") + main(Path(sys.argv[1])) diff --git a/packaging/fedora/open-mpv.spec b/packaging/fedora/open-mpv.spec index 2da9589..ab35633 100644 --- a/packaging/fedora/open-mpv.spec +++ b/packaging/fedora/open-mpv.spec @@ -20,6 +20,7 @@ BuildRequires: glycin-devel >= 2.1 BuildRequires: gstreamer1-devel BuildRequires: gtk4-devel >= 4.22 BuildRequires: rust +BuildRequires: wayland-devel Requires: glycin-loaders >= 2.1 Requires: gstreamer1-plugin-gtk4 Requires: gstreamer1-plugins-base diff --git a/src/player.rs b/src/player.rs index 3874e43..472ed04 100644 --- a/src/player.rs +++ b/src/player.rs @@ -251,11 +251,19 @@ impl Player { match msg.view() { gst::MessageView::Eos(_) => { let context = playback.borrow().context(); + let playbin = playbin.clone(); let playback = playback.clone(); let on_event = on_event.clone(); glib::idle_add_local_once(move || { - if playback.borrow().error_is_current(&context) { - on_event(Event::EndOfStream); + let current = playback.borrow().error_is_current(&context); + if current { + match change_playing(&playbin, &playback, false) { + Ok(()) => { + playback.borrow_mut().mark_ended(); + on_event(Event::EndOfStream); + } + Err(error) => on_event(Event::StateError(error)), + } } }); } @@ -584,6 +592,10 @@ impl Player { /// Toggle pause; only report the requested state after acceptance. pub fn toggle_pause(&self) -> Result { + if self.playback.borrow().is_ended() { + self.rewind()?; + return Ok(true); + } let playing = !self.is_playing(); change_playing(&self.playbin, &self.playback, playing)?; Ok(playing) @@ -594,8 +606,10 @@ impl Player { /// tracks the user instead of the pipeline's catch-up. pub fn progress(&self) -> Option<(f64, f64)> { let dur = self.duration()?; - let pos = match self.playback.borrow().pending_seek_position() { + let playback = self.playback.borrow(); + let pos = match playback.pending_seek_position() { Some(position) => position, + None if playback.is_ended() => dur, None => self .playbin .query_position::()? diff --git a/src/player/playback.rs b/src/player/playback.rs index 7f6e5a0..d261d1f 100644 --- a/src/player/playback.rs +++ b/src/player/playback.rs @@ -119,6 +119,7 @@ pub(super) struct FocusedPlayback { generation: u64, current_video: Option, playing: bool, + ended: bool, /// Last rate accepted by the pipeline. A queued seek may advertise a /// newer requested rate without changing this until GStreamer accepts it. playback_rate: f64, @@ -144,6 +145,7 @@ impl Default for FocusedPlayback { generation: 0, current_video: None, playing: false, + ended: false, playback_rate: 1.0, duration: None, seek: SeekState::default(), @@ -194,6 +196,7 @@ impl FocusedPlayback { pub(super) fn playback_started(&mut self) { self.playing = true; + self.ended = false; } pub(super) fn forget_timing(&mut self) { @@ -219,6 +222,15 @@ impl FocusedPlayback { pub(super) fn accept_seek(&mut self, request: SeekRequest) { self.playback_rate = request.rate; + if self.ended + && self + .duration + .is_some_and(|duration| request.position < duration) + { + // A successful position seek after EOS is paused at its target. + // A refused or still-queued seek must leave the end state intact. + self.ended = false; + } } pub(super) fn seek_refused(&mut self) { @@ -282,12 +294,24 @@ impl FocusedPlayback { pub(super) fn set_playing(&mut self, playing: bool) { self.playing = playing; + if playing { + self.ended = false; + } } pub(super) fn is_playing(&self) -> bool { self.playing } + pub(super) fn mark_ended(&mut self) { + self.playing = false; + self.ended = true; + } + + pub(super) fn is_ended(&self) -> bool { + self.ended + } + pub(super) fn invalidate_duration(&mut self) { self.duration = None; } @@ -764,6 +788,42 @@ pub(super) fn issue_seek( mod tests { use super::*; + #[test] + fn natural_end_stops_playback_and_a_new_start_clears_it() { + let mut playback = FocusedPlayback::default(); + playback.start_video(Path::new("movie.mkv"), None, true); + playback.playback_started(); + playback.mark_ended(); + assert!(!playback.is_playing()); + assert!(playback.is_ended()); + + playback.set_playing(true); + assert!(playback.is_playing()); + assert!(!playback.is_ended()); + + playback.mark_ended(); + playback.start_video(Path::new("next.mkv"), None, true); + assert!(!playback.is_ended()); + + playback.mark_ended(); + playback.cache_duration(12.0); + playback.accept_seek(request(2.0, 1.0)); + assert!(!playback.is_ended()); + assert!(!playback.is_playing()); + + playback.mark_ended(); + playback.accept_seek(request(12.0, 1.0)); + assert!(playback.is_ended()); + + playback.begin_seek(request(10.0, 1.0)); + assert!(!playback.request_seek(request(2.0, 1.0))); + assert!(playback.is_ended()); + playback.seek_refused(); + assert!(playback.is_ended()); + playback.accept_seek(request(2.0, 1.0)); + assert!(!playback.is_ended()); + } + /// Stand-in for what `issue_seek` records on the pipeline's behalf. fn request(position: f64, rate: f64) -> SeekRequest { SeekRequest { position, rate } diff --git a/src/window.rs b/src/window.rs index 6081c12..3a10f17 100644 --- a/src/window.rs +++ b/src/window.rs @@ -37,6 +37,7 @@ mod animation; mod assembly; mod error; mod handoff; +mod idle; mod monitor; mod open; mod operation; @@ -196,6 +197,8 @@ pub struct App { pointer: Cell<(f64, f64)>, /// Session idle-inhibit cookie; `None` when nothing is held. inhibit_cookie: Cell>, + wayland_idle: RefCell>, + idle_probe_running: Cell, /// Set before the window is allowed to close. GTK can retain this `App` /// through signal closures until process exit, so `Drop` is too late to /// own GStreamer shutdown (FR-6.7/NFR-2.2). @@ -603,7 +606,7 @@ impl App { *self.media.borrow_mut() = MediaState::Video(path.to_path_buf()); self.update_control_mode(); crate::applog!("play: {}", path.display()); - self.set_idle_inhibited(true); + self.start_idle_inhibition(); self.update_save_enabled(); self.fit_seek_bar(); // Blank rather than carry the previous video's numbers over the @@ -684,6 +687,7 @@ impl App { self.stop_transport_tick(); self.operations.borrow_mut().clear(); self.set_idle_inhibited(false); + self.wayland_idle.borrow_mut().take(); // Taking the player also drops its bus-watch guard after stop reaches // Null, so no streaming callback can outlive process shutdown. @@ -729,7 +733,11 @@ impl App { self.update_subtitles(player.subtitle_snapshot()); self.update_audio(player.audio_snapshot()); self.update_playback_rate(player.playback_rate()); - self.set_idle_inhibited(player.is_playing()); + if player.is_playing() { + self.start_idle_inhibition(); + } else { + self.set_idle_inhibited(false); + } self.show_toast(&error::message(&error, "Could not add the subtitles.")); } } @@ -1022,17 +1030,104 @@ impl App { } /// Hold off the session's idle blanker while a video actually plays. - /// Nothing else stops GNOME from blanking the screen mid-film; the - /// inhibit is dropped on pause and on leaving the video so a paused - /// or image-only session never holds it (NFR-2.2). + /// Prefer the Wayland surface protocol when available, with GTK's session + /// inhibit as a fallback. Pause and leaving the video release either one. + fn start_idle_inhibition(self: &Rc) { + self.set_idle_inhibited(true); + if self.idle_probe_running.get() + || !self.win.is_mapped() + || !self + .wayland_idle + .borrow() + .as_ref() + .is_some_and(|idle| !idle.is_active()) + { + return; + } + self.idle_probe_running.set(true); + let weak = Rc::downgrade(self); + glib::spawn_future_local(async move { + for _ in 0..100 { + glib::timeout_future(std::time::Duration::from_millis(10)).await; + let Some(app) = weak.upgrade() else { return }; + if app.shutting_down.get() + || !app + .player + .borrow() + .as_ref() + .is_some_and(|player| player.is_playing()) + { + app.idle_probe_running.set(false); + return; + } + app.set_idle_inhibited(true); + if app + .wayland_idle + .borrow() + .as_ref() + .is_some_and(idle::WaylandIdle::is_active) + { + app.idle_probe_running.set(false); + return; + } + } + if let Some(app) = weak.upgrade() { + app.idle_probe_running.set(false); + crate::applog!( + "idle inhibit: Wayland registry did not respond; GTK fallback remains" + ); + } + }); + } + fn set_idle_inhibited(&self, inhibited: bool) { - if inhibited == self.inhibit_cookie.get().is_some() { + let wayland_active = self + .wayland_idle + .borrow() + .as_ref() + .is_some_and(idle::WaylandIdle::is_active); + if (inhibited && wayland_active) + || (!inhibited && !wayland_active && self.inhibit_cookie.get().is_none()) + { + return; + } + if inhibited { + if self.wayland_idle.borrow().is_none() { + match idle::WaylandIdle::new(&self.win) { + Ok(Some(idle)) => *self.wayland_idle.borrow_mut() = Some(idle), + Ok(None) => {} + Err(error) => crate::applog!("idle inhibit: Wayland unavailable: {error}"), + } + } + if let Some(idle) = self.wayland_idle.borrow_mut().as_mut() { + match idle.inhibit(&self.win) { + Ok(true) => { + if let Some(cookie) = self.inhibit_cookie.take() + && let Some(gtk_app) = self.win.application() + { + gtk_app.uninhibit(cookie.get()); + } + crate::applog!("idle inhibit: Wayland surface inhibitor taken"); + return; + } + Ok(false) => {} + Err(error) => crate::applog!("idle inhibit: Wayland failed: {error}"), + } + } + } else if let Some(idle) = self.wayland_idle.borrow_mut().as_mut() + && idle.is_active() + { + idle.release(); + crate::applog!("idle inhibit: Wayland surface inhibitor released"); return; } let Some(gtk_app) = self.win.application() else { return; }; if inhibited { + if self.inhibit_cookie.get().is_some() { + return; + } let cookie = gtk_app.inhibit( Some(&self.win), gtk::ApplicationInhibitFlags::IDLE, @@ -1194,6 +1289,9 @@ impl App { && let Some(Err(error)) = self.with_video(Player::rewind) { self.on_player_event(player::Event::StateError(error)); + } else if self.is_video_showing() && !self.cfg.loop_video { + self.set_idle_inhibited(false); + self.update_transport(); } } player::Event::StateError(error) => { @@ -2233,7 +2331,7 @@ impl App { } Command::TogglePlayback => match self.with_video(Player::toggle_pause) { Some(Ok(true)) => { - self.set_idle_inhibited(true); + self.start_idle_inhibition(); self.flash("Play"); } Some(Ok(false)) => { diff --git a/src/window/assembly.rs b/src/window/assembly.rs index 4d4fc2b..81faa8c 100644 --- a/src/window/assembly.rs +++ b/src/window/assembly.rs @@ -402,6 +402,8 @@ impl App { menu_open: Cell::new(false), pointer: Cell::new((0.0, 0.0)), inhibit_cookie: Cell::new(None), + wayland_idle: RefCell::new(None), + idle_probe_running: Cell::new(false), shutting_down: Cell::new(false), sized_from_media: Cell::new(false), pending_media_size: Cell::new(None), @@ -541,6 +543,23 @@ impl App { } )); + // A command-line video can start before GTK maps its window. Retry + // with the native surface once the compositor can see it. + app.win.connect_map(clone!( + #[weak] + app, + move |_| { + if app + .player + .borrow() + .as_ref() + .is_some_and(|player| player.is_playing()) + { + app.start_idle_inhibition(); + } + } + )); + if app.cfg.start_fullscreen { app.win.fullscreen(); } diff --git a/src/window/idle.rs b/src/window/idle.rs new file mode 100644 index 0000000..55231d6 --- /dev/null +++ b/src/window/idle.rs @@ -0,0 +1,145 @@ +//! Keep the visible video surface awake on Wayland. GTK's session inhibit +//! does not reach compositors whose idle service listens to Wayland's +//! idle-inhibit protocol, so GTK remains the fallback when that protocol is +//! unavailable. + +use gdk4_wayland::{WaylandDisplay, WaylandSurface, prelude::*}; +use gtk4::prelude::*; +use wayland_client::{ + Connection, Dispatch, Proxy, QueueHandle, delegate_noop, protocol::wl_registry, +}; +use wayland_protocols::wp::idle_inhibit::zv1::client::{ + zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1, zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1, +}; + +#[derive(Default)] +struct State { + manager_name: Option, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, interface, .. + } = event + && interface == "zwp_idle_inhibit_manager_v1" + { + state.manager_name = Some(name); + } + } +} + +delegate_noop!(State: ignore ZwpIdleInhibitManagerV1); +delegate_noop!(State: ignore ZwpIdleInhibitorV1); + +pub(super) struct WaylandIdle { + connection: Connection, + queue: wayland_client::EventQueue, + state: State, + registry: wl_registry::WlRegistry, + manager: Option, + inhibitor: Option, +} + +impl WaylandIdle { + pub(super) fn new(window: >k4::ApplicationWindow) -> Result, String> { + let Some(surface) = window.surface() else { + return Ok(None); + }; + let Ok(display) = surface.display().downcast::() else { + return Ok(None); + }; + if !display.query_registry("zwp_idle_inhibit_manager_v1") { + return Ok(None); + } + let Some(wl_display) = display.wl_display() else { + return Ok(None); + }; + let backend = wl_display + .backend() + .upgrade() + .ok_or("Wayland display connection closed")?; + let connection = Connection::from_backend(backend); + let queue = connection.new_event_queue(); + let registry = wl_display.get_registry(&queue.handle(), ()); + connection + .flush() + .map_err(|error| format!("cannot query Wayland idle manager: {error}"))?; + Ok(Some(Self { + connection, + queue, + state: State::default(), + registry, + manager: None, + inhibitor: None, + })) + } + + pub(super) fn is_active(&self) -> bool { + self.inhibitor.is_some() + } + + /// Processes only events already read by GDK; GTK never waits for a reply. + pub(super) fn inhibit(&mut self, window: >k4::ApplicationWindow) -> Result { + if self.inhibitor.is_some() { + return Ok(true); + } + self.queue + .dispatch_pending(&mut self.state) + .map_err(|error| format!("cannot read Wayland idle registry: {error}"))?; + if self.manager.is_none() { + let Some(name) = self.state.manager_name else { + return Ok(false); + }; + self.manager = Some(self.registry.bind::( + name, + 1, + &self.queue.handle(), + (), + )); + } + let surface = window + .surface() + .ok_or("window has no surface")? + .downcast::() + .map_err(|_| "window surface is not Wayland")?; + let wl_surface = surface.wl_surface().ok_or("Wayland surface is not ready")?; + let inhibitor = self + .manager + .as_ref() + .expect("manager bound above") + .create_inhibitor(&wl_surface, &self.queue.handle(), ()); + self.inhibitor = Some(inhibitor); + if let Err(error) = self.connection.flush() { + self.release(); + return Err(format!("cannot activate Wayland idle inhibition: {error}")); + } + Ok(true) + } + + pub(super) fn release(&mut self) { + if let Some(inhibitor) = self.inhibitor.take() { + inhibitor.destroy(); + if let Err(error) = self.connection.flush() { + crate::applog!("idle inhibit: cannot release Wayland inhibitor: {error}"); + } + } + } +} + +impl Drop for WaylandIdle { + fn drop(&mut self) { + self.release(); + if let Some(manager) = self.manager.take() { + manager.destroy(); + } + let _ = self.connection.flush(); + } +}