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)
636 lines
20 KiB
Python
636 lines
20 KiB
Python
"""Sessions screen — save/load mixer sessions, setlists, and snapshots.
|
|
|
|
Touch-optimized layout for Raspberry Pi 5"/7" displays.
|
|
Provides:
|
|
- Session list with load/delete buttons
|
|
- Save current session button
|
|
- Snapshot capture/recall
|
|
- Setlist browser
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from kivy.properties import ObjectProperty, StringProperty, ListProperty
|
|
from kivy.uix.boxlayout import BoxLayout
|
|
from kivy.uix.button import Button
|
|
from kivy.uix.label import Label
|
|
from kivy.uix.screenmanager import Screen
|
|
from kivy.uix.scrollview import ScrollView
|
|
from kivy.uix.textinput import TextInput
|
|
from kivy.uix.togglebutton import ToggleButton
|
|
from kivy.metrics import dp
|
|
from kivy.clock import Clock
|
|
|
|
from ..theme import Colors, Fonts, Sizes
|
|
|
|
|
|
class SessionCard(BoxLayout):
|
|
"""A single session/snapshot entry card."""
|
|
|
|
session_name = StringProperty("")
|
|
session_info = StringProperty("")
|
|
is_snapshot = False
|
|
|
|
def __init__(self, name="", info="", is_snapshot=False, on_load=None,
|
|
on_delete=None, on_recall=None, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.orientation = "horizontal"
|
|
self.size_hint = (1, None)
|
|
self.height = dp(52)
|
|
self.padding = [dp(8), dp(4)]
|
|
self.spacing = dp(6)
|
|
self._name = name
|
|
self._is_snapshot = is_snapshot
|
|
|
|
icon = "📸" if is_snapshot else "💾"
|
|
|
|
# Name + info
|
|
info_layout = BoxLayout(orientation="vertical", size_hint=(1, 1))
|
|
name_label = Label(
|
|
text=f"{icon} {name}",
|
|
color=Colors.TEXT,
|
|
font_size=Fonts.SMALL,
|
|
halign="left",
|
|
valign="middle",
|
|
size_hint=(1, 1),
|
|
text_size=(None, None),
|
|
)
|
|
name_label.bind(size=name_label.setter("text_size"))
|
|
info_label = Label(
|
|
text=info,
|
|
color=Colors.TEXT_MUTED,
|
|
font_size=Fonts.XSMALL,
|
|
halign="left",
|
|
size_hint=(1, 0.5),
|
|
text_size=(None, None),
|
|
)
|
|
info_label.bind(size=info_label.setter("text_size"))
|
|
info_layout.add_widget(name_label)
|
|
info_layout.add_widget(info_label)
|
|
|
|
# Buttons
|
|
btn_layout = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(None, 1),
|
|
width=dp(140),
|
|
spacing=dp(4),
|
|
)
|
|
|
|
if is_snapshot:
|
|
load_btn = Button(
|
|
text="Recall",
|
|
font_size=Fonts.XSMALL,
|
|
size_hint=(1, 1),
|
|
background_color=Colors.BG_BUTTON,
|
|
color=Colors.TEXT,
|
|
)
|
|
if on_recall:
|
|
load_btn.bind(on_press=lambda x: on_recall(name))
|
|
else:
|
|
load_btn = Button(
|
|
text="Load",
|
|
font_size=Fonts.XSMALL,
|
|
size_hint=(1, 1),
|
|
background_color=Colors.BG_BUTTON,
|
|
color=Colors.TEXT,
|
|
)
|
|
if on_load:
|
|
load_btn.bind(on_press=lambda x: on_load(name))
|
|
|
|
del_btn = Button(
|
|
text="✕",
|
|
font_size=Fonts.XSMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(40),
|
|
background_color=Colors.MUTE_ON,
|
|
color=Colors.TEXT,
|
|
)
|
|
if on_delete:
|
|
del_btn.bind(on_press=lambda x: on_delete(name))
|
|
|
|
btn_layout.add_widget(load_btn)
|
|
btn_layout.add_widget(del_btn)
|
|
|
|
self.add_widget(info_layout)
|
|
self.add_widget(btn_layout)
|
|
|
|
|
|
class SessionsScreen(Screen):
|
|
"""Session management screen for the touch UI."""
|
|
|
|
client = ObjectProperty(None)
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.name = "sessions"
|
|
self._sessions_data = []
|
|
self._snapshots_data = []
|
|
self._current_subtab = "sessions"
|
|
|
|
root = BoxLayout(orientation="vertical")
|
|
|
|
# Header
|
|
header = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(1, None),
|
|
height=Sizes.HEADER_HEIGHT,
|
|
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
|
)
|
|
header.bg_color = Colors.BG_HEADER
|
|
title = Label(
|
|
text="SESSIONS",
|
|
color=Colors.ACCENT,
|
|
font_size=Fonts.HEADER,
|
|
bold=True,
|
|
size_hint=(None, 1),
|
|
width=dp(150),
|
|
halign="left",
|
|
)
|
|
title.bind(size=title.setter("text_size"))
|
|
|
|
# Sub-tab bar
|
|
sub_tabs = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(1, None),
|
|
height=dp(40),
|
|
spacing=0,
|
|
)
|
|
sub_tabs.bg_color = Colors.BG_DARK
|
|
|
|
self._tab_sessions = ToggleButton(
|
|
text="Sessions",
|
|
font_size=Fonts.SMALL,
|
|
group="subtab",
|
|
state="down",
|
|
background_color=Colors.BG_HEADER,
|
|
color=Colors.ACCENT,
|
|
)
|
|
self._tab_setlists = ToggleButton(
|
|
text="Setlists",
|
|
font_size=Fonts.SMALL,
|
|
group="subtab",
|
|
background_color=Colors.BG_HEADER,
|
|
color=Colors.TEXT_MUTED,
|
|
)
|
|
self._tab_snapshots = ToggleButton(
|
|
text="Snapshots",
|
|
font_size=Fonts.SMALL,
|
|
group="subtab",
|
|
background_color=Colors.BG_HEADER,
|
|
color=Colors.TEXT_MUTED,
|
|
)
|
|
|
|
self._tab_sessions.bind(on_press=lambda x: self._switch_subtab("sessions"))
|
|
self._tab_setlists.bind(on_press=lambda x: self._switch_subtab("setlists"))
|
|
self._tab_snapshots.bind(on_press=lambda x: self._switch_subtab("snapshots"))
|
|
|
|
sub_tabs.add_widget(self._tab_sessions)
|
|
sub_tabs.add_widget(self._tab_setlists)
|
|
sub_tabs.add_widget(self._tab_snapshots)
|
|
|
|
# Save bar
|
|
save_bar = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(1, None),
|
|
height=dp(44),
|
|
padding=[dp(8), dp(4)],
|
|
spacing=dp(6),
|
|
)
|
|
self._name_input = TextInput(
|
|
text="",
|
|
hint_text="Session name...",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(1, 1),
|
|
background_color=Colors.BG_INPUT,
|
|
foreground_color=Colors.TEXT,
|
|
multiline=False,
|
|
)
|
|
self._notes_input = TextInput(
|
|
text="",
|
|
hint_text="Notes...",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(0.6, 1),
|
|
background_color=Colors.BG_INPUT,
|
|
foreground_color=Colors.TEXT,
|
|
multiline=False,
|
|
)
|
|
save_btn = Button(
|
|
text="Save",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(80),
|
|
background_color=Colors.ACCENT,
|
|
color=Colors.BG_DARK,
|
|
)
|
|
save_btn.bind(on_press=self._on_save)
|
|
|
|
save_bar.add_widget(self._name_input)
|
|
save_bar.add_widget(self._notes_input)
|
|
save_bar.add_widget(save_btn)
|
|
|
|
# Snapshot bar (hidden by default)
|
|
self._snapshot_bar = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(1, None),
|
|
height=dp(44),
|
|
padding=[dp(8), dp(4)],
|
|
spacing=dp(6),
|
|
)
|
|
self._snap_name_input = TextInput(
|
|
text="",
|
|
hint_text="Snapshot name...",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(1, 1),
|
|
background_color=Colors.BG_INPUT,
|
|
foreground_color=Colors.TEXT,
|
|
multiline=False,
|
|
)
|
|
snap_btn = Button(
|
|
text="Capture",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(80),
|
|
background_color=Colors.ACCENT,
|
|
color=Colors.BG_DARK,
|
|
)
|
|
snap_btn.bind(on_press=self._on_capture_snapshot)
|
|
|
|
next_btn = Button(
|
|
text="▶",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(44),
|
|
background_color=Colors.BG_BUTTON,
|
|
color=Colors.TEXT,
|
|
)
|
|
next_btn.bind(on_press=self._on_snapshot_next)
|
|
|
|
prev_btn = Button(
|
|
text="◀",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(44),
|
|
background_color=Colors.BG_BUTTON,
|
|
color=Colors.TEXT,
|
|
)
|
|
prev_btn.bind(on_press=self._on_snapshot_prev)
|
|
|
|
self._snapshot_bar.add_widget(self._snap_name_input)
|
|
self._snapshot_bar.add_widget(snap_btn)
|
|
self._snapshot_bar.add_widget(prev_btn)
|
|
self._snapshot_bar.add_widget(next_btn)
|
|
|
|
# Scrollable list area
|
|
self._list_scroll = ScrollView(size_hint=(1, 1), do_scroll_x=False)
|
|
self._list_layout = BoxLayout(
|
|
orientation="vertical",
|
|
size_hint=(1, None),
|
|
spacing=dp(2),
|
|
padding=[dp(4), dp(4)],
|
|
)
|
|
self._list_layout.bind(minimum_height=self._list_layout.setter("height"))
|
|
self._list_scroll.add_widget(self._list_layout)
|
|
|
|
# Setlist view (hidden by default)
|
|
self._setlist_view = BoxLayout(
|
|
orientation="vertical",
|
|
size_hint=(1, 1),
|
|
spacing=dp(4),
|
|
)
|
|
self._setlist_view.opacity = 0
|
|
self._setlist_view.disabled = True
|
|
|
|
self._setlist_list = BoxLayout(
|
|
orientation="vertical",
|
|
size_hint=(1, 1),
|
|
spacing=dp(2),
|
|
padding=[dp(4), dp(4)],
|
|
)
|
|
|
|
setlist_bar = BoxLayout(
|
|
orientation="horizontal",
|
|
size_hint=(1, None),
|
|
height=dp(44),
|
|
padding=[dp(8), dp(4)],
|
|
spacing=dp(6),
|
|
)
|
|
self._setlist_name_input = TextInput(
|
|
text="",
|
|
hint_text="Setlist name...",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(1, 1),
|
|
background_color=Colors.BG_INPUT,
|
|
foreground_color=Colors.TEXT,
|
|
multiline=False,
|
|
)
|
|
new_setlist_btn = Button(
|
|
text="Create",
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(None, 1),
|
|
width=dp(80),
|
|
background_color=Colors.ACCENT,
|
|
color=Colors.BG_DARK,
|
|
)
|
|
new_setlist_btn.bind(on_press=self._on_create_setlist)
|
|
setlist_bar.add_widget(self._setlist_name_input)
|
|
setlist_bar.add_widget(new_setlist_btn)
|
|
|
|
self._setlist_view.add_widget(setlist_bar)
|
|
self._setlist_view.add_widget(self._setlist_list)
|
|
|
|
# Content area
|
|
content = BoxLayout(orientation="vertical", size_hint=(1, 1))
|
|
content.add_widget(self._list_scroll)
|
|
content.add_widget(self._setlist_view)
|
|
|
|
# Combine
|
|
header.add_widget(title)
|
|
root.add_widget(header)
|
|
root.add_widget(sub_tabs)
|
|
root.add_widget(save_bar)
|
|
root.add_widget(self._snapshot_bar)
|
|
root.add_widget(content)
|
|
|
|
self.add_widget(root)
|
|
|
|
# Refresh periodically
|
|
Clock.schedule_interval(self._refresh, 10.0)
|
|
|
|
def on_enter(self, *args):
|
|
"""Called when the screen is displayed."""
|
|
self._refresh()
|
|
|
|
def _switch_subtab(self, tab: str):
|
|
"""Switch between sessions/setlists/snapshots."""
|
|
self._current_subtab = tab
|
|
|
|
# Update toggle states
|
|
self._tab_sessions.state = "down" if tab == "sessions" else "normal"
|
|
self._tab_setlists.state = "down" if tab == "setlists" else "normal"
|
|
self._tab_snapshots.state = "down" if tab == "snapshots" else "normal"
|
|
|
|
# Toggle visibility
|
|
if tab == "setlists":
|
|
self._list_scroll.opacity = 0
|
|
self._list_scroll.disabled = True
|
|
self._setlist_view.opacity = 1
|
|
self._setlist_view.disabled = False
|
|
self._snapshot_bar.opacity = 0
|
|
self._snapshot_bar.disabled = True
|
|
elif tab == "snapshots":
|
|
self._list_scroll.opacity = 1
|
|
self._list_scroll.disabled = False
|
|
self._setlist_view.opacity = 0
|
|
self._setlist_view.disabled = True
|
|
self._snapshot_bar.opacity = 1
|
|
self._snapshot_bar.disabled = False
|
|
else:
|
|
self._list_scroll.opacity = 1
|
|
self._list_scroll.disabled = False
|
|
self._setlist_view.opacity = 0
|
|
self._setlist_view.disabled = True
|
|
self._snapshot_bar.opacity = 0
|
|
self._snapshot_bar.disabled = True
|
|
|
|
self._refresh()
|
|
|
|
def _refresh(self, *args):
|
|
"""Refresh the session list from the REST API."""
|
|
if not self.client:
|
|
return
|
|
|
|
if self._current_subtab in ("sessions", "snapshots"):
|
|
self._fetch_sessions()
|
|
if self._current_subtab == "snapshots":
|
|
self._fetch_snapshots()
|
|
elif self._current_subtab == "setlists":
|
|
self._fetch_setlists()
|
|
|
|
def _fetch_sessions(self):
|
|
"""Fetch sessions list from REST API."""
|
|
try:
|
|
self.client.rest_get(
|
|
"/api/v1/sessions",
|
|
on_success=self._on_sessions_loaded,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _fetch_snapshots(self):
|
|
"""Fetch snapshots list from REST API."""
|
|
try:
|
|
self.client.rest_get(
|
|
"/api/v1/snapshots",
|
|
on_success=self._on_snapshots_loaded,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _fetch_setlists(self):
|
|
"""Fetch setlists list from REST API."""
|
|
try:
|
|
self.client.rest_get(
|
|
"/api/v1/setlists",
|
|
on_success=self._on_setlists_loaded,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_sessions_loaded(self, data: dict):
|
|
"""Render session cards."""
|
|
sessions = data.get("sessions", [])
|
|
self._sessions_data = sessions
|
|
self._render_session_list()
|
|
|
|
def _on_snapshots_loaded(self, data: dict):
|
|
"""Render snapshot cards."""
|
|
snapshots = data.get("snapshots", [])
|
|
self._snapshots_data = snapshots
|
|
self._render_session_list()
|
|
|
|
def _render_session_list(self):
|
|
"""Build the session/snapshot card list."""
|
|
self._list_layout.clear_widgets()
|
|
|
|
items = []
|
|
for s in (self._sessions_data or []):
|
|
items.append({
|
|
"type": "session",
|
|
"name": s.get("name", ""),
|
|
"info": f"{s.get('size_bytes', 0)} bytes",
|
|
"is_snapshot": False,
|
|
})
|
|
for s in (self._snapshots_data or []):
|
|
cat = s.get("category", "")
|
|
cur = " (active)" if s.get("is_current") else ""
|
|
items.append({
|
|
"type": "snapshot",
|
|
"name": s.get("name", ""),
|
|
"info": f"{cat}{cur}" if (cat or cur) else "",
|
|
"is_snapshot": True,
|
|
})
|
|
|
|
if not items:
|
|
self._list_layout.add_widget(Label(
|
|
text="No sessions saved yet.\nUse the Save button above.",
|
|
color=Colors.TEXT_MUTED,
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(1, None),
|
|
height=dp(60),
|
|
halign="center",
|
|
valign="middle",
|
|
))
|
|
return
|
|
|
|
for item in items:
|
|
card = SessionCard(
|
|
name=item["name"],
|
|
info=item["info"],
|
|
is_snapshot=item["is_snapshot"],
|
|
on_load=self._on_load if not item["is_snapshot"] else None,
|
|
on_delete=self._on_delete,
|
|
on_recall=self._on_recall if item["is_snapshot"] else None,
|
|
)
|
|
self._list_layout.add_widget(card)
|
|
|
|
def _on_setlists_loaded(self, data: dict):
|
|
"""Render setlist cards."""
|
|
self._setlist_list.clear_widgets()
|
|
setlists = data.get("setlists", [])
|
|
|
|
if not setlists:
|
|
self._setlist_list.add_widget(Label(
|
|
text="No setlists. Create one above.",
|
|
color=Colors.TEXT_MUTED,
|
|
font_size=Fonts.SMALL,
|
|
size_hint=(1, None),
|
|
height=dp(40),
|
|
halign="center",
|
|
))
|
|
return
|
|
|
|
def _on_load(self, name: str):
|
|
"""Load a session."""
|
|
if not self.client:
|
|
return
|
|
try:
|
|
self.client.rest_post(
|
|
f"/api/v1/sessions/{name}/load",
|
|
body={},
|
|
on_success=lambda d: None,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_delete(self, name: str):
|
|
"""Delete a session or snapshot."""
|
|
if not self.client:
|
|
return
|
|
prefix = "snapshots" if self._current_subtab == "snapshots" else "sessions"
|
|
try:
|
|
self.client.rest_delete(
|
|
f"/api/v1/{prefix}/{name}",
|
|
on_success=lambda d: self._refresh(),
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_recall(self, name: str):
|
|
"""Recall a snapshot."""
|
|
if not self.client:
|
|
return
|
|
try:
|
|
self.client.rest_post(
|
|
f"/api/v1/snapshots/{name}/recall",
|
|
body={},
|
|
on_success=lambda d: None,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_save(self, instance):
|
|
"""Save current state as a session."""
|
|
if not self.client:
|
|
return
|
|
name = self._name_input.text.strip()
|
|
if not name:
|
|
return
|
|
notes = self._notes_input.text.strip()
|
|
try:
|
|
self.client.rest_post(
|
|
"/api/v1/sessions/save",
|
|
body={"name": name, "overwrite": True},
|
|
on_success=lambda d: (self._name_input.setter("text")(""), self._refresh()),
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_capture_snapshot(self, instance):
|
|
"""Capture current state as a snapshot."""
|
|
if not self.client:
|
|
return
|
|
name = self._snap_name_input.text.strip()
|
|
if not name:
|
|
return
|
|
try:
|
|
self.client.rest_post(
|
|
"/api/v1/snapshots/capture",
|
|
body={"name": name, "overwrite": True},
|
|
on_success=lambda d: (self._snap_name_input.setter("text")(""), self._refresh()),
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_snapshot_next(self, instance):
|
|
"""Go to next snapshot."""
|
|
if not self.client:
|
|
return
|
|
try:
|
|
self.client.rest_post(
|
|
"/api/v1/snapshots/next",
|
|
body={},
|
|
on_success=lambda d: None,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_snapshot_prev(self, instance):
|
|
"""Go to previous snapshot."""
|
|
if not self.client:
|
|
return
|
|
try:
|
|
self.client.rest_post(
|
|
"/api/v1/snapshots/previous",
|
|
body={},
|
|
on_success=lambda d: None,
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_create_setlist(self, instance):
|
|
"""Create a new setlist."""
|
|
if not self.client:
|
|
return
|
|
name = self._setlist_name_input.text.strip()
|
|
if not name:
|
|
return
|
|
try:
|
|
self.client.rest_post(
|
|
"/api/v1/setlists/save",
|
|
body={"name": name, "entries": [], "description": ""},
|
|
on_success=lambda d: self._refresh(),
|
|
on_error=lambda e: None,
|
|
)
|
|
except Exception:
|
|
pass
|