From 26a58068cb12d9140b3818714de22e8abf7b80b0 Mon Sep 17 00:00:00 2001 From: jads Date: Fri, 23 Oct 2026 21:54:21 +0300 Subject: [PATCH] feat: add phone-style PySide6 desktop player --- README.md | 25 +- mtsmusic.py | 14 + qt_app.py | 1394 +++++++++++++++++++++++++++++++++++++++++++ requirements-qt.txt | 2 + 4 files changed, 1434 insertions(+), 1 deletion(-) create mode 100644 qt_app.py create mode 100644 requirements-qt.txt diff --git a/README.md b/README.md index 7e63165..f5afd1a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # MTS Music CLI Минимальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск, -избранное, тексты песен, воспроизведение и скачивание в MP3. +избранное, тексты песен, воспроизведение и скачивание в MP3. Есть компактный +TUI-плеер и настольное Qt-приложение в стиле телефона. Внутри используется тот же путь, что и в веб-клиенте: MTS SSO выдаёт OAuth-токен Яндекс.Музыки, дальше запросы идут на `api.music.yandex.net`. @@ -14,6 +15,12 @@ python3 -m venv .venv PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromium ``` +Для Qt-приложения дополнительно: + +```bash +.venv/bin/pip install -r requirements-qt.txt +``` + Всё локальное лежит рядом с проектом: - `.venv/` — зависимости - `.playwright/` — браузер Chromium @@ -63,6 +70,22 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu `SIGSTOP`/`SIGCONT`, а перемотка перезапускает поток с нужной секунды, поэтому отдельный демон не нужен. +## Qt-приложение + +```bash +.venv/bin/python mtsmusic.py qt +``` + +Отдельное окно в стиле телефона: большая обложка, кнопка play/pause, +переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает +боковую панель с «Моей волной», избранным, историей и поиском. + +Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает +перемотка и системный звук. Окно можно растягивать, списки прокручиваются, +двойной клик по строке запускает трек. Горячие клавиши те же, что в TUI: +`Space`, `←`/`→`, `N`/`P`, `L`, `T`, `M`, `F`, `H`, `S`, `W`; `Esc` закрывает +меню/экран, `Ctrl+Q` — выход. + ## Команды ```bash diff --git a/mtsmusic.py b/mtsmusic.py index 5005a98..d9a0605 100644 --- a/mtsmusic.py +++ b/mtsmusic.py @@ -830,6 +830,17 @@ def cmd_tui(args: argparse.Namespace) -> None: die(f"TUI упал: {exc}. Подробности: {log}") +def cmd_qt(args: argparse.Namespace) -> None: + try: + from qt_app import main as qt_main + except ImportError as exc: + die( + "для Qt-приложения нужен PySide6: " + f"{exc}. Установи: pip install -r requirements-qt.txt" + ) + raise SystemExit(qt_main()) + + def print_tui_check() -> None: import platform @@ -922,6 +933,9 @@ def build_parser() -> argparse.ArgumentParser: tui.add_argument("--no-mouse", action="store_true", help="отключить поддержку мыши") tui.set_defaults(func=cmd_tui) + qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)") + qt.set_defaults(func=cmd_qt) + url = sub.add_parser("url", help="получить прямую ссылку на MP3") url.add_argument("track_id") url.set_defaults(func=cmd_url) diff --git a/qt_app.py b/qt_app.py new file mode 100644 index 0000000..6d3bce5 --- /dev/null +++ b/qt_app.py @@ -0,0 +1,1394 @@ +#!/usr/bin/env python3 +"""Phone-style desktop player for MTS Music (PySide6).""" + +from __future__ import annotations + +import sys + +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 QAudioOutput, QMediaPlayer +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + 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, + fetch_mix_batch, + fmt_duration, + force_ipv4, + get_uid, + lrclib_lyrics, + lyrics_response, + lyrics_text, + resolve_stream, +) + +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); } +#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); } +#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.signals.failed.emit(self, str(exc) or "ошибка") + except Exception as exc: # noqa: BLE001 - сообщение показываем в UI + self.signals.failed.emit(self, str(exc) or exc.__class__.__name__) + else: + self.signals.done.emit(self, result) + + +class CoverView(QWidget): + """Обложка со скруглениями и аккуратным плейсхолдером.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._pixmap: QPixmap | None = None + self.setMinimumSize(220, 220) + self.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) + + def set_pixmap(self, pixmap: QPixmap | None) -> None: + self._pixmap = pixmap + 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(): + scaled = self._pixmap.scaled( + int(side), + int(side), + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + 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 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 QtMusicApp(QMainWindow): + def __init__(self, api: Api) -> None: + super().__init__() + self.api = api + 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.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(0.8) + 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._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._update_idle_time) + self._timer.start() + + 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) + + 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.clicked.connect(self.open_drawer) + 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(288, 288) + 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 = QSlider(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) + 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) + 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: + 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_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_lyrics), + ): + 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) + if self.toast_label.isVisible(): + self._place_toast() + + 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 _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]]: + api = self.api + uid = self.uid or get_uid(api) + batch = fetch_mix_batch(api) + wave = [ + item["track"] + for item in (batch.get("sequence") or []) + if item.get("type") == "track" and item.get("track") + ] + 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} + return uid, wave, liked + + def apply_initial(self, result: tuple[str, list[dict], set[str]]) -> None: + self.uid, self.wave, self.liked_ids = result + self.queue = list(self.wave) + self.queue_name = "Моя волна" + self.set_top_status(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}") + if self.wave: + self.current = self.wave[0] + self.current_index = 0 + self.update_now_playing() + self.load_cover(self.wave[0]) + else: + self.title_label.setText("Ничего не найдено") + + # -- текущий трек ----------------------------------------------------- + + 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() + 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: + 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: + playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState + self.play_btn.setIcon(media_icon("pause" if playing else "play", 80)) + + def _update_idle_time(self) -> None: + if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: + return + if self._seeking: + return + self.on_position(self.player.position()) + + # -- обложка ---------------------------------------------------------- + + 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: resolve_stream(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 + url, item = result + self._preview = bool(item.get("preview")) + self.player.setSource(QUrl(url)) + 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)) + + 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 slider_pressed(self) -> None: + self._seeking = True + + def slider_moved(self, value: int) -> None: + 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 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)) + + 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) + payload = self.api.get(f"/users/{uid}/likes/tracks") + refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or [] + ids = [str(ref.get("id")) for ref in refs[:300]] + tracks: list[dict] = [] + for start in range(0, len(ids), 50): + chunk = ids[start : start + 50] + if chunk: + tracks.extend( + self.api.get("/tracks", trackIds=",".join(chunk)).get("result") or [] + ) + return tracks + + 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.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 == 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(-5000) + elif key == Qt.Key.Key_Right: + self.seek(5000) + 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() + 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()) diff --git a/requirements-qt.txt b/requirements-qt.txt new file mode 100644 index 0000000..7aa974b --- /dev/null +++ b/requirements-qt.txt @@ -0,0 +1,2 @@ +-r requirements.txt +PySide6>=6.7