Files
raspberry-pi-mixer/src/ui/app.py
T
shawn cdf04bd8eb
Lint & Validate / lint (push) Has been cancelled
P6-R1: Audio/video streaming pipeline
- 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)
2026-05-19 22:13:51 -04:00

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()