#!/usr/bin/env python3 import subprocess import sys from datetime import datetime from pathlib import Path import gi gi.require_version("Gtk", "3.0") gi.require_version("GtkLayerShell", "0.1") from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell # Optional on purpose. The dock is the one thing the classroom cannot # afford to lose, so a missing DVD helper simply disables disc detection # instead of taking the whole shell down. sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") try: import classroom_optical as optical except ImportError: optical = None CONFIG_FILE = Path("/etc/ekklesia-classroom/device.conf") CSS_FILE = Path("/etc/ekklesia-classroom/dock/style.css") ICON_DIR = Path("/etc/ekklesia-classroom/icons") DVD_POLL_SECONDS = 2 def read_device_config(): config = {} if not CONFIG_FILE.exists(): return config for raw_line in CONFIG_FILE.read_text().splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) config[key.strip()] = value.strip() return config def enabled(config, key, default=False): if key not in config: return default return config[key].lower() in { "1", "true", "yes", "on", "enabled", } def launch(command): subprocess.Popen(command, start_new_session=True) def notify(title, message): launch([ "notify-send", "--app-name=Ekklesia Classroom", title, message, ]) def themed_icon(*names): theme = Gtk.IconTheme.get_default() for name in names: if theme.has_icon(name): return Gtk.Image.new_from_icon_name( name, Gtk.IconSize.DIALOG, ) return Gtk.Image.new_from_icon_name( "application-x-executable", Gtk.IconSize.DIALOG, ) def custom_icon(filename, size): path = ICON_DIR / filename if not path.exists(): return None pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( str(path), size, size, True, ) return Gtk.Image.new_from_pixbuf(pixbuf) class ClassroomShell(Gtk.Window): 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-shell") self.set_decorated(False) self.set_resizable(False) self.config = read_device_config() self.dvd_image = None self.disc_ready = None GtkLayerShell.init_for_window(self) GtkLayerShell.set_layer( self, GtkLayerShell.Layer.TOP, ) GtkLayerShell.set_anchor( self, GtkLayerShell.Edge.BOTTOM, True, ) GtkLayerShell.set_anchor( self, GtkLayerShell.Edge.LEFT, True, ) GtkLayerShell.set_anchor( self, GtkLayerShell.Edge.RIGHT, True, ) GtkLayerShell.set_exclusive_zone(self, 76) GtkLayerShell.set_keyboard_interactivity( self, False, ) layout = Gtk.Overlay() layout.set_size_request(-1, 76) dock = self.build_dock() dock_align = Gtk.Alignment( xalign=0.5, yalign=0.5, xscale=0, yscale=0, ) dock_align.add(dock) layout.add(dock_align) self.clock = Gtk.Label() self.clock.get_style_context().add_class("clock") clock_align = Gtk.Alignment( xalign=1.0, yalign=0.5, xscale=0, yscale=0, ) clock_align.set_padding( 0, 0, 0, 20, ) clock_align.add(self.clock) layout.add_overlay(clock_align) layout.set_overlay_pass_through(clock_align, True) self.add(layout) self.update_time() GLib.timeout_add_seconds( 15, self.update_time, ) def build_dock(self): dock = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=10, ) dock.get_style_context().add_class("dock") dock.pack_start( self.make_button( self.browser_icon(), lambda *_: launch( ["/usr/local/bin/classroom-browser"] ), ), False, False, 0, ) if enabled( self.config, "DVD_ENABLED", False, ): dock.pack_start( self.make_button( self.dvd_icon(), lambda *_: launch( ["/usr/local/bin/classroom-dvd", "toggle"] ), ), False, False, 0, ) self.start_disc_watch() dock.pack_start( self.make_button( self.sanctuary_icon(), lambda *_: notify( "Sanctuary", "Sanctuary live view is being prepared.", ), ), False, False, 0, ) return dock def make_button(self, image, callback): button = Gtk.Button() button.set_relief(Gtk.ReliefStyle.NONE) button.get_style_context().add_class( "dock-button" ) button.add(image) button.connect("clicked", callback) return button def browser_icon(self): image = custom_icon( "browser.svg", 28, ) if image is not None: return image image = themed_icon( "web-browser-symbolic", "internet-web-browser-symbolic", ) image.set_pixel_size(28) return image def dvd_icon(self): image = custom_icon( "dvd.svg", 40, ) if image is not None: self.dvd_image = image return image image = themed_icon( "media-optical-symbolic", "media-optical", ) image.set_pixel_size(28) return image def sanctuary_icon(self): is_live = enabled( self.config, "SANCTUARY_LIVE", False, ) filename = ( "live.svg" if is_live else "live-off.svg" ) image = custom_icon( filename, 38, ) if image is not None: return image image = themed_icon( "video-display-symbolic", "video-display", ) image.set_pixel_size(28) return image def start_disc_watch(self): if optical is None: return self.check_disc() GLib.timeout_add_seconds( DVD_POLL_SECONDS, self.check_disc, ) def check_disc(self): ready = optical.drive_status() == optical.CDS_DISC_OK # The disc indicator: the DVD logo sits back when the drive is # empty or unplugged and comes up to full strength once a disc is # ready, the same gray/colour language the LIVE icon uses. if self.dvd_image is not None: self.dvd_image.set_opacity(1.0 if ready else 0.55) # The first poll only records what is already in the drive, so a # disc left in over the week does not announce itself at boot. if self.disc_ready is not None and ready and not self.disc_ready: notify( "DVD Ready", "A disc is ready to play.", ) self.disc_ready = ready return True def update_time(self): self.clock.set_text( datetime.now().strftime("%-I:%M %p") ) return True def load_css(): provider = Gtk.CssProvider() provider.load_from_path( str(CSS_FILE) ) screen = Gdk.Screen.get_default() Gtk.StyleContext.add_provider_for_screen( screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) def main(): load_css() shell = ClassroomShell() shell.connect( "destroy", Gtk.main_quit, ) shell.show_all() Gtk.main() if __name__ == "__main__": main()