diff --git a/docs/dvd.md b/docs/dvd.md index 3440a2e..bb11923 100644 --- a/docs/dvd.md +++ b/docs/dvd.md @@ -64,10 +64,22 @@ dock is a top-layer surface and DVD mode has to cover it. Its exclusive zone is `-1`, which tells sway not to move it out of the way of the strip the dock reserves. -It comes down when VLC reports playing or paused, meaning the video -output exists and has taken the display, plus a moment for the handover. -A twenty second deadline takes it down regardless: a cover with nothing -behind it is the one outcome DVD mode must never produce. +It comes down only when **all three** of these hold: + +1. VLC reports playing or paused, and +2. VLC's window is fullscreen in the sway tree, and +3. 800ms have passed since both became true. + +Playback state alone is not enough, and used to be the whole condition. +VLC reports playing while its window is still the ordinary tiled one +sway gave it — which is exactly the black startup window the cover +exists to hide. It only takes the display when it goes fullscreen, and +the sway tree is what says so. The final hold gives the disc menu time +to draw its first frames, so the handover lands on the menu rather than +on VLC's black. + +A twenty second deadline takes the cover down regardless: a cover with +nothing behind it is the one outcome DVD mode must never produce. VLC is still the backend because its `dvdnav` module is what decodes disc menus, and nothing else on Debian does that reliably. But nothing diff --git a/scripts/classroom-dvd-session b/scripts/classroom-dvd-session index 3e20868..8a01d44 100755 --- a/scripts/classroom-dvd-session +++ b/scripts/classroom-dvd-session @@ -46,8 +46,9 @@ sys.path.insert( import classroom_optical as optical # noqa: E402 import classroom_style as style # noqa: E402 +import classroom_sway as sway # noqa: E402 import classroom_vlc as vlc # noqa: E402 -from classroom_config import read_device_config, scaled, ui_scale # noqa: E402 +from classroom_config import SHELL_SCALE, read_device_config, scaled # noqa: E402 # Input shaping needs pycairo. Without it the bar still works, it just # takes pointer events across its whole strip instead of only where it @@ -77,13 +78,22 @@ HOVER_STRIP = 14 IDLE_HIDE_SECONDS = 4 LEAVE_HIDE_SECONDS = 1 -# The loading surface goes away as soon as playback is on screen. The -# deadline is a safety net: whatever happens, DVD mode must never leave a -# cover over a screen with nothing behind it. Generous, because an -# optical drive can take a while to spin a disc up. -WINDOW_POLL_MS = 500 +# How VLC is recognised in the sway tree. Qt5 puts it on XWayland, so +# the X11 class is what usually matches. +VLC_WINDOW = (r"vlc", r"vlc-.*") + +# The loading surface goes away only once VLC has genuinely taken the +# screen. The deadline is a safety net: whatever happens, DVD mode must +# never leave a cover over a screen with nothing behind it. Generous, +# because an optical drive can take a while to spin a disc up. +READY_POLL_MS = 250 LOADING_DEADLINE_SECONDS = 20 +# Held after VLC looks ready, so the disc menu has drawn its first frames +# before the cover comes off. Without this the handover lands on VLC's +# own black startup window instead of on the menu. +STABILISE_MS = 800 + # Long enough for the cover to be on screen before VLC is even started, # so no part of VLC's window is ever visible against the wallpaper. LAUNCH_DELAY_MS = 120 @@ -448,7 +458,7 @@ class ControlBar(Gtk.Window): class DvdSession: def __init__(self): self.config = read_device_config() - self.scale = ui_scale(self.config) + self.scale = SHELL_SCALE self.device = optical.default_device() self.port = free_port() @@ -555,7 +565,7 @@ class DvdSession: GLib.child_watch_add(GLib.PRIORITY_DEFAULT, self.pid, self.on_exit) - GLib.timeout_add(WINDOW_POLL_MS, self.watch_startup) + GLib.timeout_add(READY_POLL_MS, self.watch_startup) GLib.timeout_add_seconds(STATUS_POLL_SECONDS, self.watch_state) return GLib.SOURCE_REMOVE @@ -565,22 +575,38 @@ class DvdSession: self.pid = None Gtk.main_quit() - def watch_startup(self): - """Hold the cover until playback is genuinely on screen. + def player_is_fullscreen(self): + """True once VLC's window exists and has taken the whole output. - Playing or paused means the video output exists, which is also - when VLC takes the display. The deadline is the safety net: DVD - mode must never leave a cover over a screen with nothing behind - it, even if the disc never opens. + Playback state alone is not enough. VLC reports playing while its + window is still the ordinary tiled one sway gave it, which is the + black startup window the cover exists to hide. It only takes the + display when it goes fullscreen, and that is what the tree says. """ - self.elapsed += WINDOW_POLL_MS / 1000 + for window in sway.windows(): + if sway.matches(window, VLC_WINDOW) and window["fullscreen"]: + return True - ready = self.control.state() in ("playing", "paused") + return False + + def watch_startup(self): + """Hold the cover until VLC is genuinely ready to replace it. + + Three things have to line up: VLC says it is playing, its window + is fullscreen, and a moment has passed for the disc menu to draw. + The deadline is the safety net: DVD mode must never leave a cover + over a screen with nothing behind it, even if the disc never + opens. + """ + self.elapsed += READY_POLL_MS / 1000 + + # The cheap local check first; the tree is only worth walking + # once VLC claims to be playing something. + playing = self.control.state() in ("playing", "paused") + ready = playing and self.player_is_fullscreen() if ready or self.elapsed >= LOADING_DEADLINE_SECONDS: - # A moment for the video window to actually take the screen, - # so the handover has no frame of wallpaper in it. - GLib.timeout_add(400, self.drop_loading) + GLib.timeout_add(STABILISE_MS, self.drop_loading) return GLib.SOURCE_REMOVE return GLib.SOURCE_CONTINUE @@ -641,7 +667,7 @@ def main(): print("classroom-dvd-session: no optical drive", file=sys.stderr) return 1 - load_css(ui_scale(read_device_config())) + load_css(SHELL_SCALE) session = DvdSession() session.start() diff --git a/tests/test-classroom-dvd-session.sh b/tests/test-classroom-dvd-session.sh index c370cdb..3efe622 100755 --- a/tests/test-classroom-dvd-session.sh +++ b/tests/test-classroom-dvd-session.sh @@ -106,6 +106,101 @@ print("6. the disc is the last argument") check("dvd:// selects dvdnav for menus", command[-1] == "dvd:///dev/sr0") +print() +print("7. the loading surface waits for a fullscreen VLC window") + +import json # noqa: E402 +import os # noqa: E402 +import subprocess # noqa: E402 +import tempfile # noqa: E402 + + +def tree_with(app, fullscreen): + return { + "tree": { + "type": "root", "id": 1, "nodes": [{ + "type": "output", "id": 2, "name": "HDMI-A-1", "nodes": [{ + "type": "workspace", "id": 3, "name": "1", + "nodes": ([] if app is None else [{ + "type": "con", "id": 10, "pid": 100, + "app_id": None, + "window_properties": {"class": app, "instance": app}, + "focused": True, + "fullscreen_mode": 1 if fullscreen else 0, + "nodes": [], "floating_nodes": [], + }]), + "floating_nodes": [], + }], "floating_nodes": [], + }], "floating_nodes": [], + }, + "workspaces": [{"name": "1", "focused": True, "visible": True}], + } + + +work = tempfile.mkdtemp() +os.makedirs(f"{work}/bin", exist_ok=True) + +with open(f"{work}/bin/swaymsg", "w") as shim: + shim.write( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "state = json.load(open(os.environ['MOCK_TREE']))\n" + "print(json.dumps(state['tree' if sys.argv[2] == 'get_tree'" + " else 'workspaces']))\n" + ) +os.chmod(f"{work}/bin/swaymsg", 0o755) +os.environ["PATH"] = f"{work}/bin:" + os.environ["PATH"] +os.environ["MOCK_TREE"] = f"{work}/tree.json" + + +def set_tree(app, fullscreen): + with open(f"{work}/tree.json", "w") as handle: + json.dump(tree_with(app, fullscreen), handle) + + +ready = None + +try: + import importlib.util + from importlib.machinery import SourceFileLoader + + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("GtkLayerShell", "0.1") + + loader = SourceFileLoader("dvdsession", sys.argv[1] + "/scripts/classroom-dvd-session") + spec = importlib.util.spec_from_loader("dvdsession", loader) + module = importlib.util.module_from_spec(spec) + sys.modules["dvdsession"] = module + loader.exec_module(module) + ready = module.DvdSession.player_is_fullscreen +except Exception as error: # noqa: BLE001 + print(f" skip GTK unavailable here ({type(error).__name__})") + +if ready is not None: + set_tree("vlc", False) + check("a tiled VLC window is not ready", ready(None) is False) + + set_tree("vlc", True) + check("a fullscreen VLC window is ready", ready(None) is True) + + set_tree("Google-chrome", True) + check("someone else's fullscreen window is not VLC", ready(None) is False) + + set_tree(None, False) + check("no windows at all is not ready", ready(None) is False) + + check("playback state alone is no longer the condition", + "player_is_fullscreen" in module.DvdSession.watch_startup.__doc__ + or "fullscreen" in module.DvdSession.watch_startup.__doc__) + check("the handover is held briefly after both agree", + 500 <= module.STABILISE_MS <= 1000, f"got {module.STABILISE_MS}") + check("the safety deadline is still there", + module.LOADING_DEADLINE_SECONDS == 20) + +subprocess.run(["rm", "-rf", work]) + print() print(f"passed: {passed} failed: {failed}") sys.exit(1 if failed else 0)