Files
Classroom_Desktop/scripts/classroom-dvd-session
T
ekklesiaandClaude Opus 5 f8ddfbe345 feat: make DVD playback a classroom surface, not a media player
Pressing DVD now enters DVD mode rather than opening VLC. VLC is still
the backend, because its dvdnav module is what decodes disc menus and
nothing else on Debian does that reliably, but nothing a person can see
or press belongs to it any more.

classroom-dvd-session owns the whole thing. It puts a full-screen black
layer-shell surface up the moment the button is pressed, starts VLC
behind it, and drops it once playback is actually on screen — so the
harsh cut from wallpaper to VLC's black window is gone, and no partial
VLC window or reserved dock strip is ever visible. A deadline drops the
surface regardless, because DVD mode must never leave a black screen
with nothing behind it.

The controls are a classroom bar on the overlay layer, the one layer
sway draws above a fullscreen window. It slides up when the pointer
reaches the bottom of the screen and hides again shortly after it
leaves. It is drawn from the dock's own stylesheet at the room's UI
scale, so it is the same surface, the same radius and the same white
icons. While hidden it only accepts the pointer in a thin strip along
the bottom edge, so a click on a disc menu goes to the disc.

The bar reaches VLC through an HTTP interface on loopback behind a
password generated per session, sending hotkey *actions* by name. That
is what makes the next part possible.

All 108 hotkeys VLC 3.0.23 defines are unset, then Space, Esc and the
arrow keys plus Enter are bound. Crop, aspect ratio, subtitles, audio
tracks and deinterlacing are no longer reachable by pressing a key at
random, and the arrow keys no longer change VLC's volume — the
television owns volume, and CEC will own it properly later.

Esc quits instead of leaving fullscreen. There is nothing to leave, and
leaving it was what stranded people on a black VLC window. Close, Eject
and Esc all ask VLC to quit and escalate to SIGTERM then SIGKILL if it
does not; when VLC exits by any route, including a crash, the session
ends and the classroom desktop comes back.

Verified against VLC 3.0.23 on Trixie: every argument accepted, every
control action accepted over the control interface, a wrong password
refused, and quit exiting cleanly. The surfaces were rendered under a
headless sway and screenshotted to check the bar against the dock.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 22:33:53 -05:00

586 lines
16 KiB
Python
Executable File

#!/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"<state>(\w+)</state>", 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())