feat: polish Qt player and keep TUI cover in color

This commit is contained in:
jads
2026-10-23 22:20:36 +03:00
parent 0eb6909724
commit b64c2dcd12
3 changed files with 155 additions and 38 deletions

161
qt_app.py
View File

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