#!/usr/bin/env python3 """Phone-style desktop player for MTS Music (PySide6).""" from __future__ import annotations import sys from pathlib import Path from PySide6.QtCore import ( QEasingCurve, QPoint, QPropertyAnimation, QRectF, QRunnable, QSize, Qt, QThreadPool, QTimer, QUrl, Signal, QObject, ) from PySide6.QtGui import ( QColor, QFont, QFontMetrics, QIcon, QKeyEvent, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap, ) from PySide6.QtMultimedia import ( QAudioDevice, QAudioOutput, QMediaDevices, QMediaPlayer, ) from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QAbstractButton, QCheckBox, QComboBox, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider, QStackedWidget, QStyle, QStyledItemDelegate, QTextEdit, QToolButton, QVBoxLayout, QWidget, ) from media import ( load_history, record_history, track_album, track_artist, track_cover_url, ) from mtsmusic import ( USER_AGENT, Api, collect_download_tracks, download_tracks, fetch_liked_tracks, fetch_mix_batch, fmt_duration, force_ipv4, get_uid, lrclib_lyrics, lyrics_response, lyrics_text, fetch_track_file, ) from settings import ( START_QUEUE_LABELS, START_QUEUES, load_settings, update_settings, ) from mpris import MprisService, track_object_path APP_QSS = """ QWidget { background: #0e0e14; color: #f2f2f7; font-size: 13px; } QLabel { background: transparent; } #brand { font-size: 15px; font-weight: 600; } #topStatus { color: #8b8b9e; font-size: 11px; } #menuBtn { background: transparent; border: none; color: #f2f2f7; font-size: 20px; padding: 2px 6px; border-radius: 8px; } #menuBtn:hover { background: rgba(255,255,255,0.07); } #titleLabel { font-size: 23px; font-weight: 700; } #artistLabel { color: #a7a7b8; font-size: 14px; } #albumLabel { color: #6f6f82; font-size: 12px; } #timeLabel { color: #8b8b9e; font-size: 11px; } #queueLabel { color: #6f6f82; font-size: 11px; } QSlider::groove:horizontal { height: 4px; background: #2a2a36; border-radius: 2px; } QSlider::sub-page:horizontal { background: #ff3b5c; border-radius: 2px; } QSlider::handle:horizontal { width: 12px; margin: -4px 0; border-radius: 6px; background: #ffffff; } QSlider::handle:horizontal:hover { background: #ffd9df; } #playBtn { background: #ff3b5c; border: none; border-radius: 38px; } #playBtn:hover { background: #ff5574; } #prevBtn, #nextBtn { background: rgba(255,255,255,0.05); border: none; border-radius: 22px; } #prevBtn:hover, #nextBtn:hover { background: rgba(255,255,255,0.11); } #actionBtn { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 8px 18px; font-size: 12px; } #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; } #drawer QPushButton { text-align: left; background: transparent; border: none; border-radius: 10px; padding: 11px 12px; font-size: 14px; color: #e8e8f0; } #drawer QPushButton:hover { background: rgba(255,255,255,0.06); } #drawerQuit { color: #ff7d90; } #pageTitle { font-size: 17px; font-weight: 700; } #backBtn { background: transparent; border: none; border-radius: 10px; font-size: 20px; padding: 2px 10px; } #backBtn:hover { background: rgba(255,255,255,0.06); } #pageStatus { color: #8b8b9e; font-size: 12px; } QListWidget { background: transparent; border: none; outline: 0; } QListWidget::item { border: none; } QLineEdit { background: #1b1b25; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px; padding: 9px 12px; font-size: 14px; selection-background-color: #ff3b5c; } QLineEdit:focus { border-color: rgba(255,59,92,0.53); } #settingLabel { color: #a7a7b8; font-size: 13px; min-width: 118px; } #settingValue { color: #f2f2f7; font-size: 12px; min-width: 42px; } QComboBox { background: #1b1b25; border: 1px solid rgba(255,255,255,0.07); border-radius: 10px; padding: 6px 10px; color: #f2f2f7; } QComboBox::drop-down { border: none; width: 20px; } QComboBox QAbstractItemView { background: #1b1b25; color: #f2f2f7; border: 1px solid rgba(255,255,255,0.10); selection-background-color: #ff3b5c; } QCheckBox { color: #dcdce6; font-size: 13px; spacing: 8px; } QCheckBox::indicator { width: 16px; height: 16px; } QCheckBox::indicator:unchecked { border: 1px solid rgba(255,255,255,0.35); border-radius: 4px; background: #1b1b25; } #searchBtn { background: #ff3b5c; border: none; border-radius: 13px; padding: 9px 16px; font-weight: 600; } #searchBtn:hover { background: #ff5574; } QTextEdit { background: transparent; border: none; font-size: 14px; color: #dcdce6; selection-background-color: #ff3b5c; } QScrollBar:vertical { background: transparent; width: 8px; margin: 2px; } QScrollBar::handle:vertical { background: rgba(255,255,255,0.13); border-radius: 4px; min-height: 30px; } QScrollBar::add-line, QScrollBar::sub-line { height: 0; } QScrollBar::add-page, QScrollBar::sub-page { background: transparent; } #toast { background: #23232f; border: 1px solid rgba(255,255,255,0.09); border-radius: 14px; padding: 10px 16px; color: #f2f2f7; font-size: 12px; } """ def media_icon(kind: str, size: int = 64) -> QIcon: """Рисует белые иконки плеера, чтобы они выглядели одинаково в любой теме.""" pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor("#ffffff")) s = size / 64 if kind == "play": path = QPainterPath() path.moveTo(23 * s, 15 * s) path.lineTo(52 * s, 32 * s) path.lineTo(23 * s, 49 * s) path.closeSubpath() painter.drawPath(path) elif kind == "pause": painter.drawRoundedRect( QRectF( 20 * s, 16 * s, 9 * s, 32 * s), 2 * s, 2 * s) painter.drawRoundedRect( QRectF( 35 * s, 16 * s, 9 * s, 32 * s), 2 * s, 2 * s) elif kind == "prev": painter.drawRoundedRect( QRectF( 15 * s, 14 * s, 7 * s, 36 * s), 2 * s, 2 * s) path = QPainterPath() path.moveTo(52 * s, 14 * s) path.lineTo(52 * s, 50 * s) path.lineTo(25 * s, 32 * s) path.closeSubpath() painter.drawPath(path) elif kind == "next": painter.drawRoundedRect( QRectF( 42 * s, 14 * s, 7 * s, 36 * s), 2 * s, 2 * s) path = QPainterPath() path.moveTo(12 * s, 14 * s) path.lineTo(12 * s, 50 * s) path.lineTo(39 * s, 32 * s) path.closeSubpath() painter.drawPath(path) painter.end() return QIcon(pixmap) def fmt_millis(value: int | float | None) -> str: return fmt_duration(int(max(0, value or 0))) class Task(QRunnable): """Небольшая обёртка, чтобы сетевые вызовы не блокировали интерфейс.""" class Signals(QObject): done = Signal(object, object) failed = Signal(object, str) def __init__(self, fn) -> None: super().__init__() self.fn = fn self.signals = Task.Signals() self.setAutoDelete(False) def run(self) -> None: try: result = self.fn() except SystemExit as exc: self._emit_failed(str(exc) or "ошибка") except Exception as exc: # noqa: BLE001 - сообщение показываем в UI self._emit_failed(str(exc) or exc.__class__.__name__) else: 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 ProgressTask(QRunnable): """Задача, которая умеет присылать прогресс в GUI-поток.""" class Signals(QObject): progress = Signal(object) done = Signal(object, object) failed = Signal(object, str) def __init__(self, fn) -> None: super().__init__() self.fn = fn self.signals = ProgressTask.Signals() self.setAutoDelete(False) def run(self) -> None: try: result = self.fn(self._emit_progress) except SystemExit as exc: self._emit_failed(str(exc) or "ошибка") except Exception as exc: # noqa: BLE001 - сообщение показываем в UI self._emit_failed(str(exc) or exc.__class__.__name__) else: try: self.signals.done.emit(self, result) except RuntimeError: pass def _emit_progress(self, event: dict) -> None: try: self.signals.progress.emit(event) except RuntimeError: pass def _emit_failed(self, message: str) -> None: try: self.signals.failed.emit(self, message) except RuntimeError: pass class CoverView(QWidget): """Обложка со скруглениями и аккуратным плейсхолдером.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._pixmap: QPixmap | None = None self._scaled: QPixmap | None = None self._scaled_side = 0 self.setMinimumSize(220, 220) self.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred ) def set_pixmap(self, pixmap: QPixmap | None) -> None: self._pixmap = pixmap self._scaled = None self._scaled_side = 0 self.update() def paintEvent(self, event) -> None: # noqa: N802 - Qt API painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) side = min(self.width(), self.height()) x = (self.width() - side) / 2 y = (self.height() - side) / 2 rect = QRectF(x, y, side, side) clip = QPainterPath() clip.addRoundedRect(rect, 20, 20) painter.setClipPath(clip) if self._pixmap and not self._pixmap.isNull(): if self._scaled is None or self._scaled_side != int(side): self._scaled = self._pixmap.scaled( int(side), int(side), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation, ) self._scaled_side = int(side) scaled = self._scaled dx = x - (scaled.width() - side) / 2 dy = y - (scaled.height() - side) / 2 painter.drawPixmap(int(dx), int(dy), scaled) else: gradient = QLinearGradient(rect.topLeft(), rect.bottomRight()) gradient.setColorAt(0.0, QColor("#252533")) gradient.setColorAt(1.0, QColor("#15151d")) painter.fillRect(rect, gradient) painter.setPen(QColor(255, 255, 255, 48)) font = QFont() font.setPointSize(64) painter.setFont(font) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "♪") painter.setClipping(False) painter.setPen(QPen(QColor(255, 255, 255, 31), 1)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 20, 20) 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): """Две строки: название и исполнитель/альбом, длительность справа.""" def paint(self, painter: QPainter, option, index) -> None: # noqa: N802 track = index.data(Qt.ItemDataRole.UserRole) or {} painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing) selected = bool(option.state & QStyle.StateFlag.State_Selected) hovered = bool(option.state & QStyle.StateFlag.State_MouseOver) if selected: painter.fillRect(option.rect, QColor(255, 59, 92, 38)) elif hovered: painter.fillRect(option.rect, QColor(255, 255, 255, 10)) rect = option.rect.adjusted(14, 9, -14, -9) duration = fmt_millis(track.get("durationMs")) duration_width = 52 text_width = max(40, rect.width() - duration_width - 12) title_font = QFont(option.font) title_font.setPointSizeF(11.5) title_font.setBold(True) painter.setFont(title_font) painter.setPen(QColor("#f2f2f7")) title = QFontMetrics(title_font).elidedText( str(track.get("title") or "?"), Qt.TextElideMode.ElideRight, text_width, ) painter.drawText( QRectF(rect.left(), rect.top(), text_width, 22), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, title, ) sub_font = QFont(option.font) sub_font.setPointSizeF(10) painter.setFont(sub_font) painter.setPen(QColor("#8b8b9e")) line = f"{track_artist(track)} · {track_album(track)}" sub = QFontMetrics(sub_font).elidedText( line, Qt.TextElideMode.ElideRight, text_width ) painter.drawText( QRectF(rect.left(), rect.top() + 24, text_width, 18), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, sub, ) painter.setPen(QColor("#6f6f82")) painter.drawText( QRectF( rect.right() - duration_width, rect.top(), duration_width, 22), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, duration, ) painter.restore() def sizeHint(self, option, index) -> QSize: # noqa: N802 return QSize(0, 62) class Scrim(QFrame): clicked = Signal() def mousePressEvent(self, event) -> None: # noqa: N802 self.clicked.emit() super().mousePressEvent(event) class MprisBridge(QObject): """Принимает команды MPRIS из D-Bus и перекладывает их в поток Qt.""" play = Signal() pause = Signal() playPause = Signal() stop = Signal() next = Signal() previous = Signal() seek = Signal(int) setPosition = Signal(str, int) volume = Signal(float) raiseWindow = Signal() quitApp = Signal() def on_play(self) -> None: self.play.emit() def on_pause(self) -> None: self.pause.emit() def on_play_pause(self) -> None: self.playPause.emit() def on_stop(self) -> None: self.stop.emit() def on_next(self) -> None: self.next.emit() def on_previous(self) -> None: self.previous.emit() def on_seek(self, offset: int) -> None: self.seek.emit(int(offset)) def on_set_position(self, track_id: str, position: int) -> None: self.setPosition.emit(str(track_id), int(position)) def on_volume(self, value: float) -> None: self.volume.emit(float(value)) def on_raise(self) -> None: self.raiseWindow.emit() def on_quit(self) -> None: self.quitApp.emit() class QtMusicApp(QMainWindow): def __init__(self, api: Api) -> None: super().__init__() self.api = api self.settings = load_settings() self.uid = "" self.wave: list[dict] = [] self.queue: list[dict] = [] self.queue_name = "Моя волна" self.current: dict | None = None self.current_index = -1 self.liked_ids: set[str] = set() self.favorites: list[dict] = [] self.search_results: list[dict] = [] self.history: list[dict] = load_history() self.cover_cache: dict[str, QPixmap] = {} self.current_cover_url = "" self.play_token = 0 self.lyrics_token = 0 self._seeking = False self._drawer_open = False self._favorites_loading = False self._like_busy: set[str] = set() self._tasks: set[Task] = set() self._preview = False self.setWindowTitle("MTS Music") self.resize(430, 840) self.setMinimumSize(390, 720) self.setStyleSheet(APP_QSS) self.pool = QThreadPool(self) self.pool.setMaxThreadCount(4) self.covers = QNetworkAccessManager(self) self.player = QMediaPlayer(self) self.audio = QAudioOutput(self) self.audio.setVolume(int(self.settings.get("volume", 80)) / 100) self.player.setAudioOutput(self.audio) self.player.positionChanged.connect(self.on_position) self.player.durationChanged.connect(self.on_player_duration) self.player.playbackStateChanged.connect(self.update_play_button) self.player.mediaStatusChanged.connect(self.on_media_status) self.player.errorOccurred.connect(self.on_player_error) self._build_ui() self._build_drawer() self._build_toast() self._update_cover_size() self.play_btn.setFocus() # MPRIS: система видит плеер как источник звука (панель GNOME, # playerctl). self.mpris_bridge = MprisBridge(self) self.mpris_bridge.play.connect(self.mpris_play) self.mpris_bridge.pause.connect(self.mpris_pause) self.mpris_bridge.playPause.connect(self.toggle_play) self.mpris_bridge.stop.connect(self.mpris_stop) self.mpris_bridge.next.connect(lambda: self.advance(1)) self.mpris_bridge.previous.connect(lambda: self.advance(-1)) self.mpris_bridge.seek.connect(self.mpris_seek) self.mpris_bridge.setPosition.connect(self.mpris_set_position) self.mpris_bridge.volume.connect(self.mpris_set_volume) self.mpris_bridge.raiseWindow.connect(self.mpris_raise) self.mpris_bridge.quitApp.connect(self.close) self.mpris = MprisService(self.mpris_bridge, identity="MTS Music") self.mpris.update_volume(self.audio.volume()) QTimer.singleShot(0, self.load_initial) # -- построение интерфейса ------------------------------------------- def _build_ui(self) -> None: central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) self.pages = QStackedWidget() root.addWidget(self.pages) self.main_page = self._build_main_page() self.pages.addWidget(self.main_page) ( self.favorites_page, self.favorites_list, self.favorites_status, ) = self._build_list_page("Избранное") self.favorites_list._queue_name = "Избранное" self.favorites_list._tracks = [] self.favorites_list.itemDoubleClicked.connect( lambda item: self.play_from_list(self.favorites_list, item) ) self.pages.addWidget(self.favorites_page) ( self.history_page, self.history_list, self.history_status, ) = self._build_list_page("История") self.history_list._queue_name = "История" self.history_list._tracks = [] self.history_list.itemDoubleClicked.connect( lambda item: self.play_from_list(self.history_list, item) ) self.pages.addWidget(self.history_page) ( self.wave_page, self.wave_list, self.wave_status, ) = self._build_list_page("Моя волна") self.wave_list._queue_name = "Моя волна" self.wave_list._tracks = [] self.wave_list.itemDoubleClicked.connect( lambda item: self.play_from_list(self.wave_list, item) ) self.pages.addWidget(self.wave_page) ( self.search_page, self.search_input, self.search_list, self.search_status, ) = self._build_search_page() self.search_list._queue_name = "Поиск" self.search_list._tracks = [] self.search_list.itemDoubleClicked.connect( lambda item: self.play_from_list(self.search_list, item) ) self.pages.addWidget(self.search_page) ( self.lyrics_page, self.lyrics_title, self.lyrics_status, self.lyrics_body, ) = self._build_lyrics_page() self.pages.addWidget(self.lyrics_page) self.settings_page = self._build_settings_page() self.pages.addWidget(self.settings_page) self.download_page = self._build_download_page() self.pages.addWidget(self.download_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) layout.setContentsMargins(22, 10, 22, 18) layout.setSpacing(0) top = QHBoxLayout() top.setSpacing(6) menu = QToolButton() 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("") self.top_status.setObjectName("topStatus") self.top_status.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) top.addWidget(menu) top.addWidget(brand) top.addStretch(1) top.addWidget(self.top_status) layout.addLayout(top) layout.addStretch(1) self.cover = CoverView() self.cover.setFixedSize(330, 330) layout.addWidget(self.cover, 0, Qt.AlignmentFlag.AlignHCenter) layout.addSpacing(16) self.title_label = QLabel("Загрузка…") self.title_label.setObjectName("titleLabel") self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.title_label.setWordWrap(True) self.title_label.setMaximumHeight(58) layout.addWidget(self.title_label) self.artist_label = QLabel("") self.artist_label.setObjectName("artistLabel") self.artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.artist_label) self.album_label = QLabel("") self.album_label.setObjectName("albumLabel") self.album_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.album_label) layout.addSpacing(14) self.slider = ClickSlider(Qt.Orientation.Horizontal) self.slider.setRange(0, 0) 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) times = QHBoxLayout() self.elapsed_label = QLabel("0:00") self.elapsed_label.setObjectName("timeLabel") self.duration_label = QLabel("0:00") self.duration_label.setObjectName("timeLabel") times.addWidget(self.elapsed_label) times.addStretch(1) times.addWidget(self.duration_label) layout.addLayout(times) layout.addSpacing(10) controls = QHBoxLayout() controls.setSpacing(20) controls.addStretch(1) self.prev_btn = QToolButton() self.prev_btn.setObjectName("prevBtn") self.prev_btn.setIcon(media_icon("prev")) self.prev_btn.setIconSize(QSize(22, 22)) self.prev_btn.setFixedSize(44, 44) self.prev_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.prev_btn.setToolTip("Предыдущий трек (P)") self.prev_btn.clicked.connect(lambda: self.advance(-1)) self.play_btn = QToolButton() self.play_btn.setObjectName("playBtn") self.play_btn.setIcon(media_icon("play", 80)) self.play_btn.setIconSize(QSize(38, 38)) self.play_btn.setFixedSize(76, 76) self.play_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.play_btn.setToolTip("Пауза / продолжить (Space)") self.play_btn.clicked.connect(self.toggle_play) self.next_btn = QToolButton() self.next_btn.setObjectName("nextBtn") self.next_btn.setIcon(media_icon("next")) self.next_btn.setIconSize(QSize(22, 22)) self.next_btn.setFixedSize(44, 44) self.next_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.next_btn.setToolTip("Следующий трек (N)") self.next_btn.clicked.connect(lambda: self.advance(1)) controls.addWidget(self.prev_btn) controls.addWidget(self.play_btn) controls.addWidget(self.next_btn) controls.addStretch(1) layout.addLayout(controls) layout.addSpacing(12) actions = QHBoxLayout() actions.setSpacing(10) actions.addStretch(1) self.lyrics_btn = QPushButton("Текст") self.lyrics_btn.setObjectName("actionBtn") self.lyrics_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.lyrics_btn.setToolTip("Текст песни (T)") self.lyrics_btn.clicked.connect(self.show_lyrics) self.like_btn = QPushButton("♡ В избранное") self.like_btn.setObjectName("actionBtn") self.like_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.like_btn.setToolTip("Добавить / убрать из избранного (L)") self.like_btn.clicked.connect(self.toggle_like) actions.addWidget(self.lyrics_btn) actions.addWidget(self.like_btn) actions.addStretch(1) layout.addLayout(actions) layout.addStretch(1) return page def _page_header(self, title: str) -> QHBoxLayout: row = QHBoxLayout() row.setSpacing(4) back = QToolButton() back.setObjectName("backBtn") back.setText("←") back.setCursor(Qt.CursorShape.PointingHandCursor) back.clicked.connect(self.show_main) label = QLabel(title) label.setObjectName("pageTitle") row.addWidget(back) row.addWidget(label) row.addStretch(1) return row def _build_list_page( self, title: str) -> tuple[QWidget, QListWidget, QLabel]: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(16, 10, 16, 12) layout.setSpacing(6) layout.addLayout(self._page_header(title)) status = QLabel("") status.setObjectName("pageStatus") layout.addWidget(status) widget = QListWidget() widget.setItemDelegate(TrackDelegate(widget)) widget.setUniformItemSizes(True) widget.setMouseTracking(True) widget.setVerticalScrollMode( QAbstractItemView.ScrollMode.ScrollPerPixel ) layout.addWidget(widget, 1) return page, widget, status def _build_search_page( self) -> tuple[QWidget, QLineEdit, QListWidget, QLabel]: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(16, 10, 16, 12) layout.setSpacing(8) layout.addLayout(self._page_header("Поиск")) row = QHBoxLayout() row.setSpacing(8) edit = QLineEdit() edit.setPlaceholderText("Исполнитель или трек…") edit.returnPressed.connect(self.run_search) button = QPushButton("Найти") button.setObjectName("searchBtn") button.setCursor(Qt.CursorShape.PointingHandCursor) button.clicked.connect(self.run_search) row.addWidget(edit, 1) row.addWidget(button) layout.addLayout(row) status = QLabel("") status.setObjectName("pageStatus") layout.addWidget(status) widget = QListWidget() widget.setItemDelegate(TrackDelegate(widget)) widget.setUniformItemSizes(True) widget.setMouseTracking(True) widget.setVerticalScrollMode( QAbstractItemView.ScrollMode.ScrollPerPixel ) layout.addWidget(widget, 1) return page, edit, widget, status def _build_lyrics_page(self) -> tuple[QWidget, QLabel, QLabel, QTextEdit]: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(16, 10, 16, 12) layout.setSpacing(6) layout.addLayout(self._page_header("Текст песни")) title = QLabel("") title.setObjectName("artistLabel") layout.addWidget(title) status = QLabel("") status.setObjectName("pageStatus") layout.addWidget(status) body = QTextEdit() body.setReadOnly(True) body.setFrameShape(QFrame.Shape.NoFrame) layout.addWidget(body, 1) return page, title, status, body def _build_settings_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(18, 10, 18, 14) layout.setSpacing(12) layout.addLayout(self._page_header("Настройки")) vol_row = QHBoxLayout() vol_row.setSpacing(10) vol_label = QLabel("Громкость") vol_label.setObjectName("settingLabel") self.volume_slider = QSlider(Qt.Orientation.Horizontal) self.volume_slider.setRange(0, 100) self.volume_slider.setValue(int(self.settings.get("volume", 80))) self.volume_value = QLabel(f"{self.volume_slider.value()} %") self.volume_value.setObjectName("settingValue") self.volume_slider.valueChanged.connect(self.on_volume_changed) vol_row.addWidget(vol_label) vol_row.addWidget(self.volume_slider, 1) vol_row.addWidget(self.volume_value) layout.addLayout(vol_row) dev_row = QHBoxLayout() dev_row.setSpacing(10) dev_label = QLabel("Устройство") dev_label.setObjectName("settingLabel") self.device_combo = QComboBox() self.device_combo.setObjectName("settingCombo") for device in QMediaDevices.audioOutputs(): self.device_combo.addItem(device.description(), device) current_device = self.audio.device() if current_device and not current_device.isNull(): idx = self.device_combo.findData(current_device) if idx >= 0: self.device_combo.setCurrentIndex(idx) self.device_combo.currentIndexChanged.connect(self.on_device_changed) dev_row.addWidget(dev_label) dev_row.addWidget(self.device_combo, 1) layout.addLayout(dev_row) seek_row = QHBoxLayout() seek_row.setSpacing(10) seek_label = QLabel("Шаг перемотки") seek_label.setObjectName("settingLabel") self.seek_step_slider = QSlider(Qt.Orientation.Horizontal) self.seek_step_slider.setRange(1, 60) self.seek_step_slider.setValue(int(self.settings.get("seek_step", 5))) self.seek_step_value = QLabel(f"{self.seek_step_slider.value()} с") self.seek_step_value.setObjectName("settingValue") self.seek_step_slider.valueChanged.connect(self.on_seek_step_changed) seek_row.addWidget(seek_label) seek_row.addWidget(self.seek_step_slider, 1) seek_row.addWidget(self.seek_step_value) layout.addLayout(seek_row) self.autoplay_check = QCheckBox("Запускать воспроизведение при старте") self.autoplay_check.setChecked(bool(self.settings.get("autoplay"))) self.autoplay_check.toggled.connect(self.on_autoplay_toggled) layout.addWidget(self.autoplay_check) start_row = QHBoxLayout() start_row.setSpacing(10) start_label = QLabel("Стартовый список") start_label.setObjectName("settingLabel") self.start_combo = QComboBox() self.start_combo.setObjectName("settingCombo") for key in START_QUEUES: self.start_combo.addItem(START_QUEUE_LABELS[key], key) start_index = self.start_combo.findData( self.settings.get("start_queue", "wave")) self.start_combo.setCurrentIndex(max(0, start_index)) self.start_combo.currentIndexChanged.connect(self.on_start_changed) start_row.addWidget(start_label) start_row.addWidget(self.start_combo, 1) layout.addLayout(start_row) self.wave_check = QCheckBox( "Продолжать «Мою волну» с последних треков") self.wave_check.setChecked( bool( self.settings.get( "wave_from_history", True))) self.wave_check.toggled.connect(self.on_wave_from_history_toggled) layout.addWidget(self.wave_check) hint = QLabel("Настройки сохраняются в .mts-music/settings.json") hint.setObjectName("pageStatus") hint.setWordWrap(True) layout.addWidget(hint) layout.addStretch(1) return page def _build_download_page(self) -> QWidget: page = QWidget() layout = QVBoxLayout(page) layout.setContentsMargins(18, 10, 18, 14) layout.setSpacing(12) layout.addLayout(self._page_header("Скачать")) dir_label = QLabel("Папка для сохранения") dir_label.setObjectName("settingLabel") layout.addWidget(dir_label) dir_row = QHBoxLayout() dir_row.setSpacing(8) self.download_dir = QLineEdit(str(Path.home() / "Music" / "MTS")) self.download_dir.setObjectName("downloadDir") dir_row.addWidget(self.download_dir, 1) browse = QPushButton("Обзор…") browse.setObjectName("actionBtn") browse.setCursor(Qt.CursorShape.PointingHandCursor) browse.clicked.connect(self.browse_download_dir) dir_row.addWidget(browse) layout.addLayout(dir_row) source_label = QLabel("Что скачать") source_label.setObjectName("settingLabel") layout.addWidget(source_label) self.download_source = QComboBox() self.download_source.setObjectName("settingCombo") for text, kind in ( ("Текущий трек", "current"), ("Всё избранное", "likes"), ("Моя волна (20 треков)", "wave"), ("Результаты поиска", "search"), ): self.download_source.addItem(text, kind) layout.addWidget(self.download_source) self.download_btn = QPushButton("Скачать") self.download_btn.setObjectName("actionBtn") self.download_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.download_btn.clicked.connect(self.start_download) layout.addWidget(self.download_btn) self.download_progress = QProgressBar() self.download_progress.setRange(0, 1) self.download_progress.setValue(0) self.download_progress.setTextVisible(False) layout.addWidget(self.download_progress) self.download_status = QLabel( "Уже скачанные файлы пропускаются, имена — " "«Исполнитель - Название.mp3»." ) self.download_status.setObjectName("pageStatus") self.download_status.setWordWrap(True) layout.addWidget(self.download_status) layout.addStretch(1) return page # -- скачивание ------------------------------------------------------- def show_download(self) -> None: self._show(self.download_page) def browse_download_dir(self) -> None: directory = QFileDialog.getExistingDirectory( self, "Папка для скачивания", self.download_dir.text().strip() ) if directory: self.download_dir.setText(directory) def start_download(self) -> None: raw = self.download_dir.text().strip() if not raw: self.toast("Укажи папку для сохранения") return destination = Path(raw).expanduser() kind = self.download_source.currentData() if kind == "current" and not self.current: self.toast("Сначала выбери трек") return if kind == "search" and not self.search_results: self.toast("Сначала выполни поиск") return self.download_btn.setEnabled(False) self.download_progress.setRange(0, 0) self.download_status.setText("Готовлю список…") self.run_progress_task( lambda progress: self._download_job(kind, destination, progress), self.download_done, self.download_failed, ) def _download_job(self, kind: str, destination: Path, progress) -> tuple: if kind == "current": tracks = [self.current] elif kind == "likes": tracks = collect_download_tracks(self.api, likes=True) elif kind == "wave": tracks = collect_download_tracks(self.api, wave=True, number=20) else: tracks = list(self.search_results) if not tracks: raise ValueError("нечего скачивать") saved, skipped, failed = download_tracks( self.api, tracks, destination, on_event=progress ) return saved, skipped, failed, destination def on_download_progress(self, event: dict) -> None: track = event["track"] self.download_progress.setRange(0, event["total"]) self.download_progress.setValue(event["index"] - 1) title = f"{track_artist(track)} — {track.get('title') or '?'}" self.download_status.setText( f"[{event['index']}/{event['total']}] {title}") def download_done(self, result: tuple) -> None: saved, skipped, failed, destination = result self.download_btn.setEnabled(True) self.download_progress.setRange(0, 1) self.download_progress.setValue(1) message = ( f"Готово: {saved} сохранено, {skipped} уже было, " f"{failed} ошибок" ) self.download_status.setText(f"{message}\n{destination}") self.toast(message) def download_failed(self, message: str) -> None: self.download_btn.setEnabled(True) self.download_progress.setRange(0, 1) self.download_progress.setValue(0) self.download_status.setText(f"Ошибка: {message}") self.toast(message) # -- настройки -------------------------------------------------------- def update_setting(self, key: str, value) -> None: self.settings = update_settings(**{key: value}) def on_volume_changed(self, value: int) -> None: self.volume_value.setText(f"{value} %") self.audio.setVolume(value / 100) self.update_setting("volume", value) if getattr(self, "mpris", None): self.mpris.update_volume(value / 100) def on_device_changed(self, index: int) -> None: device = self.device_combo.itemData(index) if isinstance(device, QAudioDevice) and not device.isNull(): self.audio.setDevice(device) def on_seek_step_changed(self, value: int) -> None: self.seek_step_value.setText(f"{value} с") self.update_setting("seek_step", value) def on_autoplay_toggled(self, checked: bool) -> None: self.update_setting("autoplay", checked) def on_start_changed(self, index: int) -> None: self.update_setting("start_queue", self.start_combo.itemData(index)) def on_wave_from_history_toggled(self, checked: bool) -> None: self.update_setting("wave_from_history", checked) def show_settings(self) -> None: self._show(self.settings_page) def _build_drawer(self) -> None: central = self.centralWidget() self.scrim = Scrim(central) self.scrim.setStyleSheet("background: rgba(0, 0, 0, 150);") self.scrim.hide() self.scrim.clicked.connect(self.close_drawer) self.drawer = QFrame(central) self.drawer.setObjectName("drawer") self.drawer.hide() layout = QVBoxLayout(self.drawer) layout.setContentsMargins(18, 18, 18, 18) layout.setSpacing(2) title = QLabel("Меню") title.setObjectName("drawerTitle") brand = QLabel("MTS Music") brand.setObjectName("drawerBrand") layout.addWidget(title) layout.addWidget(brand) for text, handler in ( ("Моя волна", self.show_wave), ("Избранное", self.show_favorites), ("История", self.show_history), ("Поиск", self.show_search), ("Скачать", self.show_download), ("Текст песни", self.show_lyrics), ("Настройки", self.show_settings), ): button = QPushButton(text) button.setCursor(Qt.CursorShape.PointingHandCursor) button.clicked.connect(handler) layout.addWidget(button) layout.addStretch(1) quit_button = QPushButton("Выход") quit_button.setObjectName("drawerQuit") quit_button.setCursor(Qt.CursorShape.PointingHandCursor) quit_button.clicked.connect(self.close) layout.addWidget(quit_button) self.drawer_anim = QPropertyAnimation(self.drawer, b"pos", self) self.drawer_anim.setDuration(180) self.drawer_anim.setEasingCurve(QEasingCurve.Type.OutCubic) self.drawer_anim.finished.connect(self._drawer_anim_finished) def _build_toast(self) -> None: self.toast_label = QLabel("", self.centralWidget()) self.toast_label.setObjectName("toast") self.toast_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.toast_label.setWordWrap(True) self.toast_label.hide() self.toast_timer = QTimer(self) self.toast_timer.setSingleShot(True) self.toast_timer.timeout.connect(self.toast_label.hide) # -- геометрия -------------------------------------------------------- def resizeEvent(self, event) -> None: # noqa: N802 super().resizeEvent(event) if not hasattr(self, "drawer"): return central = self.centralWidget() width = min(300, max(240, central.width() - 60)) height = central.height() if self._drawer_open: self.drawer.setGeometry(0, 0, width, height) 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)) self.toast_label.adjustSize() x = (central.width() - self.toast_label.width()) // 2 y = central.height() - self.toast_label.height() - 28 self.toast_label.move(max(12, x), max(12, y)) def toast(self, text: str) -> None: self.toast_label.setText(text) self._place_toast() self.toast_label.show() self.toast_label.raise_() self.toast_timer.start(2800) def set_top_status(self, text: str) -> None: self.top_status.setText(text) def open_drawer(self) -> None: if self._drawer_open: self.close_drawer() return central = self.centralWidget() width = min(300, max(240, central.width() - 60)) self.scrim.setGeometry(0, 0, central.width(), central.height()) self.scrim.show() self.scrim.raise_() self.drawer.setGeometry(-width, 0, width, central.height()) self.drawer.show() self.drawer.raise_() self._drawer_open = True self.drawer_anim.stop() self.drawer_anim.setStartValue(QPoint(-width, 0)) self.drawer_anim.setEndValue(QPoint(0, 0)) self.drawer_anim.start() def close_drawer(self) -> None: if not self._drawer_open: return self._drawer_open = False width = self.drawer.width() self.drawer_anim.stop() self.drawer_anim.setStartValue(self.drawer.pos()) self.drawer_anim.setEndValue(QPoint(-width, 0)) self.drawer_anim.start() def _drawer_anim_finished(self) -> None: if not self._drawer_open: self.drawer.hide() self.scrim.hide() # -- загрузка данных -------------------------------------------------- def run_task(self, fn, done, failed) -> None: task = Task(fn) task.done_cb = done task.failed_cb = failed task.signals.done.connect(self._task_done) task.signals.failed.connect(self._task_failed) self._tasks.add(task) self.pool.start(task) def run_progress_task(self, fn, done, failed) -> None: task = ProgressTask(fn) task.done_cb = done task.failed_cb = failed task.signals.done.connect(self._task_done) task.signals.failed.connect(self._task_failed) task.signals.progress.connect(self.on_download_progress) self._tasks.add(task) self.pool.start(task) def _task_done(self, task: Task, result) -> None: if task not in self._tasks: return self._tasks.discard(task) task.done_cb(result) def _task_failed(self, task: Task, message: str) -> None: if task not in self._tasks: return self._tasks.discard(task) task.failed_cb(message) def on_error(self, message: str) -> None: self.set_top_status("Ошибка") self.toast(message) def load_initial(self) -> None: self.set_top_status("Загрузка…") self.run_task(self._fetch_initial, self.apply_initial, self.on_error) def _fetch_initial(self) -> tuple[str, list[dict], set[str], list[dict], str]: api = self.api uid = self.uid or get_uid(api) # Продолжаем волну с последних прослушанных треков, а не с начала. if self.settings.get("wave_from_history", True): seed = [str(track.get("id")) for track in self.history[:5]] else: seed = None batch = fetch_mix_batch(api, seed) wave = [ item["track"] for item in (batch.get("sequence") or []) if item.get("type") == "track" and item.get("track") ] want = self.settings.get("start_queue", "wave") start: list[dict] = wave name = "Моя волна" if want == "favorites": start = fetch_liked_tracks(api, uid) name = "Избранное" liked = {str(track.get("id")) for track in start} else: payload = api.get(f"/users/{uid}/likes/tracks") refs = ((payload.get("result") or {}).get( "library") or {}).get("tracks") or [] liked = {str(ref.get("id")) for ref in refs} if want == "history": start, name = list(self.history), "История" return uid, wave, liked, start, name def apply_initial( self, result: tuple[str, list[dict], set[str], list[dict], str] ) -> None: self.uid, self.wave, self.liked_ids, start, name = result self.queue = list(start) self.queue_name = name if not start: self.title_label.setText("Ничего не найдено") return self.set_top_status(f"{name} · {len(start)} · ♥ {len(self.liked_ids)}") self.current = start[0] self.current_index = 0 self.update_now_playing() self.load_cover(start[0]) if self.settings.get("autoplay"): self.play_track(start[0], self.queue, 0) # -- текущий трек ----------------------------------------------------- def update_now_playing(self) -> None: track = self.current if not track: self.title_label.setText("Ничего не играет") return self.title_label.setText(str(track.get("title") or "?")) self.artist_label.setText(track_artist(track)) self.album_label.setText(track_album(track)) duration = int(track.get("durationMs") or 0) self.slider.setRange(0, max(duration, 1)) self.slider.setValue(0) self.elapsed_label.setText("0:00") self.duration_label.setText(fmt_millis(duration)) self.update_like_button() self.update_play_button() self._update_mpris_metadata() def update_like_button(self, liked: bool | None = None) -> None: if liked is None: liked = bool( self.current and str( self.current.get("id")) in self.liked_ids) self.like_btn.setText("♥ В избранном" if liked else "♡ В избранное") self.like_btn.setProperty("liked", "true" if liked else "false") self.like_btn.style().unpolish(self.like_btn) self.like_btn.style().polish(self.like_btn) def update_play_button(self, *args) -> None: state = self.player.playbackState() playing = state == QMediaPlayer.PlaybackState.PlayingState self.play_btn.setIcon(media_icon("pause" if playing else "play", 80)) self._update_mpris_status() # -- обложка ---------------------------------------------------------- def load_cover(self, track: dict) -> None: url = track_cover_url(track) self.current_cover_url = url or "" if not url: self.cover.set_pixmap(None) return cached = self.cover_cache.get(url) if cached is not None: self.cover.set_pixmap(cached) return self.cover.set_pixmap(None) request = QNetworkRequest(QUrl(url)) request.setHeader( QNetworkRequest.KnownHeaders.UserAgentHeader, USER_AGENT) reply = self.covers.get(request) def done() -> None: try: data = bytes(reply.readAll().data()) pixmap = QPixmap() if not data or not pixmap.loadFromData(data): return self.cover_cache[url] = pixmap if len(self.cover_cache) > 30: self.cover_cache.pop(next(iter(self.cover_cache))) if url == self.current_cover_url: self.cover.set_pixmap(pixmap) finally: reply.deleteLater() reply.finished.connect(done) # -- воспроизведение -------------------------------------------------- def play_from_list( self, widget: QListWidget, item: QListWidgetItem) -> None: tracks = getattr(widget, "_tracks", []) index = widget.row(item) if not (0 <= index < len(tracks)): return self.queue = list(tracks) self.queue_name = getattr(widget, "_queue_name", "Список") self.play_track(tracks[index], self.queue, index) self.show_main() def play_track( self, track: dict, queue: list[dict] | None = None, index: int | None = None, ) -> None: if queue is not None: self.queue = queue if index is None: index = next( ( i for i, item in enumerate(self.queue) if str(item.get("id")) == str(track.get("id")) ), -1, ) self.current = track self.current_index = index self.play_token += 1 token = self.play_token self.update_now_playing() self.load_cover(track) self.set_top_status("Загрузка…") self.run_task( lambda: fetch_track_file(self.api, str(track.get("id"))), lambda result: self.start_playback(token, track, result), self.on_error, ) def start_playback( self, token: int, track: dict, result: tuple[str, dict] ) -> None: if token != self.play_token: return path, item = result self._preview = bool(item.get("preview")) self.player.setSource(QUrl.fromLocalFile(str(path))) self.player.play() self.history = record_history(track) title = str(track.get("title") or "") self.set_top_status(("preview · " if self._preview else "") + title) if self.pages.currentWidget() is self.history_page: self.fill_history() self.update_play_button() def toggle_play(self) -> None: state = self.player.playbackState() if self.player.source().isEmpty(): if self.current: self.play_track(self.current, self.queue, self.current_index) return if state == QMediaPlayer.PlaybackState.PlayingState: self.player.pause() else: self.player.play() self.update_play_button() def on_position(self, position: int) -> None: if self._seeking: return duration = self.slider.maximum() if duration > 1: self.slider.setValue(min(position, duration)) self.elapsed_label.setText(fmt_millis(position)) if getattr(self, "mpris", None): self.mpris.update_position(int(position) * 1000) def on_player_duration(self, duration: int) -> None: track_duration = int((self.current or {}).get("durationMs") or 0) if track_duration <= 0 and duration > 0: self.slider.setRange(0, duration) self.duration_label.setText(fmt_millis(duration)) def on_media_status(self, status: QMediaPlayer.MediaStatus) -> None: if status == QMediaPlayer.MediaStatus.EndOfMedia: self.advance(1) def on_player_error(self, error, message: str) -> None: if error == QMediaPlayer.Error.NoError: return self.toast(message or "Не удалось воспроизвести трек") def on_slider_seek(self, value: int) -> None: self._seeking = False self.player.setPosition(value) self.elapsed_label.setText(fmt_millis(value)) if getattr(self, "mpris", None): self.mpris.notify_seeked(value * 1000) 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)) if getattr(self, "mpris", None): self.mpris.notify_seeked(value * 1000) def seek(self, delta_ms: int) -> None: if self.player.source().isEmpty(): return duration = self.slider.maximum() position = max(0, min(self.player.position() + delta_ms, max(0, duration - 500))) self.player.setPosition(position) self.slider.setValue(position) self.elapsed_label.setText(fmt_millis(position)) if getattr(self, "mpris", None): self.mpris.notify_seeked(position * 1000) # -- MPRIS ------------------------------------------------------------ def mpris_play(self) -> None: state = self.player.playbackState() if state != QMediaPlayer.PlaybackState.PlayingState: self.toggle_play() def mpris_pause(self) -> None: state = self.player.playbackState() if state == QMediaPlayer.PlaybackState.PlayingState: self.toggle_play() def mpris_stop(self) -> None: self.player.stop() self.slider.setValue(0) self.elapsed_label.setText("0:00") self.update_play_button() def mpris_seek(self, offset_us: int) -> None: if self.player.source().isEmpty(): return duration = self.slider.maximum() position = max(0, min(self.player.position() + offset_us // 1000, max(0, duration - 500))) self.player.setPosition(position) self.slider.setValue(position) self.elapsed_label.setText(fmt_millis(position)) self.mpris.notify_seeked(position * 1000) def mpris_set_position(self, track_path: str, position_us: int) -> None: if not self.current: return if track_object_path(self.current.get("id")) != track_path: return position = max(0, position_us // 1000) self.player.setPosition(position) self.slider.setValue(position) self.elapsed_label.setText(fmt_millis(position)) self.mpris.update_position(position_us) def mpris_set_volume(self, value: float) -> None: value = max(0.0, min(1.0, value)) self.audio.setVolume(value) self.volume_slider.setValue(int(round(value * 100))) def mpris_raise(self) -> None: self.showNormal() self.raise_() self.activateWindow() def _update_mpris_status(self) -> None: if not getattr(self, "mpris", None): return state = self.player.playbackState() if state == QMediaPlayer.PlaybackState.PlayingState: status = "Playing" elif state == QMediaPlayer.PlaybackState.PausedState: status = "Paused" else: status = "Stopped" self.mpris.update_playback_status(status) def _update_mpris_metadata(self) -> None: if not getattr(self, "mpris", None): return track = self.current if not track: self.mpris.clear_metadata() self.mpris.update_capabilities( can_next=False, can_previous=False, can_seek=False ) return self.mpris.update_metadata( track.get("id"), title=str(track.get("title") or ""), artist=track_artist(track), album=track_album(track), duration_ms=int(track.get("durationMs") or 0), art_url=track_cover_url(track) or "", ) has_queue = bool(self.queue or self.wave) self.mpris.update_capabilities( can_next=has_queue, can_previous=has_queue, can_seek=True ) def advance(self, direction: int) -> None: queue = self.queue or self.wave if not queue: return index = self.current_index + direction if index >= len(queue): if self.queue_name == "Моя волна": self.load_more_wave(index) return index = 0 if index < 0: index = len(queue) - 1 self.play_track(queue[index], queue, index) def load_more_wave(self, then_play: int) -> None: self.set_top_status("Догружаю волну…") def fetch() -> list[dict]: ids = [str(track.get("id")) for track in self.wave[-5:]] batch = fetch_mix_batch(self.api, ids or None) return [ item["track"] for item in (batch.get("sequence") or []) if item.get("type") == "track" and item.get("track") ] self.run_task( fetch, lambda tracks: self.apply_more_wave( tracks, then_play), self.on_error) def apply_more_wave(self, tracks: list[dict], then_play: int) -> None: known = {str(track.get("id")) for track in self.wave} added = [ track for track in tracks if str( track.get("id")) not in known] self.wave.extend(added) self.set_top_status( f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}") if self.queue_name == "Моя волна": self.queue = list(self.wave) if then_play < len(self.queue) and added: self.play_track(self.queue[then_play], self.queue, then_play) elif not added: self.toast("Волна закончилась") # -- избранное -------------------------------------------------------- def toggle_like(self) -> None: track = self.current if not track: return track_id = str(track.get("id")) if track_id in self._like_busy: return albums = track.get("albums") or [] album = str(albums[0].get("id")) if albums else "" if not album: self.toast("У трека нет альбома") return liked = track_id in self.liked_ids self._like_busy.add(track_id) def request() -> str: uid = self.uid or get_uid(self.api) if liked: self.api.post( f"/users/{uid}/likes/tracks/remove", **{"track-ids": f"{track_id}:{album}"}, ) else: self.api.post( f"/users/{uid}/likes/tracks/add", **{"track-id": f"{track_id}:{album}"}, ) return track_id self.run_task( request, lambda _: self.apply_like( track, not liked), self.like_failed) def apply_like(self, track: dict, liked: bool) -> None: track_id = str(track.get("id")) self._like_busy.discard(track_id) if liked: self.liked_ids.add(track_id) else: self.liked_ids.discard(track_id) if self.current and str(self.current.get("id")) == track_id: self.update_like_button(liked) self.set_top_status(f"{self.queue_name} · ♥ {len(self.liked_ids)}") self.toast( "♥ Добавлено в избранное" if liked else "♡ Убрано из избранного") if self.pages.currentWidget() is self.favorites_page: self.load_favorites(force=True) def like_failed(self, message: str) -> None: if self.current: self._like_busy.discard(str(self.current.get("id"))) self.on_error(message) # -- экраны ----------------------------------------------------------- def show_main(self) -> None: self.close_drawer() self.pages.setCurrentWidget(self.main_page) def _show(self, page: QWidget) -> None: self.close_drawer() self.pages.setCurrentWidget(page) def show_favorites(self) -> None: self._show(self.favorites_page) self.load_favorites() def show_history(self) -> None: self.history = load_history() self.fill_history() self._show(self.history_page) def show_wave(self) -> None: self.fill_track_list(self.wave_list, self.wave, "Моя волна") self.wave_status.setText( f"{len(self.wave)} трек(ов) · догружается автоматически" if self.wave else "Пока пусто" ) self._show(self.wave_page) def show_search(self) -> None: self._show(self.search_page) self.search_input.setFocus() self.search_input.selectAll() def show_lyrics(self) -> None: track = self.current if not track: self.toast("Сначала выбери трек") return self.close_drawer() self.pages.setCurrentWidget(self.lyrics_page) self.lyrics_title.setText(str(track.get("title") or "")) self.lyrics_status.setText("Загружаю текст…") self.lyrics_body.setPlainText("") self.lyrics_token += 1 token = self.lyrics_token self.run_task( lambda: self._fetch_lyrics(track), lambda result: self.apply_lyrics(token, result), lambda message: self.lyrics_failed(token, message), ) def _fetch_lyrics(self, track: dict) -> tuple[str, str]: text = "" source = "MTS" try: response = lyrics_response(self.api, str(track.get("id")), "TEXT") if response.status_code == 200: text = lyrics_text(self.api, response) if "Подключите подписку" in text: text = "" except Exception: # noqa: BLE001 - просто уходим в резервный источник text = "" if not text.strip(): text = lrclib_lyrics(track, want_sync=False) or "" source = "LRCLIB" return text, source def apply_lyrics(self, token: int, result: tuple[str, str]) -> None: if token != self.lyrics_token: return text, source = result if not text.strip(): self.lyrics_status.setText("Текст не найден") return self.lyrics_status.setText(f"Источник: {source}") self.lyrics_body.setPlainText(text) def lyrics_failed(self, token: int, message: str) -> None: if token != self.lyrics_token: return self.lyrics_status.setText("Текст не найден") self.toast(message) # -- списки ----------------------------------------------------------- def fill_track_list( self, widget: QListWidget, tracks: list[dict], queue_name: str ) -> None: widget.clear() widget._tracks = list(tracks) widget._queue_name = queue_name for track in tracks: item = QListWidgetItem() item.setData(Qt.ItemDataRole.UserRole, track) item.setSizeHint(QSize(0, 62)) widget.addItem(item) def fill_history(self) -> None: self.fill_track_list(self.history_list, self.history, "История") self.history_status.setText( f"{len(self.history)} трек(ов)" if self.history else "Пока пусто" ) def load_favorites(self, force: bool = False) -> None: if self._favorites_loading: return if self.favorites and not force: self.fill_track_list( self.favorites_list, self.favorites, "Избранное") self.favorites_status.setText(f"{len(self.favorites)} трек(ов)") return self._favorites_loading = True self.favorites_status.setText("Загружаю…") if not self.favorites: self.favorites_list.clear() self.run_task( self._fetch_favorites, self.apply_favorites, self.favorites_failed) def _fetch_favorites(self) -> list[dict]: uid = self.uid or get_uid(self.api) return fetch_liked_tracks(self.api, uid) def apply_favorites(self, tracks: list[dict]) -> None: self._favorites_loading = False self.favorites = tracks self.fill_track_list(self.favorites_list, tracks, "Избранное") self.favorites_status.setText( f"{len(tracks)} трек(ов)" if tracks else "Пока пусто" ) def favorites_failed(self, message: str) -> None: self._favorites_loading = False self.favorites_status.setText("Не удалось загрузить") self.toast(message) def run_search(self) -> None: query = self.search_input.text().strip() if not query: return self.search_status.setText("Ищу…") def search() -> list[dict]: payload = self.api.get( "/search", text=query, type="track", page=0, **{"page-size": 25}, nocorrect="true", ) return (((payload.get("result") or {}).get( "tracks") or {}).get("results") or []) self.run_task(search, self.apply_search, self.search_failed) def apply_search(self, tracks: list[dict]) -> None: self.search_results = tracks self.fill_track_list(self.search_list, tracks, "Поиск") self.search_status.setText( f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено" ) def search_failed(self, message: str) -> None: self.search_status.setText("Ошибка поиска") self.toast(message) # -- клавиатура ------------------------------------------------------- def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 focus = QApplication.focusWidget() if isinstance(focus, QLineEdit) and event.key() != Qt.Key.Key_Escape: super().keyPressEvent(event) return if event.modifiers() & Qt.KeyboardModifier.ControlModifier: if event.key() == Qt.Key.Key_Q: self.close() return super().keyPressEvent(event) 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() elif self.pages.currentWidget() is not self.main_page: self.show_main() return if key == Qt.Key.Key_Space: self.toggle_play() elif key == Qt.Key.Key_Left: self.seek(-int(self.settings.get("seek_step", 5)) * 1000) elif key == Qt.Key.Key_Right: self.seek(int(self.settings.get("seek_step", 5)) * 1000) elif key == Qt.Key.Key_N: self.advance(1) elif key == Qt.Key.Key_P: self.advance(-1) elif key == Qt.Key.Key_L: self.toggle_like() elif key == Qt.Key.Key_T: self.show_lyrics() elif key == Qt.Key.Key_M: self.open_drawer() elif key == Qt.Key.Key_F: self.show_favorites() elif key == Qt.Key.Key_H: self.show_history() elif key == Qt.Key.Key_S: self.show_search() elif key == Qt.Key.Key_W: self.show_wave() else: super().keyPressEvent(event) def closeEvent(self, event) -> None: # noqa: N802 self.player.stop() if getattr(self, "mpris", None): self.mpris.close() self.pool.clear() super().closeEvent(event) def main() -> int: force_ipv4() app = QApplication(sys.argv) app.setApplicationName("MTS Music") app.setApplicationDisplayName("MTS Music") app.setStyle("Fusion") try: api = Api() except SystemExit: return 1 if not api.token: QMessageBox.warning( None, "MTS Music", "Сначала войди в аккаунт:\n\n.venv/bin/python mtsmusic.py login", ) return 1 window = QtMusicApp(api) window.show() return app.exec() if __name__ == "__main__": raise SystemExit(main())