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]>
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect x="50" y="12" width="7" height="40" rx="3.5" fill="#ffffff"/><path fill="#ffffff" d="M28 13.6v36.8L44.6 32z"/><path fill="#ffffff" d="M7 13.6v36.8L23.6 32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect x="7" y="12" width="7" height="40" rx="3.5" fill="#ffffff"/><path fill="#ffffff" d="M36 13.6v36.8L19.4 32z"/><path fill="#ffffff" d="M57 13.6v36.8L40.4 32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path fill="#ffffff" d="M14.5 10.3 32 27.8l17.5-17.5a3.5 3.5 0 0 1 5 5L36.9 32.8l17.5 17.5a3.5 3.5 0 0 1-5 5L32 37.8 14.5 55.3a3.5 3.5 0 0 1-5-5l17.6-17.5L9.5 15.3a3.5 3.5 0 0 1 5-5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path fill="#ffffff" d="M11 9h42a5 5 0 0 1 5 5v36a5 5 0 0 1-5 5H11a5 5 0 0 1-5-5V14a5 5 0 0 1 5-5zm0 6a1 1 0 0 0-1 1v32a1 1 0 0 0 1 1h42a1 1 0 0 0 1-1V16a1 1 0 0 0-1-1H11z"/><path fill="#ffffff" d="M17 22h30a2.5 2.5 0 0 1 0 5H17a2.5 2.5 0 0 1 0-5zm0 9h30a2.5 2.5 0 0 1 0 5H17a2.5 2.5 0 0 1 0-5zm0 9h18a2.5 2.5 0 0 1 0 5H17a2.5 2.5 0 0 1 0-5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path fill="#ffffff" d="M29.7 9.6a3 3 0 0 1 4.6 0l21 25.4c1.6 2 .2 5-2.3 5h-42c-2.5 0-3.9-3-2.3-5l21-25.4z"/><path fill="#ffffff" d="M11 47h42a3.5 3.5 0 0 1 0 7H11a3.5 3.5 0 0 1 0-7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path fill="#ffffff" d="M16 10h9a3 3 0 0 1 3 3v38a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3V13a3 3 0 0 1 3-3zm23 0h9a3 3 0 0 1 3 3v38a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3V13a3 3 0 0 1 3-3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><path fill="#ffffff" d="M18 12.5c0-2.4 2.6-3.9 4.7-2.7l30 19.5c1.9 1.2 1.9 4 0 5.2l-30 19.5c-2.1 1.2-4.7-.3-4.7-2.7V12.5z"/></svg>
|
||||||
|
After Width: | Height: | Size: 214 B |
@@ -19,8 +19,10 @@ exec --no-startup-id /usr/local/bin/classroom-dock
|
|||||||
|
|
||||||
include /etc/sway/config.d/*
|
include /etc/sway/config.d/*
|
||||||
|
|
||||||
# The DVD player is an appliance screen, not a desktop window: no sway
|
# The DVD player is an appliance screen, not a desktop window. VLC takes
|
||||||
# title bar, and it opens fullscreen over the dock.
|
# 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 [class="(?i)vlc"] border none
|
||||||
for_window [app_id="(?i)vlc"] border none
|
for_window [app_id="(?i)vlc"] border none
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
Pressing DVD does not open a media player. It enters **DVD mode**: a
|
||||||
VLC's `dvdnav` module is what provides the disc menus, and `dvd://`
|
full-screen playback surface the classroom owns, with VLC decoding the
|
||||||
selects it.
|
disc behind it.
|
||||||
|
|
||||||
`classroom-dvd toggle` hands the disc to VLC through `classroom-window`,
|
`classroom-dvd-session` runs the whole thing:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
VLC is still the backend because its `dvdnav` module is what decodes
|
||||||
where the TV is the whole interface. It is dressed down with documented
|
disc menus, and nothing else on Debian does that reliably. But nothing
|
||||||
VLC 3.x options — no skin is involved, because skins replace the
|
of VLC's own interface is exposed: no menu bar, no panels, no
|
||||||
interface wholesale and take DVD menu input with them:
|
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 |
|
| ⏮ | Previous chapter |
|
||||||
| `--qt-minimal-view` | No menu bar or extended panels |
|
| ▶ / ⏸ | Play or pause, showing which one it will do |
|
||||||
| `--qt-dark-palette` | Dark window instead of the default light grey |
|
| ⏭ | Next chapter |
|
||||||
| `--no-qt-bgcone` | No orange cone, no album-art panel |
|
| ☰ | Back to the disc menu |
|
||||||
| `--no-qt-system-tray` | Not a background desktop application |
|
| ⏏ | Ejects the disc and leaves DVD mode |
|
||||||
| `--no-qt-name-in-title` | Nothing in the title bar to read |
|
| ✕ | Leaves DVD mode |
|
||||||
| `--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 |
|
|
||||||
|
|
||||||
sway also draws no border on the VLC window
|
While the bar is hidden it only accepts the pointer in a thin strip
|
||||||
(`for_window [class="(?i)vlc"] border none`), so there is no title bar
|
along the very bottom edge, so a click anywhere else goes to the disc
|
||||||
above the video either.
|
menu rather than to an invisible bar.
|
||||||
|
|
||||||
The auto-hiding fullscreen controller is deliberately **kept**. It is
|
The bar reaches VLC over an HTTP control interface on the loopback
|
||||||
the only way to pause or change chapter with a mouse, it is not
|
address, behind a password generated for that session alone. Commands
|
||||||
persistent chrome, and a volunteer with a wireless mouse needs it.
|
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
|
### The keyboard
|
||||||
takes the keyboard and mouse handling that DVD menu navigation depends
|
|
||||||
on, so it is not an option here.
|
|
||||||
|
|
||||||
Note that `--no-qt-updates-notif`, which earlier classroom builds
|
All 108 hotkeys VLC 3.0.23 defines are unset, then these are bound:
|
||||||
passed, is not an option in Debian's VLC at all — it made VLC exit
|
|
||||||
before it started.
|
| 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
|
## Commercial (CSS encrypted) discs
|
||||||
|
|
||||||
|
|||||||
@@ -119,12 +119,34 @@ With `DVD_ENABLED=true`:
|
|||||||
- [ ] Closing the player works
|
- [ ] Closing the player works
|
||||||
- [ ] Ejecting is detected, the icon goes back
|
- [ ] Ejecting is detected, the icon goes back
|
||||||
- [ ] Reinserting is detected again
|
- [ ] 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
|
- [ ] Commercial CSS disc plays without anyone having run codec setup
|
||||||
by hand
|
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
|
## OTA
|
||||||
|
|
||||||
- [ ] `./update_canary.sh` publishes package first, manifest last
|
- [ ] `./update_canary.sh` publishes package first, manifest last
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,7 +4,7 @@ Section: misc
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Ekklesia
|
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
|
Recommends: vlc
|
||||||
Description: Ekklesia managed classroom environment
|
Description: Ekklesia managed classroom environment
|
||||||
A minimal Wayland classroom environment for Ekklesia-managed church computers.
|
A minimal Wayland classroom environment for Ekklesia-managed church computers.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ chmod 0644 /home/classroom/.bash_profile
|
|||||||
chmod 0755 /usr/local/bin/classroom-browser
|
chmod 0755 /usr/local/bin/classroom-browser
|
||||||
chmod 0755 /usr/local/bin/classroom-window
|
chmod 0755 /usr/local/bin/classroom-window
|
||||||
chmod 0755 /usr/local/bin/classroom-dvd
|
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-dvd-css-setup
|
||||||
chmod 0755 /usr/local/bin/classroom-notify-test
|
chmod 0755 /usr/local/bin/classroom-notify-test
|
||||||
chmod 0755 /usr/local/bin/classroom-notifications
|
chmod 0755 /usr/local/bin/classroom-notifications
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ sys.path.insert(
|
|||||||
)
|
)
|
||||||
|
|
||||||
import classroom_optical as optical # noqa: E402
|
import classroom_optical as optical # noqa: E402
|
||||||
|
from classroom_vlc import PLAYER # noqa: E402
|
||||||
|
|
||||||
PLAYER = "vlc"
|
|
||||||
WINDOW_TOOL = "/usr/local/bin/classroom-window"
|
WINDOW_TOOL = "/usr/local/bin/classroom-window"
|
||||||
|
SESSION_TOOL = "/usr/local/bin/classroom-dvd-session"
|
||||||
|
|
||||||
|
|
||||||
def notify(title, message, urgency="normal"):
|
def notify(title, message, urgency="normal"):
|
||||||
@@ -42,49 +43,15 @@ def notify(title, message, urgency="normal"):
|
|||||||
print(f"{title}: {message}", file=sys.stderr)
|
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):
|
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:
|
if shutil.which(PLAYER) is None:
|
||||||
notify(
|
notify(
|
||||||
"DVD",
|
"DVD",
|
||||||
@@ -99,7 +66,7 @@ def play(device):
|
|||||||
"--match",
|
"--match",
|
||||||
"(?i)vlc",
|
"(?i)vlc",
|
||||||
"--",
|
"--",
|
||||||
*player_command(device),
|
SESSION_TOOL,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"<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())
|
||||||
@@ -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
|
||||||
@@ -105,6 +105,10 @@ install -Dm755 \
|
|||||||
"$ROOT/scripts/classroom-dvd" \
|
"$ROOT/scripts/classroom-dvd" \
|
||||||
"$STAGE/usr/local/bin/classroom-dvd"
|
"$STAGE/usr/local/bin/classroom-dvd"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-dvd-session" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-dvd-session"
|
||||||
|
|
||||||
install -Dm755 \
|
install -Dm755 \
|
||||||
"$ROOT/scripts/classroom-dvd-css-setup" \
|
"$ROOT/scripts/classroom-dvd-css-setup" \
|
||||||
"$STAGE/usr/local/bin/classroom-dvd-css-setup"
|
"$STAGE/usr/local/bin/classroom-dvd-css-setup"
|
||||||
@@ -130,6 +134,14 @@ install -Dm644 \
|
|||||||
"$ROOT/lib/classroom_config.py" \
|
"$ROOT/lib/classroom_config.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/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 \
|
install -Dm644 \
|
||||||
"$ROOT/lib/classroom_optical.py" \
|
"$ROOT/lib/classroom_optical.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py"
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py"
|
||||||
@@ -199,6 +211,14 @@ install -Dm644 \
|
|||||||
"$ROOT/config/icons/browser.svg" \
|
"$ROOT/config/icons/browser.svg" \
|
||||||
"$STAGE/etc/ekklesia-classroom/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"
|
printf '%s\n' "$VERSION" > "$ROOT/VERSION"
|
||||||
|
|
||||||
dpkg-deb \
|
dpkg-deb \
|
||||||
|
|||||||