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 \