Files
Classroom_Desktop/scripts/classroom-dvd-session
ekklesiaandClaude Opus 5 13aac85b87 docs: record Parent Call, the DVD migration and what is left to test
Three things in this window that a container cannot answer, and they are
now the top of the next-steps list: a real key pressed into Parent Call,
a television switched on after the computer, and a disc that plays.

Also brings the comments in the shared libraries up to date. The only
mention of sway left in the tree is the upgrade step that removes the
sway-era login shell from a room being upgraded.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 13:09:42 -05:00

699 lines
21 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:
loading surface layer-shell OVERLAY, covers the whole display
including the dock while the disc loads, removed
once playback is actually on screen
VLC fullscreen, an ordinary window below both layers
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("GtkLayerShell", "0.1")
from gi.repository import Gdk, 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 SHELL_SCALE, read_device_config, scaled # noqa: E402
from classroom_icons import ScalableIcon # 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
# How long the cover stays up at the very least.
#
# This replaced a query to the window manager. The session used to walk
# the compositor's tree until VLC's window was really fullscreen, because
# VLC
# reports playing while its window is still the ordinary one — the black
# startup window the cover exists to hide.
#
# VLC's own status was measured as a replacement and is not one: it
# reports <fullscreen>true</fullscreen> in the same 250ms poll as
# <state>playing</state>, because the flag is the setting it was started
# with and not the state of its window. So the cover is held for a floor
# instead, and LOADING_DEADLINE_SECONDS still caps the wait. On real
# hardware the disc takes far longer than this to open, so the floor is
# almost never what decides.
MINIMUM_COVER_SECONDS = 1.2
# The loading surface goes away only once VLC has genuinely taken the
# screen. The deadline is a safety net: whatever happens, DVD mode must
# never leave a cover over a screen with nothing behind it. Generous,
# because an optical drive can take a while to spin a disc up.
READY_POLL_MS = 250
LOADING_DEADLINE_SECONDS = 20
# Held after VLC looks ready, so the disc menu has drawn its first frames
# before the cover comes off. Without this the handover lands on VLC's
# own black startup window instead of on the menu.
STABILISE_MS = 800
# Long enough for the cover to be on screen before VLC is even started,
# so no part of VLC's window is ever visible against the wallpaper.
LAUNCH_DELAY_MS = 120
STATUS_POLL_SECONDS = 1
def extra_css(scale):
"""Styling for the two surfaces this program owns.
The loading card reuses the dock's own .dock class, so it is the same
translucent surface, border and corner radius as everything else in
the room.
"""
return f"""
window#classroom-dvd-loading {{
background-color: #000000;
background-image: none;
}}
.dvd-loading {{
padding: {scaled(16, scale)}px {scaled(22, scale)}px;
}}
.dvd-loading label {{
color: #ffffff;
font-size: {scaled(17, scale)}px;
font-weight: 600;
}}
.dvd-loading spinner,
.dvd-loading spinner:checked {{
color: #ffffff;
opacity: 1;
min-width: {scaled(28, scale)}px;
min-height: {scaled(28, scale)}px;
}}
window#classroom-dvd-bar {{
background-color: transparent;
background-image: none;
border: none;
box-shadow: none;
}}
"""
def icon(filename, size):
"""A control bar icon, drawn at the display's real pixel density."""
return ScalableIcon(filename, size, ICON_DIR)
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."""
return self.field(r"<state>(\w+)</state>")
def field(self, pattern):
body = self.request()
if not body:
return None
found = re.search(pattern, body)
return found.group(1) if found else None
def wants_fullscreen(self):
"""Whether VLC believes it is fullscreen.
Deliberately named for what it is. VLC reports this true from the
moment it starts, because it is the option it was given, not a
report about its window — measured at 250ms resolution against a
real VLC. It is a precondition worth checking and not evidence
that anything is on screen.
"""
return self.field(r"<fullscreen>(\w+)</fullscreen>") == "true"
class LoadingSurface(Gtk.Window):
"""The whole display, while the disc spins up.
Entering DVD mode has to look like entering DVD mode, not like an
application opening on the classroom desktop, so this goes up before
VLC is even started and covers everything until there is something
to show.
"""
def __init__(self, scale):
super().__init__()
self.set_name("classroom-dvd-loading")
self.set_decorated(False)
# Deliberately NOT app-paintable, and deliberately no RGBA visual.
# A GtkWindow only renders its own CSS background when the
# application is not painting the window itself, and this surface
# is nothing but background. Setting app_paintable here is what
# made an earlier version of this window invisible.
GtkLayerShell.init_for_window(self)
# OVERLAY rather than TOP. The panel is a TOP layer surface and
# DVD mode has to cover it; OVERLAY is above it without depending
# on which surface the compositor happened to create first. It is
# taken down the moment playback is on screen, so it never sits
# over the video.
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY)
for edge in (
GtkLayerShell.Edge.TOP,
GtkLayerShell.Edge.BOTTOM,
GtkLayerShell.Edge.LEFT,
GtkLayerShell.Edge.RIGHT,
):
GtkLayerShell.set_anchor(self, edge, True)
# -1 means "do not move me out of anyone's way": the strip the
# dock reserves is part of the display like any other.
GtkLayerShell.set_exclusive_zone(self, -1)
GtkLayerShell.set_keyboard_interactivity(self, False)
card = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=scaled(14, scale),
)
card.get_style_context().add_class("dock")
card.get_style_context().add_class("dvd-loading")
self.spinner = Gtk.Spinner()
self.spinner.set_size_request(scaled(28, scale), scaled(28, scale))
card.pack_start(self.spinner, False, False, 0)
label = Gtk.Label(label="Loading DVD\u2026")
card.pack_start(label, False, False, 0)
centred = Gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0, yscale=0)
centred.add(card)
self.add(centred)
def show_all(self):
super().show_all()
self.spinner.start()
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 a compositor draws above a fullscreen
# window, so the classroom's controls stay over the video.
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")
image = icon(filename, scaled(ICON_SIZE, scale))
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
image.set_icon("media-pause.svg" if playing else "media-play.svg")
class DvdSession:
def __init__(self):
self.config = read_device_config()
self.scale = SHELL_SCALE
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.loading = LoadingSurface(self.scale)
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.loading.show_all()
self.bar.show_all()
# The cover goes up first. VLC follows once it is actually on
# screen, so no part of its window is ever seen against the
# wallpaper.
GLib.timeout_add(LAUNCH_DELAY_MS, self.launch)
def launch(self):
command = vlc.player_command(self.device, self.port, self.password)
try:
self.pid, _, _, _ = GLib.spawn_async(
argv=command,
flags=GLib.SpawnFlags.SEARCH_PATH
| GLib.SpawnFlags.DO_NOT_REAP_CHILD,
)
except GLib.Error as error:
# A cover with nothing behind it is the one outcome DVD mode
# must never produce.
print(f"classroom-dvd-session: {error}", file=sys.stderr)
Gtk.main_quit()
return GLib.SOURCE_REMOVE
GLib.child_watch_add(GLib.PRIORITY_DEFAULT, self.pid, self.on_exit)
GLib.timeout_add(READY_POLL_MS, self.watch_startup)
GLib.timeout_add_seconds(STATUS_POLL_SECONDS, self.watch_state)
return GLib.SOURCE_REMOVE
def on_exit(self, pid, status, *_):
GLib.spawn_close_pid(pid)
self.pid = None
Gtk.main_quit()
def player_is_ready(self):
"""True once there is something worth uncovering.
Three things, none of which asks the window manager anything:
VLC is playing or paused, VLC was told to be fullscreen and still
thinks it is, and the cover has been up for its minimum.
"""
if not self.control.wants_fullscreen():
return False
return self.elapsed >= MINIMUM_COVER_SECONDS
def watch_startup(self):
"""Hold the cover until VLC is genuinely ready to replace it.
Three things have to line up: VLC says it is playing, it is still
set fullscreen, and a moment has passed for the disc menu to draw.
The deadline is the safety net: DVD mode must never leave a cover
over a screen with nothing behind it, even if the disc never
opens.
"""
self.elapsed += READY_POLL_MS / 1000
# The cheap local check first; the tree is only worth walking
# once VLC claims to be playing something.
playing = self.control.state() in ("playing", "paused")
ready = playing and self.player_is_ready()
if ready or self.elapsed >= LOADING_DEADLINE_SECONDS:
GLib.timeout_add(STABILISE_MS, self.drop_loading)
return GLib.SOURCE_REMOVE
return GLib.SOURCE_CONTINUE
def drop_loading(self):
self.loading.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(scale)).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(SHELL_SCALE)
session = DvdSession()
session.start()
Gtk.main()
if session.eject_on_exit:
optical.eject(session.device)
return 0
if __name__ == "__main__":
sys.exit(main())