from PySide6.QtCore import Qt, QSettings, QTimer from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QWidget, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QSlider, QButtonGroup, QScrollArea, QScroller, ) from audio.system_volume import set_volume import build_info class SoundScreen(QWidget): def __init__(self, on_eq): super().__init__() self._on_eq = on_eq self._settings = QSettings("car_ui", "ui") self._pending_volume: int | None = None self._volume_apply_timer = QTimer(self) self._volume_apply_timer.setSingleShot(True) self._volume_apply_timer.timeout.connect(self._flush_volume) root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(12) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QScrollArea.NoFrame) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroller = QScroller.scroller(scroll.viewport()) scroller.grabGesture( scroll.viewport(), QScroller.LeftMouseButtonGesture ) content = QWidget() content_layout = QVBoxLayout(content) content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(12) content_layout.addWidget(self._build_volume_card()) content_layout.addWidget(self._build_premute_card()) content_layout.addWidget(self._build_ducking_card()) content_layout.addWidget(self._build_balance_card()) content_layout.addWidget(self._build_tone_card()) content_layout.addWidget(self._build_eq_card()) content_layout.addStretch(1) scroll.setWidget(content) root.addWidget(scroll, 1) def _read_int(self, key: str, default: int) -> int: return _read_int_setting(self._settings, key, default) def _read_bool(self, key: str, default: bool) -> bool: return _read_bool_setting(self._settings, key, default) def _queue_volume_apply(self, value: int): self._pending_volume = value self._volume_apply_timer.start(120) def _flush_volume(self): if self._pending_volume is None: return self._set_system_volume(self._pending_volume) def _set_system_volume(self, value: int): set_volume(value) def _build_volume_card(self) -> QWidget: card, body = _card("Громкость") default_value = build_info.DEFAULT_SOUND_VOLUME value = self._read_int("sound/base_volume", default_value) row, slider, value_label = _slider_row( "Базовая громкость", 0, 100, value, lambda v: f"{v}%", ) slider.valueChanged.connect( lambda v: self._settings.setValue("sound/base_volume", v) ) slider.valueChanged.connect(self._queue_volume_apply) body.addWidget(row) return card def _build_premute_card(self) -> QWidget: card, body = _card("Премут") premute_enabled = self._read_bool("sound/premute_enabled", False) premute_value = self._read_int("sound/premute_volume", 10) row_toggle, toggle_btn = _toggle_row("Премут", checked=premute_enabled) toggle_btn.toggled.connect( lambda checked: self._settings.setValue( "sound/premute_enabled", checked, ) ) row_slider, slider, value_label = _slider_row( "Громкость премут", 0, 100, premute_value, lambda v: f"{v}%", ) slider.valueChanged.connect( lambda v: self._settings.setValue("sound/premute_volume", v) ) body.addWidget(row_toggle) body.addWidget(row_slider) return card def _build_ducking_card(self) -> QWidget: card, body = _card("Ducking") ducking_enabled = self._read_bool("sound/ducking_enabled", True) ducking_value = self._read_int("sound/ducking_volume", 35) row_toggle, toggle_btn = _toggle_row("Ducking", checked=ducking_enabled) toggle_btn.toggled.connect( lambda checked: self._settings.setValue( "sound/ducking_enabled", checked, ) ) row_slider, slider, value_label = _slider_row( "Громкость при Ducking", 0, 100, ducking_value, lambda v: f"{v}%", ) slider.valueChanged.connect( lambda v: self._settings.setValue("sound/ducking_volume", v) ) body.addWidget(row_toggle) body.addWidget(row_slider) return card def _build_balance_card(self) -> QWidget: card, body = _card("Баланс и фейдер") row, slider, value_label = _slider_row( "Баланс L/R", -50, 50, 0, _format_balance, ) body.addWidget(row) row, slider, value_label = _slider_row( "Фейдер F/R", -50, 50, 0, _format_fader, ) body.addWidget(row) return card def _build_tone_card(self) -> QWidget: card, body = _card("Тон") row = QWidget() row.setObjectName("SoundToneRow") layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) group = QButtonGroup(row) group.setExclusive(True) for idx, label in enumerate(("Тёплый", "Нейтральный", "Яркий")): btn = QPushButton(label) btn.setObjectName("SoundToneBtn") btn.setCheckable(True) btn.setMinimumHeight(40) btn.setFont(QFont("", 13, 600)) if idx == 1: btn.setChecked(True) group.addButton(btn) layout.addWidget(btn, 1) body.addWidget(row) return card def _build_eq_card(self) -> QWidget: card, body = _card("Эквалайзер") btn = QPushButton("Открыть эквалайзер") btn.setObjectName("SoundEqBtn") btn.setMinimumHeight(48) btn.setFont(QFont("", 14, 700)) btn.clicked.connect(self._on_eq) body.addWidget(btn) return card def _card(title: str) -> tuple[QWidget, QVBoxLayout]: card = QWidget() card.setObjectName("SoundCard") layout = QVBoxLayout(card) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(10) header = QLabel(title) header.setObjectName("SoundCardTitle") header.setFont(QFont("", 14, 700)) layout.addWidget(header) return card, layout def _toggle_row(label: str, checked: bool) -> tuple[QWidget, QPushButton]: row = QWidget() row.setObjectName("SoundToggleRow") layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(12) lbl = QLabel(label) lbl.setFont(QFont("", 13, 600)) btn = QPushButton("Выкл") btn.setObjectName("SoundToggle") btn.setCheckable(True) btn.setChecked(checked) btn.setMinimumHeight(36) btn.setMinimumWidth(86) btn.setFont(QFont("", 12, 700)) def _sync_text(is_checked: bool): btn.setText("Вкл" if is_checked else "Выкл") btn.toggled.connect(_sync_text) _sync_text(btn.isChecked()) layout.addWidget(lbl) layout.addStretch(1) layout.addWidget(btn) return row, btn def _slider_row( label: str, minimum: int, maximum: int, value: int, formatter, ) -> tuple[QWidget, QSlider, QLabel]: row = QWidget() row.setObjectName("SoundSliderRow") layout = QVBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) header = QWidget() header_layout = QHBoxLayout(header) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(8) lbl = QLabel(label) lbl.setFont(QFont("", 13, 600)) val = QLabel(formatter(value)) val.setObjectName("SoundValue") val.setFont(QFont("", 12, 600)) val.setAlignment(Qt.AlignRight | Qt.AlignVCenter) header_layout.addWidget(lbl) header_layout.addStretch(1) header_layout.addWidget(val) slider = QSlider(Qt.Horizontal) slider.setObjectName("SoundSlider") slider.setRange(minimum, maximum) slider.setValue(value) slider.valueChanged.connect(lambda v: val.setText(formatter(v))) layout.addWidget(header) layout.addWidget(slider) return row, slider, val def _format_balance(value: int) -> str: if value == 0: return "Центр" side = "L" if value < 0 else "R" return f"{side} {abs(value)}" def _format_fader(value: int) -> str: if value == 0: return "Центр" side = "F" if value < 0 else "R" return f"{side} {abs(value)}" def _read_int_setting(settings: QSettings, key: str, default: int) -> int: raw = settings.value(key, default) try: return int(raw) except (TypeError, ValueError): return default def _read_bool_setting(settings: QSettings, key: str, default: bool) -> bool: raw = settings.value(key, default) if isinstance(raw, bool): return raw if isinstance(raw, str): return raw.strip().lower() in ("1", "true", "yes", "on") return bool(raw)