diff --git a/config/icons/chapter-next.svg b/config/icons/chapter-next.svg new file mode 100644 index 0000000..d8314a0 --- /dev/null +++ b/config/icons/chapter-next.svg @@ -0,0 +1 @@ + diff --git a/config/icons/chapter-prev.svg b/config/icons/chapter-prev.svg new file mode 100644 index 0000000..ce88e0a --- /dev/null +++ b/config/icons/chapter-prev.svg @@ -0,0 +1 @@ + diff --git a/config/icons/close.svg b/config/icons/close.svg new file mode 100644 index 0000000..2f5c9e3 --- /dev/null +++ b/config/icons/close.svg @@ -0,0 +1 @@ + diff --git a/config/icons/disc-menu.svg b/config/icons/disc-menu.svg new file mode 100644 index 0000000..302d01a --- /dev/null +++ b/config/icons/disc-menu.svg @@ -0,0 +1 @@ + diff --git a/config/icons/eject.svg b/config/icons/eject.svg new file mode 100644 index 0000000..41c61d1 --- /dev/null +++ b/config/icons/eject.svg @@ -0,0 +1 @@ + diff --git a/config/icons/media-pause.svg b/config/icons/media-pause.svg new file mode 100644 index 0000000..67fccc7 --- /dev/null +++ b/config/icons/media-pause.svg @@ -0,0 +1 @@ + diff --git a/config/icons/media-play.svg b/config/icons/media-play.svg new file mode 100644 index 0000000..9d6a117 --- /dev/null +++ b/config/icons/media-play.svg @@ -0,0 +1 @@ + diff --git a/config/sway/config b/config/sway/config index e8a0897..7512c30 100644 --- a/config/sway/config +++ b/config/sway/config @@ -19,8 +19,10 @@ exec --no-startup-id /usr/local/bin/classroom-dock include /etc/sway/config.d/* -# The DVD player is an appliance screen, not a desktop window: no sway -# title bar, and it opens fullscreen over the dock. +# The DVD player is an appliance screen, not a desktop window. VLC takes +# the whole display itself; this removes the title bar sway would +# otherwise draw above the video. classroom-dvd-session covers the +# wallpaper with black until VLC is up, so there is never a gap. for_window [class="(?i)vlc"] border none for_window [app_id="(?i)vlc"] border none diff --git a/docs/dvd.md b/docs/dvd.md index 53cfb5d..cfa4c1a 100644 --- a/docs/dvd.md +++ b/docs/dvd.md @@ -36,54 +36,84 @@ Discs never play on their own; playback always needs a button press. The first poll after login only records what is already in the drive, so a disc left in over the week does not announce itself at every boot. -## Playback +## Playback: DVD mode -Playback is VLC, chosen over mpv because mpv dropped DVD menu support. -VLC's `dvdnav` module is what provides the disc menus, and `dvd://` -selects it. +Pressing DVD does not open a media player. It enters **DVD mode**: a +full-screen playback surface the classroom owns, with VLC decoding the +disc behind it. -`classroom-dvd toggle` hands the disc to VLC through `classroom-window`, -so the DVD player behaves like any other classroom application: the DVD -button focuses it, minimises it, and restores it. `Super+M` minimises it -too. VLC starts fullscreen; `Esc` leaves fullscreen. +`classroom-dvd-session` runs the whole thing: -## How VLC is made to fit the classroom +``` +black backdrop layer-shell TOP, covers wallpaper and dock while the + disc loads, hidden once VLC is on screen +VLC fullscreen, drawn above the top layer by sway +control bar layer-shell OVERLAY, the one layer sway draws above a + fullscreen window +``` -Stock VLC looks like a desktop media player, which is wrong for a room -where the TV is the whole interface. It is dressed down with documented -VLC 3.x options — no skin is involved, because skins replace the -interface wholesale and take DVD menu input with them: +VLC is still the backend because its `dvdnav` module is what decodes +disc menus, and nothing else on Debian does that reliably. But nothing +of VLC's own interface is exposed: no menu bar, no panels, no +fullscreen controller, no cone, and no sway title bar. -| Option | Why | +### The control bar + +Move the mouse to the bottom of the screen and a bar slides up. It is +the same dark translucent rounded surface as the dock, drawn from the +same stylesheet at the same UI scale, so DVD mode feels like the same +room. It hides again a second after the pointer leaves it, or after four +seconds without movement. + +| Control | Does | | --- | --- | -| `--fullscreen` | The disc opens straight onto the TV | -| `--qt-minimal-view` | No menu bar or extended panels | -| `--qt-dark-palette` | Dark window instead of the default light grey | -| `--no-qt-bgcone` | No orange cone, no album-art panel | -| `--no-qt-system-tray` | Not a background desktop application | -| `--no-qt-name-in-title` | Nothing in the title bar to read | -| `--no-qt-video-autoresize` | The window does not jump to the video size | -| `--qt-notification=0` | No now-playing popups | -| `--qt-continue=0` | Never asks to resume where it left off | -| `--no-qt-recentplay` | No history of the discs a classroom has played | -| `--no-qt-privacy-ask` | No first-run privacy dialog | -| `--no-video-title-show` | No title banner over the video | +| ⏮ | Previous chapter | +| ▶ / ⏸ | Play or pause, showing which one it will do | +| ⏭ | Next chapter | +| ☰ | Back to the disc menu | +| ⏏ | Ejects the disc and leaves DVD mode | +| ✕ | Leaves DVD mode | -sway also draws no border on the VLC window -(`for_window [class="(?i)vlc"] border none`), so there is no title bar -above the video either. +While the bar is hidden it only accepts the pointer in a thin strip +along the very bottom edge, so a click anywhere else goes to the disc +menu rather than to an invisible bar. -The auto-hiding fullscreen controller is deliberately **kept**. It is -the only way to pause or change chapter with a mouse, it is not -persistent chrome, and a volunteer with a wireless mouse needs it. +The bar reaches VLC over an HTTP control interface on the loopback +address, behind a password generated for that session alone. Commands +are sent as VLC hotkey **actions** by name, which is why they keep +working even though every key has been unbound. -`--intf dummy` would give an even barer window, but the dummy interface -takes the keyboard and mouse handling that DVD menu navigation depends -on, so it is not an option here. +### The keyboard -Note that `--no-qt-updates-notif`, which earlier classroom builds -passed, is not an option in Debian's VLC at all — it made VLC exit -before it started. +All 108 hotkeys VLC 3.0.23 defines are unset, then these are bound: + +| Key | Does | +| --- | --- | +| Space | Play / pause | +| Esc | Leaves DVD mode | +| Arrow keys | Move around a disc menu | +| Enter | Choose the highlighted menu item | + +Nothing else responds. A volunteer cannot land on crop, aspect ratio, +subtitle tracks, audio tracks or deinterlacing by pressing a key at +random, and **the arrow keys no longer change VLC's volume** — the +television owns volume. + +Esc quits rather than leaving fullscreen. DVD mode is the whole screen, +so there is nothing to leave, and leaving it used to strand people on a +black VLC window with no way back. + +There is no fullscreen toggle, because DVD mode is not a window. + +### Leaving + +Close, Eject and Esc all do the same thing: ask VLC to quit, and if it +does not, escalate to SIGTERM and then SIGKILL. When VLC exits — however +it exits, including a crash — the session ends and the classroom desktop +and dock come straight back. Nothing can leave a black window behind. + +Only one DVD session can run at a time, however fast the button is +pressed. ## Commercial (CSS encrypted) discs diff --git a/docs/testing.md b/docs/testing.md index 2ee4e80..8479f16 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -119,12 +119,34 @@ With `DVD_ENABLED=true`: - [ ] Closing the player works - [ ] Ejecting is detected, the icon goes back - [ ] Reinserting is detected again -- [ ] VLC opens fullscreen, dark, with no menu bar, no orange cone and - no sway title bar -- [ ] The fullscreen controller still appears on mouse movement - [ ] Commercial CSS disc plays without anyone having run codec setup by hand +### DVD mode + +- [ ] Pressing DVD goes black immediately — no wallpaper, no dock, no + partial VLC window at any point +- [ ] Video or the disc menu appears inside that black surface +- [ ] No VLC chrome anywhere: no menu bar, no fullscreen controller, + no cone, no sway title bar +- [ ] Wallpaper is never visible around VLC +- [ ] Moving the mouse to the bottom slides the classroom bar up +- [ ] It hides again shortly after the mouse leaves it +- [ ] While hidden, clicking a disc menu item near the bottom still + reaches the menu +- [ ] Play/pause toggles and the icon follows the actual state +- [ ] Previous and next chapter work +- [ ] The menu button returns to the disc menu +- [ ] Space pauses; arrow keys and Enter navigate the disc menu +- [ ] Arrow keys do **not** change volume, and no key reaches crop, + aspect ratio, subtitles, audio tracks or deinterlacing +- [ ] Esc leaves DVD mode cleanly, with no black window left behind +- [ ] Close leaves DVD mode cleanly +- [ ] Eject leaves DVD mode and opens the tray +- [ ] Killing VLC by hand also returns to the classroom desktop +- [ ] Pressing DVD twice quickly does not start two sessions +- [ ] The bar is sized by UI_SCALE like the dock + ## OTA - [ ] `./update_canary.sh` publishes package first, manifest last diff --git a/lib/classroom_vlc.py b/lib/classroom_vlc.py new file mode 100644 index 0000000..0eac03f --- /dev/null +++ b/lib/classroom_vlc.py @@ -0,0 +1,127 @@ +"""How the classroom launches VLC. + +VLC is the DVD backend because its dvdnav module is what decodes disc +menus. Everything a user can see or press, though, belongs to the +classroom: the visible controls are classroom-dvd-session's overlay, and +VLC's own interface and keyboard are shut down here. +""" + +import secrets + +PLAYER = "vlc" + +# Every hotkey VLC 3.0.23 defines, read out of `vlc -H --advanced` on +# Debian Trixie. All of them are unset before anything is bound, so a +# volunteer cannot land on crop, aspect ratio, subtitle tracks, audio +# tracks, deinterlacing or the volume by pressing a key at random. +# +# A future VLC that adds a hotkey would leave it bound. Regenerate with: +# vlc -H --advanced | grep -oP '^\s+--key-\K[a-z0-9-]+' +ALL_HOTKEYS = ( + "aspect-ratio", "audio-track", "audiodelay-down", "audiodelay-up", + "audiodevice-cycle", "chapter-next", "chapter-prev", "clear-playlist", + "crop", "crop-bottom", "crop-left", "crop-right", "crop-top", + "decr-scalefactor", "deinterlace", "deinterlace-mode", "disc-menu", + "faster", "frame-next", "incr-scalefactor", "intf-boss", + "intf-popup-menu", "intf-show", "jump-extrashort", "jump-long", + "jump-medium", "jump-short", "leave-fullscreen", "length", "loop", + "nav-activate", "nav-down", "nav-left", "nav-right", "nav-up", "next", + "pause", "play", "play-bookmark1", "play-bookmark10", "play-bookmark2", + "play-bookmark3", "play-bookmark4", "play-bookmark5", "play-bookmark6", + "play-bookmark7", "play-bookmark8", "play-bookmark9", "play-pause", + "position", "prev", "program-sid-next", "program-sid-prev", "quit", + "random", "rate-faster-fine", "rate-normal", "rate-slower-fine", + "record", "set-bookmark1", "set-bookmark10", "set-bookmark2", + "set-bookmark3", "set-bookmark4", "set-bookmark5", "set-bookmark6", + "set-bookmark7", "set-bookmark8", "set-bookmark9", "slower", + "snapshot", "stop", "subdelay-down", "subdelay-up", "subpos-down", + "subpos-up", "subsync-apply", "subsync-markaudio", "subsync-marksub", + "subsync-reset", "subtitle-revtrack", "subtitle-text-scale-down", + "subtitle-text-scale-normal", "subtitle-text-scale-up", + "subtitle-toggle", "subtitle-track", "title-next", "title-prev", + "toggle-autoscale", "toggle-fullscreen", "uncrop-bottom", + "uncrop-left", "uncrop-right", "uncrop-top", "unzoom", + "viewpoint-fov-in", "viewpoint-fov-out", "viewpoint-roll-anticlock", + "viewpoint-roll-clock", "vol-down", "vol-mute", "vol-up", "wallpaper", + "zoom", "zoom-double", "zoom-half", "zoom-original", "zoom-quarter", +) + +# The only keys a classroom DVD responds to. +# +# Esc quits rather than leaving fullscreen: DVD mode is the whole screen, +# so there is nothing to leave, and leaving it used to strand people on a +# black VLC window with no way out. Volume is deliberately absent — the +# television owns volume. +CLASSROOM_HOTKEYS = { + "play-pause": "Space", + "quit": "Esc", + # Disc menu navigation. Without these a menu can only be clicked. + "nav-up": "Up", + "nav-down": "Down", + "nav-left": "Left", + "nav-right": "Right", + "nav-activate": "Enter", +} + +# VLC dressed down to a playback surface. Verified against 3.0.23. +PRESENTATION = ( + # DVD mode is the whole display, and sway fullscreens the window the + # moment it maps so no wallpaper is ever visible around it. + "--fullscreen", + # No menu bar, no panels, dark rather than light grey. + "--qt-minimal-view", + "--qt-dark-palette", + # No orange cone or album-art panel behind the video. + "--no-qt-bgcone", + # VLC's own fullscreen controller is replaced by the classroom bar. + "--no-qt-fs-controller", + # Nothing that behaves like a desktop application. + "--no-qt-system-tray", + "--no-qt-name-in-title", + "--no-qt-video-autoresize", + "--qt-notification=0", + # Never offer to resume, never keep a list of the discs played here. + "--qt-continue=0", + "--no-qt-recentplay", + # No first-run privacy dialog, no title banner over the video. + "--no-qt-privacy-ask", + "--no-video-title-show", +) + + +def control_password(): + """A fresh password for this session's control interface.""" + return secrets.token_urlsafe(24) + + +def player_command(device, port, password): + """The full VLC command line for a DVD session. + + The HTTP control interface is how the classroom's own control bar + reaches VLC. It triggers hotkey *actions* by name, which keeps + working after every key has been unbound, and it listens on the + loopback address behind a password generated for this session only. + """ + command = [PLAYER, *PRESENTATION] + + for hotkey in ALL_HOTKEYS: + command.append(f"--key-{hotkey}=") + + for hotkey, binding in CLASSROOM_HOTKEYS.items(): + command.append(f"--key-{hotkey}={binding}") + + command += [ + "--extraintf", + "http", + "--http-host", + "127.0.0.1", + "--http-port", + str(port), + "--http-password", + password, + ] + + # dvd:// selects dvdnav, which is what provides the disc menus. + command.append(f"dvd://{device}") + + return command diff --git a/packaging/debian/control.template b/packaging/debian/control.template index f30e41b..34f75ef 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -4,7 +4,7 @@ Section: misc Priority: optional Architecture: amd64 Maintainer: Ekklesia -Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates +Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates Recommends: vlc Description: Ekklesia managed classroom environment A minimal Wayland classroom environment for Ekklesia-managed church computers. diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 29d8a02..001b53f 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -20,6 +20,7 @@ chmod 0644 /home/classroom/.bash_profile chmod 0755 /usr/local/bin/classroom-browser chmod 0755 /usr/local/bin/classroom-window chmod 0755 /usr/local/bin/classroom-dvd +chmod 0755 /usr/local/bin/classroom-dvd-session chmod 0755 /usr/local/bin/classroom-dvd-css-setup chmod 0755 /usr/local/bin/classroom-notify-test chmod 0755 /usr/local/bin/classroom-notifications diff --git a/scripts/classroom-dvd b/scripts/classroom-dvd index 4a38f41..d0097a5 100755 --- a/scripts/classroom-dvd +++ b/scripts/classroom-dvd @@ -20,9 +20,10 @@ sys.path.insert( ) import classroom_optical as optical # noqa: E402 +from classroom_vlc import PLAYER # noqa: E402 -PLAYER = "vlc" WINDOW_TOOL = "/usr/local/bin/classroom-window" +SESSION_TOOL = "/usr/local/bin/classroom-dvd-session" def notify(title, message, urgency="normal"): @@ -42,49 +43,15 @@ def notify(title, message, urgency="normal"): print(f"{title}: {message}", file=sys.stderr) -def player_command(device): - """VLC, dressed down to look like an appliance rather than a desktop app. - - All of these are documented VLC 3.x options, verified against the - 3.0.23 build in Debian Trixie. No skin is involved: skins replace the - interface wholesale and break DVD menu input, which is the one thing - that has to keep working. - - Note that --no-qt-updates-notif, which earlier builds passed, is not - an option in Debian's VLC and made it refuse to start at all. - """ - return [ - PLAYER, - # Fullscreen first: the disc opens straight onto the TV. - "--fullscreen", - # No menu bar or extended panels, and a dark window instead of - # the default light grey one. - "--qt-minimal-view", - "--qt-dark-palette", - # No orange cone, and no album-art panel, behind the video. - "--no-qt-bgcone", - # Nothing that behaves like a desktop application. - "--no-qt-system-tray", - "--no-qt-name-in-title", - "--no-qt-video-autoresize", - "--qt-notification=0", - # Never offer to resume, and never accumulate a list of the - # discs a classroom has played. - "--qt-continue=0", - "--no-qt-recentplay", - # No first-run privacy dialog, no title banner over the video. - "--no-qt-privacy-ask", - "--no-video-title-show", - # dvd:// selects VLC's dvdnav module, which is what provides the - # disc menus. The Qt interface is kept for exactly that reason: - # --intf dummy would be cleaner still but takes the keyboard and - # mouse handling that menu navigation depends on with it. - f"dvd://{device}", - ] - - def play(device): - """Hand the disc to the player through the normal window toggle.""" + """Hand the disc to DVD mode through the normal window toggle. + + classroom-dvd-session owns the player from here: it puts a black + surface up immediately, starts VLC behind it, and provides the + classroom's own controls. Going through classroom-window keeps the + dock button behaving like every other one — focus if it is already + up, rather than starting a second session. + """ if shutil.which(PLAYER) is None: notify( "DVD", @@ -99,7 +66,7 @@ def play(device): "--match", "(?i)vlc", "--", - *player_command(device), + SESSION_TOOL, ] ) diff --git a/scripts/classroom-dvd-session b/scripts/classroom-dvd-session new file mode 100755 index 0000000..9d34748 --- /dev/null +++ b/scripts/classroom-dvd-session @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 + +"""DVD mode: a full-screen playback surface owned by the classroom. + +VLC decodes the disc and its menus, because nothing else on Debian does +that reliably. Everything a person can see or press belongs to the +classroom: VLC's own interface is switched off, every one of its +keyboard shortcuts is unbound, and the visible controls are the bar in +this program, styled from the same stylesheet as the dock. + +Layout, bottom to top: + + black backdrop layer-shell TOP, covers wallpaper and dock while + the disc loads, hidden once VLC is up + VLC fullscreen, drawn above the top layer by sway + control bar layer-shell OVERLAY, drawn above fullscreen video +""" + +import fcntl +import os +import re +import signal +import socket +import sys +from base64 import b64encode +from pathlib import Path +from urllib import error as urlerror +from urllib import parse as urlparse +from urllib import request as urlrequest + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("GtkLayerShell", "0.1") + +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, GtkLayerShell + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +import classroom_optical as optical # noqa: E402 +import classroom_style as style # noqa: E402 +import classroom_vlc as vlc # noqa: E402 +from classroom_config import read_device_config, scaled, ui_scale # 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 +# is actually showing something. +try: + import cairo +except ImportError: + cairo = None + +ICON_DIR = Path("/etc/ekklesia-classroom/icons") + +# The bar is the same height as the dock strip, so DVD mode feels like +# the same room rather than a different program. +BAR_HEIGHT = 76 +ICON_SIZE = 28 + +# The dock floats clear of the bottom edge because its 44px buttons and +# 8px padding sit centred in a 76px strip. The bar matches that gap so it +# reads as the same surface rather than something stuck to the screen. +BAR_INSET = 7 + +# How far up from the bottom edge of the screen wakes the bar. +HOVER_STRIP = 14 + +# Hidden this long after the pointer stops moving, and this long after it +# leaves the bar. +IDLE_HIDE_SECONDS = 4 +LEAVE_HIDE_SECONDS = 1 + +# The backdrop goes away as soon as VLC is up. The deadline is a safety +# net: whatever happens, DVD mode must never leave a black screen with +# nothing behind it. +WINDOW_POLL_MS = 500 +BACKDROP_DEADLINE_SECONDS = 10 + +STATUS_POLL_SECONDS = 1 + +EXTRA_CSS = """ +window#classroom-dvd-backdrop { + background-color: #000000; + background-image: none; +} + +window#classroom-dvd-bar { + background-color: transparent; + background-image: none; + border: none; + box-shadow: none; +} +""" + + +def icon(filename, size): + path = ICON_DIR / filename + + if not path.exists(): + return None + + return GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), size, size, True) + + +def free_port(): + """A loopback port for VLC's control interface.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +class VlcControl: + """The classroom's line to VLC. + + Commands go through VLC's HTTP interface as hotkey *actions* rather + than key presses, which is exactly why the control bar keeps working + after every key has been unbound. + """ + + def __init__(self, port, password): + self.url = f"http://127.0.0.1:{port}/requests/status.xml" + self.authorization = "Basic " + b64encode( + f":{password}".encode("utf-8") + ).decode("ascii") + + def request(self, **parameters): + url = self.url + + if parameters: + url += "?" + urlparse.urlencode(parameters) + + try: + message = urlrequest.Request( + url, + headers={"Authorization": self.authorization}, + ) + + with urlrequest.urlopen(message, timeout=1) as response: + return response.read().decode("utf-8", "replace") + except (urlerror.URLError, OSError, ValueError): + return None + + def press(self, action): + return self.request(command="key", val=action) + + def state(self): + """playing, paused, stopped, or None while VLC is still coming up.""" + body = self.request() + + if not body: + return None + + found = re.search(r"(\w+)", body) + + return found.group(1) if found else None + + +class Backdrop(Gtk.Window): + """Full-screen black, above the wallpaper and the dock.""" + + def __init__(self): + super().__init__() + + screen = self.get_screen() + visual = screen.get_rgba_visual() + + if visual is not None: + self.set_visual(visual) + + self.set_app_paintable(True) + self.set_name("classroom-dvd-backdrop") + self.set_decorated(False) + + GtkLayerShell.init_for_window(self) + GtkLayerShell.set_layer(self, GtkLayerShell.Layer.TOP) + + for edge in ( + GtkLayerShell.Edge.TOP, + GtkLayerShell.Edge.BOTTOM, + GtkLayerShell.Edge.LEFT, + GtkLayerShell.Edge.RIGHT, + ): + GtkLayerShell.set_anchor(self, edge, True) + + # -1 means "ignore everyone else's reserved space": DVD mode is + # the whole display, including the strip the dock reserves. + GtkLayerShell.set_exclusive_zone(self, -1) + GtkLayerShell.set_keyboard_interactivity(self, False) + + self.connect("realize", self.ignore_pointer) + + def ignore_pointer(self, *_): + # Scenery, never a target. + if cairo is None: + return + + self.get_window().input_shape_combine_region(cairo.Region(), 0, 0) + + +class ControlBar(Gtk.Window): + """The classroom's DVD controls, above fullscreen video.""" + + def __init__(self, scale, buttons): + super().__init__() + + self.scale = scale + self.height = scaled(BAR_HEIGHT, scale) + self.strip = scaled(HOVER_STRIP, scale) + self.idle_source = None + self.leave_source = None + self.revealed = False + + screen = self.get_screen() + visual = screen.get_rgba_visual() + + if visual is not None: + self.set_visual(visual) + + self.set_app_paintable(True) + self.set_name("classroom-dvd-bar") + self.set_decorated(False) + + GtkLayerShell.init_for_window(self) + + # OVERLAY is the one layer sway draws above a fullscreen window. + GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY) + + for edge in ( + GtkLayerShell.Edge.BOTTOM, + GtkLayerShell.Edge.LEFT, + GtkLayerShell.Edge.RIGHT, + ): + GtkLayerShell.set_anchor(self, edge, True) + + GtkLayerShell.set_exclusive_zone(self, -1) + + # Never take the keyboard: the disc menus need it. + GtkLayerShell.set_keyboard_interactivity(self, False) + + self.set_size_request(-1, self.height) + + bar = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=scaled(10, scale), + ) + bar.get_style_context().add_class("dock") + + self.images = {} + + for name, filename, callback in buttons: + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + button.get_style_context().add_class("dock-button") + + pixbuf = icon(filename, scaled(ICON_SIZE, scale)) + image = Gtk.Image.new_from_pixbuf(pixbuf) if pixbuf else Gtk.Image() + + self.images[name] = image + button.add(image) + button.connect("clicked", callback) + + bar.pack_start(button, False, False, 0) + + centred = Gtk.Alignment(xalign=0.5, yalign=1.0, xscale=0, yscale=0) + centred.set_padding(0, scaled(BAR_INSET, scale), 0, 0) + centred.add(bar) + + self.revealer = Gtk.Revealer() + self.revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_UP) + self.revealer.set_transition_duration(220) + self.revealer.set_reveal_child(False) + self.revealer.set_valign(Gtk.Align.END) + self.revealer.add(centred) + + self.add(self.revealer) + + self.add_events( + Gdk.EventMask.ENTER_NOTIFY_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + self.connect("enter-notify-event", self.on_enter) + self.connect("leave-notify-event", self.on_leave) + self.connect("motion-notify-event", self.on_motion) + # The shape has to be reapplied whenever the surface gets a + # size: at realize the allocation is still zero, which would + # leave a region nothing can ever enter. + self.connect("size-allocate", lambda *_: self.shape()) + + def shape(self): + """Only take pointer events where the bar is actually reachable. + + Concealed, that is a thin strip along the bottom edge, so a click + on a disc menu goes to VLC rather than to an invisible bar. + """ + if cairo is None: + return + + window = self.get_window() + + if window is None: + return + + width = self.get_allocated_width() + height = self.get_allocated_height() or self.height + + if width <= 0 or height <= 0: + return + + if self.revealed: + area = cairo.RectangleInt(0, 0, width, height) + else: + area = cairo.RectangleInt( + 0, + height - self.strip, + width, + self.strip, + ) + + window.input_shape_combine_region(cairo.Region(area), 0, 0) + + def reveal(self): + if not self.revealed: + self.revealed = True + self.revealer.set_reveal_child(True) + self.shape() + + self.arm_idle() + + def conceal(self): + self.cancel(("idle_source", "leave_source")) + + if self.revealed: + self.revealed = False + self.revealer.set_reveal_child(False) + self.shape() + + return GLib.SOURCE_REMOVE + + def cancel(self, names): + for name in names: + source = getattr(self, name) + + if source is not None: + GLib.source_remove(source) + setattr(self, name, None) + + def arm_idle(self): + self.cancel(("idle_source", "leave_source")) + self.idle_source = GLib.timeout_add_seconds( + IDLE_HIDE_SECONDS, + self.conceal, + ) + + def on_enter(self, *_): + self.reveal() + return False + + def on_motion(self, *_): + self.reveal() + return False + + def on_leave(self, *_): + self.cancel(("idle_source", "leave_source")) + self.leave_source = GLib.timeout_add_seconds( + LEAVE_HIDE_SECONDS, + self.conceal, + ) + return False + + def set_playing(self, playing): + image = self.images.get("play") + + if image is None: + return + + pixbuf = icon( + "media-pause.svg" if playing else "media-play.svg", + scaled(ICON_SIZE, self.scale), + ) + + if pixbuf is not None: + image.set_from_pixbuf(pixbuf) + + +class DvdSession: + def __init__(self): + self.config = read_device_config() + self.scale = ui_scale(self.config) + + self.device = optical.default_device() + self.port = free_port() + self.password = vlc.control_password() + self.control = VlcControl(self.port, self.password) + + self.pid = None + self.closing = False + self.eject_on_exit = False + self.elapsed = 0 + + self.backdrop = Backdrop() + self.bar = ControlBar( + self.scale, + [ + ("previous", "chapter-prev.svg", self.previous_chapter), + ("play", "media-play.svg", self.play_pause), + ("next", "chapter-next.svg", self.next_chapter), + ("menu", "disc-menu.svg", self.disc_menu), + ("eject", "eject.svg", self.eject), + ("close", "close.svg", self.close), + ], + ) + + # -- controls --------------------------------------------------- + + def play_pause(self, *_): + self.control.press("play-pause") + + def previous_chapter(self, *_): + self.control.press("chapter-prev") + + def next_chapter(self, *_): + self.control.press("chapter-next") + + def disc_menu(self, *_): + self.control.press("disc-menu") + + def eject(self, *_): + self.eject_on_exit = True + self.close() + + def close(self, *_): + if self.closing: + return + + self.closing = True + + # Ask, then insist. + self.control.press("quit") + GLib.timeout_add_seconds(2, self.insist) + + def insist(self): + """VLC did not take the hint. Ask the kernel.""" + if self.pid is None: + Gtk.main_quit() + return GLib.SOURCE_REMOVE + + try: + os.kill(self.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + GLib.timeout_add_seconds(2, self.finish) + + return GLib.SOURCE_REMOVE + + def finish(self): + if self.pid is not None: + try: + os.kill(self.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + Gtk.main_quit() + return GLib.SOURCE_REMOVE + + # -- the player ------------------------------------------------- + + def start(self): + self.backdrop.show_all() + self.bar.show_all() + + command = vlc.player_command(self.device, self.port, self.password) + + self.pid, _, _, _ = GLib.spawn_async( + argv=command, + flags=GLib.SpawnFlags.SEARCH_PATH + | GLib.SpawnFlags.DO_NOT_REAP_CHILD, + ) + + GLib.child_watch_add(GLib.PRIORITY_DEFAULT, self.pid, self.on_exit) + + GLib.timeout_add(WINDOW_POLL_MS, self.watch_startup) + GLib.timeout_add_seconds(STATUS_POLL_SECONDS, self.watch_state) + + def on_exit(self, pid, status, *_): + GLib.spawn_close_pid(pid) + self.pid = None + Gtk.main_quit() + + def watch_startup(self): + """Keep the black surface until VLC is actually covering the screen. + + Playing or paused means the video output exists, which is also + when VLC takes the screen. The deadline is the safety net: DVD + mode must never leave a black screen with nothing behind it, even + if the disc never opens. + """ + self.elapsed += WINDOW_POLL_MS / 1000 + + ready = self.control.state() in ("playing", "paused") + + if ready or self.elapsed >= BACKDROP_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_backdrop) + return GLib.SOURCE_REMOVE + + return GLib.SOURCE_CONTINUE + + def drop_backdrop(self): + self.backdrop.hide() + return GLib.SOURCE_REMOVE + + def watch_state(self): + if self.closing: + return GLib.SOURCE_REMOVE + + self.bar.set_playing(self.control.state() == "playing") + + return GLib.SOURCE_CONTINUE + + +def load_css(scale): + try: + provider = Gtk.CssProvider() + provider.load_from_data((style.render(scale) + EXTRA_CSS).encode("utf-8")) + except (OSError, GLib.Error) as error: + print(f"classroom-dvd-session: {error}", file=sys.stderr) + return + + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + +def take_lock(): + """One DVD session at a time, however fast the button is pressed.""" + directory = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") + directory = directory / "ekklesia-classroom" + + try: + directory.mkdir(parents=True, exist_ok=True) + handle = open(directory / "dvd.lock", "w", encoding="utf-8") + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, BlockingIOError): + return None + + return handle + + +def main(): + lock = take_lock() + + if lock is None: + print("classroom-dvd-session: already running", file=sys.stderr) + return 0 + + if optical.default_device() is None: + print("classroom-dvd-session: no optical drive", file=sys.stderr) + return 1 + + load_css(ui_scale(read_device_config())) + + session = DvdSession() + session.start() + + Gtk.main() + + if session.eject_on_exit: + optical.eject(session.device) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-classroom-dvd-session.sh b/tests/test-classroom-dvd-session.sh new file mode 100755 index 0000000..c370cdb --- /dev/null +++ b/tests/test-classroom-dvd-session.sh @@ -0,0 +1,112 @@ +#!/bin/bash +set -uo pipefail + +# Checks the VLC launch policy: every hotkey unset, only the classroom +# ones bound, and the control interface wired to this session only. +# +# tests/test-classroom-dvd-session.sh + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' +import sys + +sys.path.insert(0, sys.argv[1] + "/lib") + +import classroom_vlc as vlc + +passed = failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + print(f" ok {label}") + passed += 1 + else: + print(f" FAIL {label}") + if detail: + print(f" {detail}") + failed += 1 + + +command = vlc.player_command("/dev/sr0", 41234, "s3cret") + +print("1. every VLC hotkey is unset") + +check("VLC 3.0.23 defines 108 hotkeys", len(vlc.ALL_HOTKEYS) == 108, + f"got {len(vlc.ALL_HOTKEYS)}") +check("no duplicates in the hotkey list", + len(set(vlc.ALL_HOTKEYS)) == len(vlc.ALL_HOTKEYS)) + +unset = [a for a in command if a.startswith("--key-") and a.endswith("=")] +check("one unset per hotkey", len(unset) == len(vlc.ALL_HOTKEYS), + f"{len(unset)} unsets for {len(vlc.ALL_HOTKEYS)} hotkeys") + +# The specific complaints from hardware testing. +for name in ("crop", "aspect-ratio", "vol-up", "vol-down", "vol-mute", + "subtitle-track", "audio-track", "deinterlace", + "toggle-fullscreen", "leave-fullscreen"): + check(f"{name} is unset", f"--key-{name}=" in command) + +print() +print("2. only the classroom keys are bound") + +bound = {a.split("=", 1)[0][len("--key-"):]: a.split("=", 1)[1] + for a in command + if a.startswith("--key-") and not a.endswith("=")} + +check("bound set is exactly the classroom set", + bound == vlc.CLASSROOM_HOTKEYS, f"got {bound}") +check("Space is play/pause", bound.get("play-pause") == "Space") +check("Esc quits rather than leaving fullscreen", + bound.get("quit") == "Esc") +check("arrow keys are disc navigation, not volume", + bound.get("nav-up") == "Up" and bound.get("nav-down") == "Down") +check("Enter activates a menu item", bound.get("nav-activate") == "Enter") +check("nothing bound to volume", + not any(k.startswith("vol-") for k in bound)) +check("no fullscreen toggle: DVD mode is the whole screen", + "toggle-fullscreen" not in bound and "leave-fullscreen" not in bound) + +print() +print("3. unsets come before the bindings, so the bindings win") + +first_binding = min(i for i, a in enumerate(command) + if a.startswith("--key-") and not a.endswith("=")) +last_unset = max(i for i, a in enumerate(command) if a.endswith("=") + and a.startswith("--key-")) +check("every unset precedes every binding", last_unset < first_binding, + f"last unset {last_unset}, first binding {first_binding}") + +print() +print("4. VLC's own interface is switched off") + +for option in ("--qt-minimal-view", "--no-qt-fs-controller", "--no-qt-bgcone", + "--no-qt-system-tray", "--no-video-title-show", "--fullscreen", + "--qt-dark-palette"): + check(f"{option} passed", option in command) + +print() +print("5. the control interface belongs to this session alone") + +check("bound to loopback only", + command[command.index("--http-host") + 1] == "127.0.0.1") +check("uses the port it was given", + command[command.index("--http-port") + 1] == "41234") +check("uses the password it was given", + command[command.index("--http-password") + 1] == "s3cret") +check("passwords are not reused", + vlc.control_password() != vlc.control_password()) +check("passwords are long enough to matter", + len(vlc.control_password()) >= 24) + +print() +print("6. the disc is the last argument") + +check("dvd:// selects dvdnav for menus", command[-1] == "dvd:///dev/sr0") + +print() +print(f"passed: {passed} failed: {failed}") +sys.exit(1 if failed else 0) +PY diff --git a/tools/build-release.sh b/tools/build-release.sh index 6c7baf1..0e02155 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -105,6 +105,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-dvd" \ "$STAGE/usr/local/bin/classroom-dvd" +install -Dm755 \ + "$ROOT/scripts/classroom-dvd-session" \ + "$STAGE/usr/local/bin/classroom-dvd-session" + install -Dm755 \ "$ROOT/scripts/classroom-dvd-css-setup" \ "$STAGE/usr/local/bin/classroom-dvd-css-setup" @@ -130,6 +134,14 @@ install -Dm644 \ "$ROOT/lib/classroom_config.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py" +install -Dm644 \ + "$ROOT/lib/classroom_style.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py" + +install -Dm644 \ + "$ROOT/lib/classroom_vlc.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_vlc.py" + install -Dm644 \ "$ROOT/lib/classroom_optical.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py" @@ -199,6 +211,14 @@ install -Dm644 \ "$ROOT/config/icons/browser.svg" \ "$STAGE/etc/ekklesia-classroom/icons/browser.svg" +# DVD mode control bar. +for icon in media-play media-pause chapter-prev chapter-next \ + disc-menu eject close; do + install -Dm644 \ + "$ROOT/config/icons/${icon}.svg" \ + "$STAGE/etc/ekklesia-classroom/icons/${icon}.svg" +done + printf '%s\n' "$VERSION" > "$ROOT/VERSION" dpkg-deb \