Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,584 @@
|
||||
"""Touch-optimized PySide6 UI for javis-slave (Surface Pro 7 kiosk style).
|
||||
|
||||
Same VS Code Dark Modern palette as the Javis master GUI, with all touch
|
||||
targets ≥ 48 px.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
# ── palette (mirrors javis/gui/main_window.py) ────────────────────────────────
|
||||
CL_BG = "#1e1e1e"
|
||||
CL_SURFACE = "#252526"
|
||||
CL_SURFACE2 = "#2d2d2d"
|
||||
CL_INPUT = "#3c3c3c"
|
||||
CL_ACCENT = "#DA7756"
|
||||
CL_ACCENT2 = "#e0886a"
|
||||
CL_TEXT = "#cccccc"
|
||||
CL_MUTED = "#858585"
|
||||
CL_BORDER = "#3c3c3c"
|
||||
|
||||
# Gain applied to raw mic RMS before driving the orb pulse (A-12: was magic 8.0).
|
||||
# Typical speech RMS is ~0.01–0.1, so ×8 maps it into the visible 0..1 range.
|
||||
_RMS_GAIN = 8.0
|
||||
|
||||
STATE_COLORS = {
|
||||
"idle": "#3c3c3c",
|
||||
"listening": "#4ec994",
|
||||
"thinking": CL_ACCENT,
|
||||
"speaking": "#4ec994",
|
||||
"muted": "#f14c4c",
|
||||
"error": "#f14c4c",
|
||||
}
|
||||
|
||||
STATE_LABELS = {
|
||||
"idle": "Sag das Wake-Word …",
|
||||
"listening": "Ich höre zu …",
|
||||
"thinking": "Einen Moment …",
|
||||
"speaking": "Antwort wird abgespielt …",
|
||||
"muted": "Stumm geschaltet",
|
||||
"error": "Fehler",
|
||||
}
|
||||
|
||||
_INPUT_QSS = (
|
||||
f"QLineEdit {{ background: {CL_INPUT}; color: {CL_TEXT}; border: 1px solid {CL_BORDER};"
|
||||
f" border-radius: 12px; padding: 14px 18px; font-size: 16pt; }}"
|
||||
f"QLineEdit:focus {{ border: 2px solid {CL_ACCENT}; }}"
|
||||
)
|
||||
|
||||
_PRIMARY_BTN_QSS = (
|
||||
f"QPushButton {{ background: {CL_ACCENT}; color: #1e1e1e; font-weight: 700;"
|
||||
f" border: 0; border-radius: 14px; padding: 16px 32px; font-size: 16pt; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_ACCENT2}; }}"
|
||||
f"QPushButton:disabled {{ background: {CL_INPUT}; color: {CL_MUTED}; }}"
|
||||
)
|
||||
|
||||
_GHOST_BTN_QSS = (
|
||||
f"QPushButton {{ background: transparent; border: 1px solid {CL_BORDER}; color: {CL_MUTED};"
|
||||
f" padding: 12px 24px; border-radius: 12px; font-size: 13pt; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_SURFACE2}; color: {CL_TEXT}; }}"
|
||||
)
|
||||
|
||||
|
||||
class StateOrb(QtWidgets.QWidget):
|
||||
"""Large animated circle reflecting the agent state.
|
||||
|
||||
Alexa-style visual feedback:
|
||||
- Color indicates device state (idle/listening/thinking/speaking/error)
|
||||
- Pulse ring shows mic activity level in real-time
|
||||
- Mute overlay (red X) when microphone is disabled
|
||||
- Retry indicator (spinning dots) during reconnection attempts
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._state = "idle"
|
||||
self._level = 0.0 # mic RMS 0..1 (pulse while idle/listening)
|
||||
self._muted = False # explicit mute state overlay
|
||||
self._reconnecting = False # showing retry indicator
|
||||
self.setMinimumSize(260, 260)
|
||||
|
||||
def set_state(self, state: str) -> None:
|
||||
self._state = state
|
||||
self.update()
|
||||
|
||||
def set_level(self, level: float) -> None:
|
||||
self._level = max(0.0, min(1.0, level))
|
||||
self.update()
|
||||
|
||||
def set_muted(self, muted: bool) -> None:
|
||||
"""Set the mute overlay state (Alexa-style mute indicator)."""
|
||||
self._muted = muted
|
||||
self.update()
|
||||
|
||||
def set_reconnecting(self, reconnecting: bool) -> None:
|
||||
"""Show/hide reconnection retry indicator."""
|
||||
self._reconnecting = reconnecting
|
||||
self.update()
|
||||
|
||||
def is_muted(self) -> bool:
|
||||
return self._muted
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
p = QtGui.QPainter(self)
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
rect = self.rect()
|
||||
size = min(rect.width(), rect.height())
|
||||
base_r = size * 0.32
|
||||
pulse = base_r * (1.0 + 0.35 * self._level)
|
||||
center = rect.center()
|
||||
|
||||
color = QtGui.QColor(STATE_COLORS.get(self._state, CL_INPUT))
|
||||
|
||||
# Outer pulse ring.
|
||||
ring = QtGui.QColor(color)
|
||||
ring.setAlpha(60)
|
||||
p.setBrush(ring)
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.drawEllipse(center, pulse, pulse)
|
||||
|
||||
# Core.
|
||||
p.setBrush(color)
|
||||
p.drawEllipse(center, base_r, base_r)
|
||||
|
||||
# Icon glyph — dark on bright cores, light on the dark idle core so it stays visible.
|
||||
bright_core = self._state in ("listening", "thinking", "speaking", "muted", "error")
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#1e1e1e" if bright_core else CL_TEXT), 3))
|
||||
font = p.font()
|
||||
font.setPointSizeF(size * 0.11)
|
||||
p.setFont(font)
|
||||
glyph = {"idle": "🎤", "listening": "🎤", "thinking": "…",
|
||||
"speaking": "🔊", "muted": "🔇", "error": "!"}.get(self._state, "")
|
||||
p.drawText(QtCore.QRectF(rect), QtCore.Qt.AlignCenter, glyph)
|
||||
|
||||
# Mute overlay (Alexa-style red X indicator).
|
||||
if self._muted:
|
||||
mute_r = base_r * 0.5
|
||||
mute_center = QtCore.QPointF(center.x() + size * 0.28,
|
||||
center.y() - size * 0.28)
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#f14c4c"), max(2, size // 80)))
|
||||
p.drawLine(
|
||||
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() - mute_r),
|
||||
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() + mute_r))
|
||||
p.drawLine(
|
||||
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() - mute_r),
|
||||
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() + mute_r))
|
||||
|
||||
# Reconnecting indicator (pulsing outer ring).
|
||||
if self._reconnecting:
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#f59e0b"), max(2, size // 60)))
|
||||
ring_r = base_r * 1.35
|
||||
p.drawEllipse(center, ring_r, ring_r)
|
||||
|
||||
p.end()
|
||||
|
||||
|
||||
class SetupScreen(QtWidgets.QWidget):
|
||||
"""First-run pairing: master address + pairing code."""
|
||||
|
||||
pairRequested = QtCore.Signal(str, int, str, str) # host, port, code, name
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setStyleSheet(f"background: {CL_BG};")
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setAlignment(QtCore.Qt.AlignCenter)
|
||||
|
||||
card = QtWidgets.QFrame()
|
||||
card.setMaximumWidth(620)
|
||||
card.setStyleSheet(
|
||||
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
|
||||
f" border-radius: 18px; }}")
|
||||
v = QtWidgets.QVBoxLayout(card)
|
||||
v.setContentsMargins(40, 36, 40, 36)
|
||||
v.setSpacing(18)
|
||||
|
||||
title = QtWidgets.QLabel("Javis Slave koppeln")
|
||||
title.setStyleSheet(
|
||||
f"color: {CL_TEXT}; font-size: 24pt; font-weight: 800; border: 0;")
|
||||
title.setAlignment(QtCore.Qt.AlignCenter)
|
||||
v.addWidget(title)
|
||||
|
||||
hint = QtWidgets.QLabel(
|
||||
"Erzeuge auf dem Javis-Master im Tab „Devices“ einen Pairing-Code\n"
|
||||
"und trage hier die angezeigte Adresse und den Code ein.")
|
||||
hint.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
|
||||
hint.setAlignment(QtCore.Qt.AlignCenter)
|
||||
v.addWidget(hint)
|
||||
|
||||
self.host_edit = QtWidgets.QLineEdit()
|
||||
self.host_edit.setPlaceholderText("Master-IP (z. B. 192.168.1.20)")
|
||||
self.host_edit.setStyleSheet(_INPUT_QSS)
|
||||
v.addWidget(self.host_edit)
|
||||
|
||||
self.port_edit = QtWidgets.QLineEdit("8765")
|
||||
self.port_edit.setPlaceholderText("Port")
|
||||
self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self))
|
||||
self.port_edit.setStyleSheet(_INPUT_QSS)
|
||||
v.addWidget(self.port_edit)
|
||||
|
||||
self.code_edit = QtWidgets.QLineEdit()
|
||||
self.code_edit.setPlaceholderText("Pairing-Code")
|
||||
self.code_edit.setMaxLength(6)
|
||||
self.code_edit.setStyleSheet(_INPUT_QSS + "QLineEdit { letter-spacing: 6px; }")
|
||||
v.addWidget(self.code_edit)
|
||||
|
||||
self.name_edit = QtWidgets.QLineEdit()
|
||||
self.name_edit.setPlaceholderText("Gerätename (z. B. Tablet Küche)")
|
||||
self.name_edit.setStyleSheet(_INPUT_QSS)
|
||||
v.addWidget(self.name_edit)
|
||||
|
||||
self.status_lbl = QtWidgets.QLabel("")
|
||||
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
|
||||
self.status_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.status_lbl.setWordWrap(True)
|
||||
v.addWidget(self.status_lbl)
|
||||
|
||||
self.pair_btn = QtWidgets.QPushButton("Koppeln")
|
||||
self.pair_btn.setStyleSheet(_PRIMARY_BTN_QSS)
|
||||
self.pair_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.pair_btn.clicked.connect(self._submit)
|
||||
v.addWidget(self.pair_btn)
|
||||
|
||||
outer.addWidget(card)
|
||||
|
||||
def _submit(self) -> None:
|
||||
host = self.host_edit.text().strip()
|
||||
port = int(self.port_edit.text() or "8765")
|
||||
code = self.code_edit.text().strip()
|
||||
name = self.name_edit.text().strip()
|
||||
if not host or not code:
|
||||
self.status_lbl.setText("Bitte Master-IP und Pairing-Code eingeben.")
|
||||
return
|
||||
self.status_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
|
||||
self.status_lbl.setText("Verbinde …")
|
||||
self.pair_btn.setEnabled(False)
|
||||
self.pairRequested.emit(host, port, code, name)
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
|
||||
self.status_lbl.setText(message)
|
||||
self.pair_btn.setEnabled(True)
|
||||
|
||||
|
||||
class MainScreen(QtWidgets.QWidget):
|
||||
"""Paired mode: state orb, transcript card, status badges.
|
||||
|
||||
Alexa-style UX features:
|
||||
- Mute toggle button on the orb
|
||||
- Connection health monitoring with retry countdown
|
||||
- Transcript history (scrollable conversation log)
|
||||
- Visual mute indicator overlay
|
||||
"""
|
||||
|
||||
settingsRequested = QtCore.Signal()
|
||||
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setStyleSheet(f"background: {CL_BG};")
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setContentsMargins(28, 20, 28, 24)
|
||||
outer.setSpacing(16)
|
||||
|
||||
# ── top bar: room badge · connection health · mute · settings ──────
|
||||
top = QtWidgets.QHBoxLayout()
|
||||
self.room_lbl = QtWidgets.QLabel("Raum: —")
|
||||
self.room_lbl.setStyleSheet(
|
||||
f"color: {CL_TEXT}; background: {CL_SURFACE2}; border-radius: 14px;"
|
||||
f"padding: 10px 22px; font-size: 14pt; font-weight: 600;")
|
||||
top.addWidget(self.room_lbl)
|
||||
top.addStretch(1)
|
||||
|
||||
# Connection status with retry countdown
|
||||
self.conn_lbl = QtWidgets.QLabel("● verbunden")
|
||||
self.conn_lbl.setStyleSheet(
|
||||
"color: #4ec994; font-size: 13pt; padding: 10px;")
|
||||
top.addWidget(self.conn_lbl)
|
||||
|
||||
# Mute toggle button (Alexa-style red ring when muted)
|
||||
self.mute_btn = QtWidgets.QPushButton("🎤")
|
||||
self.mute_btn.setFixedSize(56, 56)
|
||||
self.mute_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.mute_btn.setStyleSheet(
|
||||
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_TEXT}; border: 0;"
|
||||
f" border-radius: 14px; font-size: 18pt; }}"
|
||||
f"QPushButton:checked {{ background: #f14c4c; color: white; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}"
|
||||
f"QPushButton:disabled {{ background: {CL_SURFACE}; color: {CL_MUTED}; }}")
|
||||
self.mute_btn.setCheckable(True) # Alexa-style toggle state
|
||||
self.mute_btn.clicked.connect(self._on_mute_toggled)
|
||||
top.addWidget(self.mute_btn)
|
||||
|
||||
gear = QtWidgets.QPushButton("⚙")
|
||||
gear.setFixedSize(56, 56)
|
||||
gear.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
gear.setStyleSheet(
|
||||
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_MUTED}; border: 0;"
|
||||
f" border-radius: 14px; font-size: 18pt; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}")
|
||||
gear.clicked.connect(self.settingsRequested.emit)
|
||||
top.addWidget(gear)
|
||||
outer.addLayout(top)
|
||||
|
||||
# Retry countdown label (hidden by default, shown during reconnection)
|
||||
self.retry_lbl = QtWidgets.QLabel("")
|
||||
self.retry_lbl.setStyleSheet(
|
||||
f"color: #f59e0b; font-size: 12pt; font-weight: 600; border: 0;")
|
||||
self.retry_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.retry_lbl.setVisible(False)
|
||||
outer.addWidget(self.retry_lbl)
|
||||
|
||||
# ── state orb + label ─────────────────────────────────────────────
|
||||
self.orb = StateOrb()
|
||||
outer.addWidget(self.orb, 2)
|
||||
self.state_lbl = QtWidgets.QLabel(STATE_LABELS["idle"])
|
||||
self.state_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.state_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 16pt;")
|
||||
outer.addWidget(self.state_lbl)
|
||||
|
||||
# ── transcript card (scrollable conversation history) ──────────────
|
||||
card = QtWidgets.QFrame()
|
||||
card.setStyleSheet(
|
||||
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
|
||||
f" border-radius: 18px; }}")
|
||||
cv = QtWidgets.QVBoxLayout(card)
|
||||
cv.setContentsMargins(28, 22, 28, 22)
|
||||
cv.setSpacing(10)
|
||||
|
||||
# Header label
|
||||
history_header = QtWidgets.QLabel("Konversation")
|
||||
history_header.setStyleSheet(f"color: {CL_MUTED}; font-size: 11pt; border: 0;")
|
||||
cv.addWidget(history_header)
|
||||
|
||||
# Transcript history (replaces single-turn display).
|
||||
# NOTE: this layout must belong to *exactly one* parent — we set it on
|
||||
# ``transcript_container`` (inside the scroll area) below, so it must
|
||||
# NOT also be added to ``cv`` (Qt raises RuntimeError: "Attempting to
|
||||
# set QLayout ... when the QLayout already has a parent").
|
||||
self.transcript_layout = QtWidgets.QVBoxLayout()
|
||||
self.transcript_layout.setSpacing(8)
|
||||
|
||||
# Scroll area for transcript
|
||||
self.transcript_scroll = QtWidgets.QScrollArea()
|
||||
self.transcript_scroll.setWidgetResizable(True)
|
||||
self.transcript_scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.transcript_scroll.setStyleSheet("background: transparent; border: 0;")
|
||||
self.transcript_container = QtWidgets.QWidget()
|
||||
self.transcript_container.setLayout(self.transcript_layout)
|
||||
self.transcript_scroll.setWidget(self.transcript_container)
|
||||
QtWidgets.QScroller.grabGesture(
|
||||
self.transcript_scroll.viewport(), QtWidgets.QScroller.TouchGesture)
|
||||
cv.addWidget(self.transcript_scroll, 1)
|
||||
|
||||
outer.addWidget(card, 3)
|
||||
|
||||
# Track last state for change detection
|
||||
self._last_state = "idle"
|
||||
|
||||
def _on_mute_toggled(self) -> None:
|
||||
"""Handle mute button toggle."""
|
||||
is_muted = self.mute_btn.isChecked()
|
||||
self.muteToggled.emit(is_muted)
|
||||
|
||||
# ── slots (called via Qt signals from the engine bridge) ──────────────
|
||||
|
||||
def set_state(self, state: str) -> None:
|
||||
self.orb.set_state(state)
|
||||
self.orb.set_muted(state == "muted") # sync mute overlay with state
|
||||
label = STATE_LABELS.get(state, state)
|
||||
if label:
|
||||
self.state_lbl.setText(label)
|
||||
# Mute toggle stays in place (enabled/disabled) so the top bar never
|
||||
# jumps while the state cycles; it is only inactive mid-turn.
|
||||
interactive_states = ("idle", "listening", "speaking", "muted")
|
||||
self.mute_btn.setEnabled(state in interactive_states)
|
||||
# Sync the button's checked state to match the engine's mute state.
|
||||
want_checked = (state == "muted")
|
||||
if self.mute_btn.isChecked() != want_checked:
|
||||
self.mute_btn.blockSignals(True)
|
||||
self.mute_btn.setChecked(want_checked)
|
||||
self.mute_btn.blockSignals(False)
|
||||
|
||||
def set_mic_level(self, rms: float, score: float) -> None:
|
||||
self.orb.set_level(min(1.0, rms * _RMS_GAIN))
|
||||
|
||||
def _ensure_transcript_spacer(self) -> None:
|
||||
"""Remove existing spacer (if any) and add a fresh one at the end."""
|
||||
# Walk backwards to find and remove any existing spacer items
|
||||
while self.transcript_layout.count() > 0:
|
||||
item = self.transcript_layout.itemAt(self.transcript_layout.count() - 1)
|
||||
if isinstance(item, QtWidgets.QSpacerItem):
|
||||
self.transcript_layout.removeItem(item)
|
||||
else:
|
||||
break
|
||||
spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum,
|
||||
QtWidgets.QSizePolicy.Expanding)
|
||||
self.transcript_layout.addSpacerItem(spacer)
|
||||
|
||||
def _prune_transcript(self, max_bubbles: int = 40) -> None:
|
||||
"""Keep at most *max_bubbles* bubbles in the transcript layout (oldest first)."""
|
||||
count = self.transcript_layout.count()
|
||||
bubbles = count
|
||||
if count > 0 and isinstance(self.transcript_layout.itemAt(count - 1), QtWidgets.QSpacerItem):
|
||||
bubbles -= 1
|
||||
excess = bubbles - max_bubbles
|
||||
for _ in range(max(0, excess)):
|
||||
item = self.transcript_layout.takeAt(0)
|
||||
if item is not None and item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
def set_turn(self, user_text: str, reply_text: str) -> None:
|
||||
"""Add a new conversation turn to the transcript history (Alexa-style)."""
|
||||
# User message bubble
|
||||
user_bubble = QtWidgets.QLabel(f"👤 {user_text}")
|
||||
user_bubble.setStyleSheet(
|
||||
f"background: {CL_SURFACE2}; color: {CL_TEXT}; border-radius: 12px;"
|
||||
f"padding: 12px 18px; font-size: 14pt;")
|
||||
user_bubble.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
|
||||
user_bubble.setWordWrap(True)
|
||||
self.transcript_layout.addWidget(user_bubble)
|
||||
|
||||
# Assistant message bubble (if any)
|
||||
if reply_text:
|
||||
reply_bubble = QtWidgets.QLabel(f"🤖 {reply_text}")
|
||||
reply_bubble.setStyleSheet(
|
||||
f"background: {CL_ACCENT}; color: #1e1e1e; border-radius: 12px;"
|
||||
f"padding: 12px 18px; font-size: 14pt; font-weight: 500;")
|
||||
reply_bubble.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
|
||||
reply_bubble.setWordWrap(True)
|
||||
self.transcript_layout.addWidget(reply_bubble)
|
||||
|
||||
self._prune_transcript()
|
||||
# Spacer to push content to bottom (like Alexa's conversation history)
|
||||
self._ensure_transcript_spacer()
|
||||
|
||||
# Auto-scroll to bottom
|
||||
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
|
||||
|
||||
def _scroll_transcript_to_bottom(self) -> None:
|
||||
"""Auto-scroll transcript to show latest messages."""
|
||||
vbar = self.transcript_scroll.verticalScrollBar()
|
||||
vbar.setValue(vbar.maximum())
|
||||
|
||||
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
|
||||
if ok:
|
||||
self.conn_lbl.setText("● verbunden")
|
||||
self.conn_lbl.setStyleSheet("color: #4ec994; font-size: 13pt; padding: 10px;")
|
||||
self.retry_lbl.setVisible(False)
|
||||
self.orb.set_reconnecting(False)
|
||||
else:
|
||||
if retry_count > 0 and backoff > 0:
|
||||
# Show retry countdown (Alexa-style progressive feedback)
|
||||
text = f"○ trennung · versuch {retry_count} in {backoff:.0f}s"
|
||||
self.conn_lbl.setText(text)
|
||||
self.conn_lbl.setStyleSheet("color: #f59e0b; font-size: 13pt; padding: 10px;")
|
||||
self.retry_lbl.setVisible(True)
|
||||
self.retry_lbl.setText(f"Wiederverbindungsversuch in {backoff:.0f}s …")
|
||||
self.orb.set_reconnecting(True)
|
||||
else:
|
||||
self.conn_lbl.setText("● getrennt")
|
||||
self.conn_lbl.setStyleSheet("color: #f14c4c; font-size: 13pt; padding: 10px;")
|
||||
|
||||
def set_room(self, room: Optional[str]) -> None:
|
||||
self.room_lbl.setText(f"Raum: {room}" if room else "Raum: —")
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
"""Show a recoverable error to the user (Alexa-style progressive disclosure)."""
|
||||
self._prune_transcript()
|
||||
error_bubble = QtWidgets.QLabel(f"⚠ {message}")
|
||||
error_bubble.setStyleSheet(
|
||||
f"color: #f14c4c; font-size: 14pt; border: 0; padding: 12px;")
|
||||
error_bubble.setWordWrap(True)
|
||||
self.transcript_layout.addWidget(error_bubble)
|
||||
self._ensure_transcript_spacer()
|
||||
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
|
||||
|
||||
|
||||
class SlaveWindow(QtWidgets.QMainWindow):
|
||||
"""Top-level window switching between setup and main screens.
|
||||
|
||||
Wired to engine callbacks that bridge asyncio events to Qt signals:
|
||||
- engineState: device state changes (idle/listening/thinking/speaking/error)
|
||||
- engineMicLevel: real-time audio level for orb pulse animation
|
||||
- engineTurn: conversation turn results (user text + assistant reply)
|
||||
- engineError: error messages from the engine
|
||||
- engineConnection: connection health updates with retry countdown
|
||||
- engineRoom: room assignment changes
|
||||
"""
|
||||
|
||||
# Bridged engine events (emitted from asyncio, consumed on the Qt side).
|
||||
engineState = QtCore.Signal(str)
|
||||
engineMicLevel = QtCore.Signal(float, float)
|
||||
engineTurn = QtCore.Signal(str, str)
|
||||
engineError = QtCore.Signal(str)
|
||||
engineConnection = QtCore.Signal(bool, int, float) # (ok, retry_count, backoff)
|
||||
engineRoom = QtCore.Signal(object)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Javis Slave")
|
||||
self.resize(1368, 912) # Surface Pro 7 logical resolution @150 %
|
||||
self.setStyleSheet(f"background: {CL_BG};")
|
||||
|
||||
self.stack = QtWidgets.QStackedWidget()
|
||||
self.setup_screen = SetupScreen()
|
||||
self.main_screen = MainScreen()
|
||||
self.stack.addWidget(self.setup_screen)
|
||||
self.stack.addWidget(self.main_screen)
|
||||
self.setCentralWidget(self.stack)
|
||||
|
||||
self.engineState.connect(self.main_screen.set_state)
|
||||
self.engineMicLevel.connect(self.main_screen.set_mic_level)
|
||||
self.engineTurn.connect(self.main_screen.set_turn)
|
||||
self.engineError.connect(self.main_screen.show_error)
|
||||
self.engineConnection.connect(self.main_screen.set_connection)
|
||||
self.engineRoom.connect(lambda r: self.main_screen.set_room(r))
|
||||
|
||||
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
|
||||
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
|
||||
self._kiosk = False
|
||||
QtGui.QShortcut(QtGui.QKeySequence("Esc"), self, activated=self._leave_fullscreen)
|
||||
|
||||
def set_kiosk(self, enabled: bool) -> None:
|
||||
self._kiosk = enabled
|
||||
if enabled:
|
||||
self.showFullScreen()
|
||||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
|
||||
|
||||
def _leave_fullscreen(self) -> None:
|
||||
if self.isFullScreen():
|
||||
self.showNormal()
|
||||
if self._kiosk:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
def show_setup(self) -> None:
|
||||
self.stack.setCurrentWidget(self.setup_screen)
|
||||
|
||||
def show_main(self) -> None:
|
||||
self.stack.setCurrentWidget(self.main_screen)
|
||||
|
||||
def _toggle_fullscreen(self) -> None:
|
||||
if self.isFullScreen():
|
||||
self.showNormal()
|
||||
else:
|
||||
self.showFullScreen()
|
||||
|
||||
def show_settings_dialog(self, device_id: str, device_name: str,
|
||||
on_repair: Callable[[], None]) -> None:
|
||||
dlg = QtWidgets.QDialog(self)
|
||||
dlg.setWindowTitle("Einstellungen")
|
||||
dlg.setStyleSheet(f"background: {CL_SURFACE}; color: {CL_TEXT};")
|
||||
v = QtWidgets.QVBoxLayout(dlg)
|
||||
v.setContentsMargins(28, 24, 28, 24)
|
||||
v.setSpacing(14)
|
||||
info = QtWidgets.QLabel(
|
||||
f"Gerät: {device_name or '—'}\nGeräte-ID: {device_id}")
|
||||
info.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt;")
|
||||
info.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
|
||||
v.addWidget(info)
|
||||
btn_fs = QtWidgets.QPushButton("Vollbild umschalten (F11)")
|
||||
btn_fs.setStyleSheet(_GHOST_BTN_QSS)
|
||||
btn_fs.clicked.connect(lambda: (self._toggle_fullscreen(), dlg.accept()))
|
||||
v.addWidget(btn_fs)
|
||||
btn_repair = QtWidgets.QPushButton("Neu koppeln …")
|
||||
btn_repair.setStyleSheet(_GHOST_BTN_QSS)
|
||||
|
||||
def _do_repair():
|
||||
ret = QtWidgets.QMessageBox.question(
|
||||
dlg, "Neu koppeln",
|
||||
"Verbindung zum Master trennen und neu koppeln?")
|
||||
if ret == QtWidgets.QMessageBox.Yes:
|
||||
dlg.accept()
|
||||
on_repair()
|
||||
|
||||
btn_repair.clicked.connect(_do_repair)
|
||||
v.addWidget(btn_repair)
|
||||
btn_close = QtWidgets.QPushButton("Schließen")
|
||||
btn_close.setStyleSheet(_PRIMARY_BTN_QSS)
|
||||
btn_close.clicked.connect(dlg.accept)
|
||||
v.addWidget(btn_close)
|
||||
dlg.exec()
|
||||
Reference in new issue
Block a user