The loading surface was invisible. GtkWindow only renders its own CSS background when the application is not painting the window itself, and this window was created app-paintable with no draw handler and no child — so it drew nothing at all. The black area during loading was never it: it was VLC's own window, tiled by sway into the usable area, which excludes the strip the dock reserves. That is why the dock and the wallpaper stayed on screen underneath. An earlier headless check missed this because that sway had no wallpaper configured, so an invisible surface and a black one looked identical. Rechecked with the real wallpaper and the real dock underneath. The surface now paints, and carries a spinner and "Loading DVD..." on the same translucent rounded card as the dock. It moves from the top layer to the overlay layer, because the dock is a top-layer surface and DVD mode has to cover it rather than depend on which surface sway created first. Its exclusive zone of -1 keeps sway from moving it out of the way of the dock's reserved strip. It goes up before VLC is started rather than alongside it, so no part of VLC's window is ever seen against the wallpaper, and comes down only once VLC reports playback on screen. The deadline that takes it down regardless is now twenty seconds, since an optical drive can be slow, and a failure to start VLC at all ends the session instead of leaving a cover with nothing behind it. Co-Authored-By: Claude Opus 5 <[email protected]>
659 lines
19 KiB
Python
Executable File
659 lines
19 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, 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 loading surface goes away as soon as playback is on 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.
|
|
WINDOW_POLL_MS = 500
|
|
LOADING_DEADLINE_SECONDS = 20
|
|
|
|
# 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):
|
|
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 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 dock is a TOP layer surface, and
|
|
# DVD mode has to cover it; OVERLAY is above it without depending
|
|
# on which surface sway happens to have created 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 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.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(WINDOW_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 watch_startup(self):
|
|
"""Hold the cover until playback is genuinely on screen.
|
|
|
|
Playing or paused means the video output exists, which is also
|
|
when VLC takes the display. 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 += WINDOW_POLL_MS / 1000
|
|
|
|
ready = self.control.state() in ("playing", "paused")
|
|
|
|
if ready or self.elapsed >= LOADING_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_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(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())
|