From b64c2dcd126ca2a8afcc80cbbfddd97b8c901efc Mon Sep 17 00:00:00 2001 From: jads Date: Fri, 23 Oct 2026 22:20:36 +0300 Subject: [PATCH] feat: polish Qt player and keep TUI cover in color --- README.md | 20 ++++--- qt_app.py | 161 +++++++++++++++++++++++++++++++++++++++++++++--------- tui.py | 12 ++-- 3 files changed, 155 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6664005..95898c5 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu .venv/bin/python mtsmusic.py tui ``` -Интерфейс в терминале сделан как экран телефона и полностью чёрно-белый: -по центру обложка в оттенках серого, строго под ней по той же ширине — +Интерфейс в терминале сделан как экран телефона: чёрно-белые элементы и +цветная обложка. Обложка по центру, строго под ней по той же ширине — большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже «Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое меню: «Моя волна», избранное, история прослушивания, поиск, текст песни и @@ -77,15 +77,21 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu .venv/bin/python mtsmusic.py qt ``` -Отдельное окно в стиле телефона: большая обложка, кнопка play/pause, +Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause, переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает боковую панель с «Моей волной», избранным, историей и поиском. Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает -перемотка и системный звук. Окно можно растягивать, списки прокручиваются, -двойной клик по строке запускает трек. Горячие клавиши те же, что в TUI: -`Space`, `←`/`→`, `N`/`P`, `L`, `T`, `M`, `F`, `H`, `S`, `W`; `Esc` закрывает -меню/экран, `Ctrl+Q` — выход. +перемотка и системный звук. По полосе прогресса можно кликнуть в любое место — +каретка сразу перейдёт туда (и потянется, если вести мышью). Окно растягивается +вместе с обложкой, списки прокручиваются, двойной клик по строке запускает трек. + +`Tab` переключает фокус по элементам: меню, полоса, предыдущий, play, следующий, +«Текст», «Избранное». Горячие клавиши: `Space`, `←`/`→`, `N`/`P`, `L`, `T`, `M`, +`F`, `H`, `S`, `W`; `Esc` закрывает меню/экран, `Ctrl+Q` — выход. + +«Моя волна» продолжается с последних прослушанных треков (берётся история), а не +начинается каждый раз с одного и того же места. ## Команды diff --git a/qt_app.py b/qt_app.py index 6d3bce5..057b372 100644 --- a/qt_app.py +++ b/qt_app.py @@ -36,6 +36,7 @@ from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QAbstractButton, QFrame, QHBoxLayout, QLabel, @@ -111,6 +112,11 @@ QSlider::handle:horizontal:hover { background: #ffd9df; } } #actionBtn:hover { background: rgba(255,255,255,0.09); } #actionBtn[liked="true"] { color: #ff8296; border-color: rgba(255,59,92,0.40); } +#menuBtn:focus, #prevBtn:focus, #nextBtn:focus, #playBtn:focus, +#actionBtn:focus, #backBtn:focus, #searchBtn:focus { + border: 2px solid #ffffff; +} +QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; } #drawer { background: #16161f; border-right: 1px solid rgba(255,255,255,0.06); } #drawerTitle { font-size: 18px; font-weight: 700; padding: 4px 2px 10px 2px; } #drawerBrand { color: #6f6f82; font-size: 11px; padding: 0 2px 12px 2px; } @@ -216,11 +222,21 @@ class Task(QRunnable): try: result = self.fn() except SystemExit as exc: - self.signals.failed.emit(self, str(exc) or "ошибка") + self._emit_failed(str(exc) or "ошибка") except Exception as exc: # noqa: BLE001 - сообщение показываем в UI - self.signals.failed.emit(self, str(exc) or exc.__class__.__name__) + self._emit_failed(str(exc) or exc.__class__.__name__) else: - self.signals.done.emit(self, result) + try: + self.signals.done.emit(self, result) + except RuntimeError: + # Приложение уже закрылось, сигнал отправлять некуда. + pass + + def _emit_failed(self, message: str) -> None: + try: + self.signals.failed.emit(self, message) + except RuntimeError: + pass class CoverView(QWidget): @@ -278,6 +294,43 @@ class CoverView(QWidget): painter.end() +class ClickSlider(QSlider): + """Перематывает по клику в любую точку полосы, а не шагами.""" + + seekRequested = Signal(int) + scrubStarted = Signal() + scrubFinished = Signal() + + def _value_at(self, x: int) -> int: + return QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), x, max(1, self.width()) + ) + + def mousePressEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton and self.maximum() > 0: + self.scrubStarted.emit() + value = self._value_at(event.position().toPoint().x()) + self.setValue(value) + self.seekRequested.emit(value) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: # noqa: N802 + if event.buttons() & Qt.MouseButton.LeftButton and self.maximum() > 0: + value = self._value_at(event.position().toPoint().x()) + self.setValue(value) + self.seekRequested.emit(value) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton: + self.scrubFinished.emit() + super().mouseReleaseEvent(event) + + class TrackDelegate(QStyledItemDelegate): """Две строки: название и исполнитель/альбом, длительность справа.""" @@ -394,6 +447,8 @@ class QtMusicApp(QMainWindow): self._build_ui() self._build_drawer() self._build_toast() + self._update_cover_size() + self.play_btn.setFocus() self._timer = QTimer(self) self._timer.setInterval(500) @@ -468,6 +523,32 @@ class QtMusicApp(QMainWindow): ) self.pages.addWidget(self.lyrics_page) + self._setup_tab_order() + + def _setup_tab_order(self) -> None: + """Явный порядок обхода по Tab для главного экрана.""" + for widget in ( + self.menu_btn, + self.slider, + self.prev_btn, + self.play_btn, + self.next_btn, + self.lyrics_btn, + self.like_btn, + ): + widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + order = [ + self.menu_btn, + self.slider, + self.prev_btn, + self.play_btn, + self.next_btn, + self.lyrics_btn, + self.like_btn, + ] + for first, second in zip(order, order[1:]): + self.setTabOrder(first, second) + def _build_main_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) @@ -480,7 +561,9 @@ class QtMusicApp(QMainWindow): menu.setObjectName("menuBtn") menu.setText("☰") menu.setCursor(Qt.CursorShape.PointingHandCursor) + menu.setFocusPolicy(Qt.FocusPolicy.StrongFocus) menu.clicked.connect(self.open_drawer) + self.menu_btn = menu brand = QLabel("MTS Music") brand.setObjectName("brand") self.top_status = QLabel("") @@ -496,7 +579,7 @@ class QtMusicApp(QMainWindow): layout.addStretch(1) self.cover = CoverView() - self.cover.setFixedSize(288, 288) + self.cover.setFixedSize(330, 330) layout.addWidget(self.cover, 0, Qt.AlignmentFlag.AlignHCenter) layout.addSpacing(16) @@ -518,11 +601,12 @@ class QtMusicApp(QMainWindow): layout.addWidget(self.album_label) layout.addSpacing(14) - self.slider = QSlider(Qt.Orientation.Horizontal) + self.slider = ClickSlider(Qt.Orientation.Horizontal) self.slider.setRange(0, 0) - self.slider.sliderPressed.connect(self.slider_pressed) - self.slider.sliderReleased.connect(self.slider_released) - self.slider.sliderMoved.connect(self.slider_moved) + self.slider.seekRequested.connect(self.on_slider_seek) + self.slider.actionTriggered.connect(self.on_slider_action) + self.slider.scrubStarted.connect(lambda: setattr(self, "_seeking", True)) + self.slider.scrubFinished.connect(lambda: setattr(self, "_seeking", False)) layout.addWidget(self.slider) layout.addSpacing(3) @@ -590,10 +674,6 @@ class QtMusicApp(QMainWindow): layout.addLayout(actions) layout.addStretch(1) - self.queue_label = QLabel("") - self.queue_label.setObjectName("queueLabel") - self.queue_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(self.queue_label) return page def _page_header(self, title: str) -> QHBoxLayout: @@ -745,9 +825,22 @@ class QtMusicApp(QMainWindow): else: self.drawer.setGeometry(-width, 0, width, height) self.scrim.setGeometry(0, 0, central.width(), height) + self._update_cover_size() if self.toast_label.isVisible(): self._place_toast() + def _update_cover_size(self) -> None: + """Обложка — насколько позволяет окно, но не мельче разумного.""" + central = self.centralWidget() + side = min( + central.width() - 64, + int(central.height() * 0.46), + 380, + ) + side = max(240, side) + if self.cover.width() != side: + self.cover.setFixedSize(side, side) + def _place_toast(self) -> None: central = self.centralWidget() self.toast_label.setMaximumWidth(max(200, central.width() - 40)) @@ -833,7 +926,9 @@ class QtMusicApp(QMainWindow): def _fetch_initial(self) -> tuple[str, list[dict], set[str]]: api = self.api uid = self.uid or get_uid(api) - batch = fetch_mix_batch(api) + # Продолжаем волну с последних прослушанных треков, а не с начала. + seed = [str(track.get("id")) for track in self.history[:5]] + batch = fetch_mix_batch(api, seed or None) wave = [ item["track"] for item in (batch.get("sequence") or []) @@ -874,11 +969,6 @@ class QtMusicApp(QMainWindow): self.duration_label.setText(fmt_millis(duration)) self.update_like_button() self.update_play_button() - total = len(self.queue) - position = self.current_index + 1 if self.current_index >= 0 else 0 - self.queue_label.setText( - f"{self.queue_name} · {position}/{total}" if total else self.queue_name - ) def update_like_button(self, liked: bool | None = None) -> None: if liked is None: @@ -1026,16 +1116,17 @@ class QtMusicApp(QMainWindow): return self.toast(message or "Не удалось воспроизвести трек") - def slider_pressed(self) -> None: - self._seeking = True - - def slider_moved(self, value: int) -> None: + def on_slider_seek(self, value: int) -> None: + self._seeking = False + self.player.setPosition(value) self.elapsed_label.setText(fmt_millis(value)) - def slider_released(self) -> None: - self._seeking = False - self.player.setPosition(self.slider.value()) - self.elapsed_label.setText(fmt_millis(self.slider.value())) + def on_slider_action(self, action: int) -> None: + # Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает ClickSlider). + if self.slider.maximum() > 0: + value = self.slider.value() + self.player.setPosition(value) + self.elapsed_label.setText(fmt_millis(value)) def seek(self, delta_ms: int) -> None: if self.player.source().isEmpty(): @@ -1329,6 +1420,24 @@ class QtMusicApp(QMainWindow): return key = event.key() + if key in (Qt.Key.Key_Tab, Qt.Key.Key_Backtab): + super().keyPressEvent(event) + return + if isinstance(focus, QAbstractButton) and key in ( + Qt.Key.Key_Space, + Qt.Key.Key_Return, + Qt.Key.Key_Enter, + ): + super().keyPressEvent(event) + return + if isinstance(focus, QSlider) and key in ( + Qt.Key.Key_Left, + Qt.Key.Key_Right, + Qt.Key.Key_Up, + Qt.Key.Key_Down, + ): + super().keyPressEvent(event) + return if key == Qt.Key.Key_Escape: if self._drawer_open: self.close_drawer() diff --git a/tui.py b/tui.py index 763df56..c90dd89 100644 --- a/tui.py +++ b/tui.py @@ -65,8 +65,8 @@ MONO_THEME = Theme( def cover_renderable(data: bytes, width: int) -> Text: - """Обложка в оттенках серого (полностью чёрно-белый интерфейс).""" - image = Image.open(io.BytesIO(data)).convert("L") + """Цветная обложка, интерфейс вокруг остаётся чёрно-белым.""" + image = Image.open(io.BytesIO(data)).convert("RGB") rows = max(1, width // 2) image = image.resize((width, rows * 2), Image.Resampling.LANCZOS) @@ -77,8 +77,8 @@ def cover_renderable(data: bytes, width: int) -> Text: bottom = image.getpixel((x, y * 2 + 1)) canvas.append( "▀", - style=f"#{top:02x}{top:02x}{top:02x} " - f"on #{bottom:02x}{bottom:02x}{bottom:02x}", + style=f"#{top[0]:02x}{top[1]:02x}{top[2]:02x} " + f"on #{bottom[0]:02x}{bottom[1]:02x}{bottom[2]:02x}", ) if y != rows - 1: canvas.append("\n") @@ -598,7 +598,9 @@ class MusicTui(App): self.uid = get_uid(api) from mtsmusic import fetch_mix_batch - batch = fetch_mix_batch(api) + # Продолжаем волну с последних прослушанных треков, а не с начала. + seed = [str(track.get("id")) for track in self.history[:5]] + batch = fetch_mix_batch(api, seed or None) wave = [ item["track"] for item in (batch.get("sequence") or [])