cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
- src/streaming/ module: camera detection (USB webcam + Pi Camera Module via v4l2/libcamera), GStreamer pipeline builder with V4L2/OMX/x264 h264 encoders, platform presets (YouTube, Twitch, Facebook, custom RTMP), streamer lifecycle manager with auto-reconnect, scene management, stream statistics - Keyboard shortcut controller (evdev + pynput fallback) for stream control: Ctrl+Shift+S (start/stop), Ctrl+Shift+1-9 (scene switch), Ctrl+Shift+R (reconnect) - REST API: /api/v1/stream/* endpoints for start/stop/status, scenes, cameras, platforms, and hotkey listing — integrated into NetworkServer - StreamStateProvider bridging layer connects REST routes to Streamer instance - GStreamer pipeline supports ALSA/JACK/PulseAudio audio capture and V4L2/libcamera video capture with H.264 hardware encoding on RPi4B - Low-latency streaming settings for live performance (zerolatency, no B-frames) - 89 streaming tests (709 total project tests pass)
289 lines
9.8 KiB
Python
289 lines
9.8 KiB
Python
"""Kivy application entry point for the touchscreen mixer UI.
|
|
|
|
Provides a ScreenManager with four screens:
|
|
- Mixer surface (faders, meters, mute/solo)
|
|
- Routing matrix
|
|
- Plugin chain
|
|
- Settings (brightness, timeout, DPI)
|
|
|
|
Designed for Raspberry Pi 5"/7" touch displays.
|
|
Runs fullscreen at native resolution with DPI-aware layout.
|
|
|
|
Usage:
|
|
python3 main_touch.py # connect to localhost:8000
|
|
python3 main_touch.py --host 192.168.1.10 # remote mixer
|
|
KIVY_DPI=220 python3 main_touch.py # override DPI
|
|
|
|
Environment:
|
|
MIXER_HOST — mixer server hostname (default 127.0.0.1)
|
|
MIXER_PORT — REST API port (default 8000)
|
|
MIXER_OSC_PORT — OSC server port (default 9001)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import logging
|
|
|
|
from kivy.app import App
|
|
from kivy.clock import Clock
|
|
from kivy.config import Config
|
|
from kivy.core.window import Window
|
|
from kivy.properties import ObjectProperty
|
|
from kivy.uix.boxlayout import BoxLayout
|
|
from kivy.uix.button import Button
|
|
from kivy.uix.label import Label
|
|
from kivy.uix.screenmanager import ScreenManager, Screen, FadeTransition, SlideTransition
|
|
from kivy.metrics import dp
|
|
|
|
from .ipc import MixerClient
|
|
from .theme import Colors, Fonts, Sizes
|
|
from .screens.mixer import MixerScreen
|
|
from .screens.routing import RoutingScreen
|
|
from .screens.plugins import PluginScreen
|
|
from .screens.settings import SettingsScreen
|
|
from .screens.sessions import SessionsScreen
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Touch-optimized Kivy config ──────────────────────────────────────────────
|
|
|
|
def _configure_kivy():
|
|
"""Apply touch-optimized Kivy settings before window creation."""
|
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
|
Config.set("kivy", "window_icon", "")
|
|
Config.set("kivy", "exit_on_escape", 0) # No accidental exit
|
|
Config.set("graphics", "fullscreen", "auto")
|
|
Config.set("graphics", "show_cursor", 0) # Hide cursor on touchscreen
|
|
Config.set("graphics", "resizable", 0) # Fixed size for embedded
|
|
|
|
# Allow environment overrides
|
|
if os.environ.get("KIVY_SHOW_CURSOR"):
|
|
Config.set("graphics", "show_cursor", 1)
|
|
|
|
|
|
# ── Tab bar ──────────────────────────────────────────────────────────────────
|
|
|
|
class TabBar(BoxLayout):
|
|
"""Bottom tab bar for screen switching.
|
|
|
|
Four tabs: Mixer, Routing, Plugins, Settings.
|
|
Touch-optimized: large hit targets, clear active indicator.
|
|
"""
|
|
|
|
screen_manager = ObjectProperty(None)
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.orientation = "horizontal"
|
|
self.size_hint = (1, None)
|
|
self.height = Sizes.TAB_BAR_HEIGHT
|
|
self.spacing = 0
|
|
|
|
self._tabs: dict[str, Button] = {}
|
|
self._active_tab = "mixer"
|
|
|
|
tabs = [
|
|
("mixer", "MIX"),
|
|
("routing", "RTG"),
|
|
("plugins", "PLG"),
|
|
("sessions", "SES"),
|
|
("settings", "SET"),
|
|
]
|
|
for name, label in tabs:
|
|
btn = Button(
|
|
text=label,
|
|
background_normal="",
|
|
background_color=Colors.BG_HEADER,
|
|
color=Colors.ACCENT if name == "mixer" else Colors.TEXT_MUTED,
|
|
font_size=Fonts.SMALL,
|
|
bold=True,
|
|
)
|
|
btn.bind(on_press=self._make_switch(name))
|
|
self._tabs[name] = btn
|
|
self.add_widget(btn)
|
|
|
|
def _make_switch(self, screen_name: str):
|
|
def cb(instance):
|
|
if self.screen_manager:
|
|
self.screen_manager.current = screen_name
|
|
self._set_active(screen_name)
|
|
return cb
|
|
|
|
def _set_active(self, name: str):
|
|
self._active_tab = name
|
|
for tab_name, btn in self._tabs.items():
|
|
if tab_name == name:
|
|
btn.color = Colors.ACCENT
|
|
btn.font_size = Fonts.BODY
|
|
else:
|
|
btn.color = Colors.TEXT_MUTED
|
|
btn.font_size = Fonts.SMALL
|
|
|
|
def switch_to(self, name: str):
|
|
"""External switch (e.g., from gesture)."""
|
|
self._set_active(name)
|
|
if self.screen_manager:
|
|
self.screen_manager.current = name
|
|
|
|
|
|
# ── Root widget ──────────────────────────────────────────────────────────────
|
|
|
|
class MixerRoot(BoxLayout):
|
|
"""Root layout: header + screen area + tab bar."""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.orientation = "vertical"
|
|
|
|
# Status bar (top)
|
|
self._status_bar = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(1, None),
|
|
height=Sizes.HEADER_HEIGHT,
|
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
|
)
|
|
self._status_bar.bg_color = Colors.BG_HEADER
|
|
|
|
self._status_label = Label(
|
|
text="● Connected",
|
|
color=Colors.ACTIVE,
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(120),
|
|
halign="left",
|
|
)
|
|
self._status_label.bind(size=self._status_label.setter("text_size"))
|
|
|
|
self._time_label = Label(
|
|
text="",
|
|
color=Colors.TEXT_MUTED,
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(1, 1),
|
|
halign="right",
|
|
)
|
|
|
|
self._status_bar.add_widget(self._status_label)
|
|
self._status_bar.add_widget(self._time_label)
|
|
|
|
# Screen manager (center)
|
|
self.screen_manager = ScreenManager(transition=FadeTransition(duration=0.2))
|
|
|
|
# Screens
|
|
self.mixer_screen = MixerScreen(name="mixer")
|
|
self.routing_screen = RoutingScreen(name="routing")
|
|
self.plugin_screen = PluginScreen(name="plugins")
|
|
self.sessions_screen = SessionsScreen(name="sessions")
|
|
self.settings_screen = SettingsScreen(name="settings")
|
|
|
|
self.screen_manager.add_widget(self.mixer_screen)
|
|
self.screen_manager.add_widget(self.routing_screen)
|
|
self.screen_manager.add_widget(self.plugin_screen)
|
|
self.screen_manager.add_widget(self.sessions_screen)
|
|
self.screen_manager.add_widget(self.settings_screen)
|
|
|
|
# Tab bar (bottom)
|
|
self.tab_bar = TabBar(screen_manager=self.screen_manager)
|
|
|
|
self.add_widget(self._status_bar)
|
|
self.add_widget(self.screen_manager)
|
|
self.add_widget(self.tab_bar)
|
|
|
|
def set_client(self, client: MixerClient):
|
|
"""Inject the mixer client into all screens."""
|
|
self.mixer_screen.client = client
|
|
self.routing_screen.client = client
|
|
self.plugin_screen.client = client
|
|
self.sessions_screen.client = client
|
|
self.settings_screen.client = client
|
|
|
|
def update_status(self, connected: bool, info: str = ""):
|
|
"""Update the status bar."""
|
|
if connected:
|
|
self._status_label.text = "● Connected" + (f" {info}" if info else "")
|
|
self._status_label.color = Colors.ACTIVE
|
|
else:
|
|
self._status_label.text = "○ Disconnected"
|
|
self._status_label.color = Colors.MUTE_ON
|
|
|
|
|
|
# ── Kivy App ─────────────────────────────────────────────────────────────────
|
|
|
|
class MixerApp(App):
|
|
"""Touchscreen mixer application.
|
|
|
|
Args to build():
|
|
host: Mixer server hostname.
|
|
rest_port: REST API port.
|
|
osc_port: OSC server port.
|
|
api_key: API key for authentication.
|
|
"""
|
|
|
|
client: MixerClient = None # type: ignore
|
|
|
|
def __init__(self, host="127.0.0.1", rest_port=8000, osc_port=9001, api_key="", **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._host = host
|
|
self._rest_port = rest_port
|
|
self._osc_port = osc_port
|
|
self._api_key = api_key
|
|
|
|
def build(self):
|
|
"""Build the UI and return the root widget."""
|
|
_configure_kivy()
|
|
|
|
# Set window properties
|
|
Window.clearcolor = Colors.BG_DARK
|
|
Window.bind(on_key_down=self._on_key_down)
|
|
|
|
self.client = MixerClient(
|
|
host=self._host,
|
|
rest_port=self._rest_port,
|
|
osc_port=self._osc_port,
|
|
api_key=self._api_key,
|
|
)
|
|
|
|
self.root_widget = MixerRoot()
|
|
self.root_widget.set_client(self.client)
|
|
|
|
# Periodic health check
|
|
Clock.schedule_interval(self._health_check, 5.0)
|
|
|
|
# Update clock
|
|
Clock.schedule_interval(self._update_clock, 1.0)
|
|
|
|
return self.root_widget
|
|
|
|
def _health_check(self, dt):
|
|
"""Check mixer server connectivity."""
|
|
self.client.fetch_state(
|
|
on_success=lambda state: self.root_widget.update_status(True),
|
|
on_error=lambda e: self.root_widget.update_status(False, str(e)[:20]),
|
|
)
|
|
|
|
def _update_clock(self, dt):
|
|
"""Update the time display."""
|
|
import time
|
|
t = time.localtime()
|
|
self.root_widget._time_label.text = time.strftime("%H:%M:%S", t)
|
|
|
|
def _on_key_down(self, window, key, scancode, codepoint, modifier):
|
|
"""Handle keyboard shortcuts."""
|
|
# ESC to toggle between screens
|
|
if key == 27: # ESC
|
|
sm = self.root_widget.screen_manager
|
|
screens = sm.screen_names
|
|
idx = screens.index(sm.current)
|
|
next_idx = (idx + 1) % len(screens)
|
|
sm.current = screens[next_idx]
|
|
self.root_widget.tab_bar.switch_to(screens[next_idx])
|
|
return True
|
|
return False
|
|
|
|
def on_stop(self):
|
|
"""Cleanup on app exit."""
|
|
if self.client:
|
|
self.client.close()
|