diff --git a/README.md b/README.md index 95898c5..1615a91 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu цветная обложка. Обложка по центру, строго под ней по той же ширине — большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже «Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое -меню: «Моя волна», избранное, история прослушивания, поиск, текст песни и -выход. +меню: «Моя волна», избранное, история прослушивания, поиск, текст песни, +«Настройки» и выход. Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны (клик по прогрессу перематывает трек). @@ -79,7 +79,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause, переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает -боковую панель с «Моей волной», избранным, историей и поиском. +боковую панель с «Моей волной», избранным, историей, поиском и настройками. Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает перемотка и системный звук. По полосе прогресса можно кликнуть в любое место — @@ -93,6 +93,31 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu «Моя волна» продолжается с последних прослушанных треков (берётся история), а не начинается каждый раз с одного и того же места. +## Настройки + +Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной +строки и хранятся в `.mts-music/settings.json`: + +```bash +# показать текущие настройки +.venv/bin/python mtsmusic.py settings + +# изменить: громкость 60%, шаг перемотки 10 секунд +.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10 +``` + +| Настройка | По умолчанию | Описание | +|-----------|--------------|----------| +| `volume` | `80` | громкость плеера, 0–100 | +| `seek_step` | `5` | шаг перемотки стрелками, секунды | +| `autoplay` | `false` | начинать воспроизведение сразу при запуске | +| `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` | +| `wave_from_history` | `true` | продолжать «Мою волну» с последних треков | + +В Qt-приложении на странице настроек дополнительно выбирается устройство вывода +звука. Громкость меняется на лету, остальные настройки применяются к новым +трекам и при следующем запуске. + ## Команды ```bash @@ -129,6 +154,10 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu # скачивание .venv/bin/python mtsmusic.py download 12345678 -o track.mp3 + +# настройки +.venv/bin/python mtsmusic.py settings +.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10 ``` Токен можно передать и через переменную окружения: diff --git a/mtsmusic.py b/mtsmusic.py index d9a0605..603b8ff 100644 --- a/mtsmusic.py +++ b/mtsmusic.py @@ -411,6 +411,21 @@ def fetch_mix_batch(api: Api, queue: list[str] | None = None) -> dict: return api.get(f"/rotor/station/{WAVE_STATION}/tracks", **params).get("result") or {} +def fetch_liked_tracks(api: Api, uid: str, limit: int = 300) -> list[dict]: + """Избранные треки с деталями: /tracks принимает не больше 50 id за раз.""" + payload = 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[:limit]] + tracks: list[dict] = [] + for start in range(0, len(ids), 50): + chunk = ids[start : start + 50] + if chunk: + tracks.extend( + api.get("/tracks", trackIds=",".join(chunk)).get("result") or [] + ) + return tracks + + def print_mix_item(index: int, item: dict) -> None: track = item.get("track") or {} heart = "♥" if item.get("liked") else " " @@ -841,6 +856,33 @@ def cmd_qt(args: argparse.Namespace) -> None: raise SystemExit(qt_main()) +def cmd_settings(args: argparse.Namespace) -> None: + # Импорт внутри функции: settings.py сам зависит от mtsmusic.CONFIG_DIR. + from settings import DEFAULTS, load_settings, settings_path, save_settings + + saved = False + if args.set: + updates: dict = {} + for pair in args.set: + if "=" not in pair: + die(f"неверный формат: {pair} (нужно ключ=значение)") + key, value = pair.split("=", 1) + key = key.strip() + if key not in DEFAULTS: + die(f"неизвестная настройка: {key}. Доступны: {', '.join(DEFAULTS)}") + updates[key] = value + current = save_settings(updates) + saved = True + else: + current = load_settings() + + for key, default in DEFAULTS.items(): + print(f"{key:<18} = {current.get(key, default)}") + print(f"\nФайл: {settings_path()}") + if saved: + print("Сохранено.") + + def print_tui_check() -> None: import platform @@ -936,6 +978,16 @@ def build_parser() -> argparse.ArgumentParser: qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)") qt.set_defaults(func=cmd_qt) + settings = sub.add_parser("settings", help="показать или изменить настройки") + settings.add_argument( + "-s", + "--set", + nargs="+", + metavar="KEY=VALUE", + help="изменить настройки, например: -s volume=60 seek_step=10", + ) + settings.set_defaults(func=cmd_settings) + url = sub.add_parser("url", help="получить прямую ссылку на MP3") url.add_argument("track_id") url.set_defaults(func=cmd_url) diff --git a/player.py b/player.py index b8956d0..18838e5 100644 --- a/player.py +++ b/player.py @@ -16,7 +16,7 @@ from pathlib import Path class MusicPlayer: - def __init__(self, explicit: str | None = None) -> None: + def __init__(self, explicit: str | None = None, volume: int = 80) -> None: self.path = self._pick(explicit) self.proc: asyncio.subprocess.Process | None = None self.url: str | None = None @@ -26,6 +26,7 @@ class MusicPlayer: self.paused_total = 0.0 self.paused_at = 0.0 self.switching = False + self.volume = max(0, min(100, int(volume))) @staticmethod def _pick(explicit: str | None) -> str | None: @@ -47,17 +48,34 @@ class MusicPlayer: self.path or "ffplay", "-nodisp", "-autoexit", + "-volume", + str(self.volume), "-loglevel", "error", ] if start > 0: command += ["-ss", f"{start:.3f}"] return command + [url] - command = [self.path or "mpv", "--no-video", "--really-quiet"] + command = [ + self.path or "mpv", + "--no-video", + "--really-quiet", + f"--volume={self.volume}", + ] if start > 0: command.append(f"--start={start:.3f}") return command + [url] + async def set_volume(self, volume: int) -> None: + """Меняет громкость; ffplay/mpv не умеют на лету, поэтому перезапускаем.""" + self.volume = max(0, min(100, int(volume))) + if self.url and self.is_playing(): + position = self.elapsed() + was_paused = self.paused + await self.play(self.url, position) + if was_paused: + self.pause() + async def play(self, url: str, start: float = 0.0) -> None: await self.stop() if not self.path: diff --git a/qt_app.py b/qt_app.py index 057b372..ebd35a4 100644 --- a/qt_app.py +++ b/qt_app.py @@ -31,12 +31,19 @@ from PySide6.QtGui import ( QPen, QPixmap, ) -from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer +from PySide6.QtMultimedia import ( + QAudioDevice, + QAudioOutput, + QMediaDevices, + QMediaPlayer, +) from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QAbstractButton, + QCheckBox, + QComboBox, QFrame, QHBoxLayout, QLabel, @@ -67,6 +74,7 @@ from media import ( from mtsmusic import ( USER_AGENT, Api, + fetch_liked_tracks, fetch_mix_batch, fmt_duration, force_ipv4, @@ -76,6 +84,12 @@ from mtsmusic import ( lyrics_text, resolve_stream, ) +from settings import ( + START_QUEUE_LABELS, + START_QUEUES, + load_settings, + update_settings, +) APP_QSS = """ QWidget { @@ -140,6 +154,23 @@ QLineEdit { 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; @@ -245,6 +276,8 @@ 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 @@ -252,6 +285,8 @@ class CoverView(QWidget): 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 @@ -267,12 +302,15 @@ class CoverView(QWidget): 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, - ) + 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) @@ -405,6 +443,7 @@ 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] = [] @@ -436,7 +475,7 @@ class QtMusicApp(QMainWindow): self.player = QMediaPlayer(self) self.audio = QAudioOutput(self) - self.audio.setVolume(0.8) + 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) @@ -450,11 +489,6 @@ class QtMusicApp(QMainWindow): self._update_cover_size() self.play_btn.setFocus() - 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) # -- построение интерфейса ------------------------------------------- @@ -523,6 +557,9 @@ class QtMusicApp(QMainWindow): ) self.pages.addWidget(self.lyrics_page) + self.settings_page = self._build_settings_page() + self.pages.addWidget(self.settings_page) + self._setup_tab_order() def _setup_tab_order(self) -> None: @@ -759,6 +796,124 @@ class QtMusicApp(QMainWindow): 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 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) + + 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) @@ -784,6 +939,7 @@ class QtMusicApp(QMainWindow): ("История", self.show_history), ("Поиск", self.show_search), ("Текст песни", self.show_lyrics), + ("Настройки", self.show_settings), ): button = QPushButton(text) button.setCursor(Qt.CursorShape.PointingHandCursor) @@ -923,34 +1079,51 @@ class QtMusicApp(QMainWindow): 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]]: + def _fetch_initial(self) -> tuple[str, list[dict], set[str], list[dict], str]: api = self.api uid = self.uid or get_uid(api) # Продолжаем волну с последних прослушанных треков, а не с начала. - seed = [str(track.get("id")) for track in self.history[:5]] - batch = fetch_mix_batch(api, seed or None) + 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") ] - 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]) + 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) # -- текущий трек ----------------------------------------------------- @@ -982,13 +1155,6 @@ class QtMusicApp(QMainWindow): 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: @@ -1347,17 +1513,7 @@ class QtMusicApp(QMainWindow): 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 + return fetch_liked_tracks(self.api, uid) def apply_favorites(self, tracks: list[dict]) -> None: self._favorites_loading = False @@ -1447,9 +1603,9 @@ class QtMusicApp(QMainWindow): if key == Qt.Key.Key_Space: self.toggle_play() elif key == Qt.Key.Key_Left: - self.seek(-5000) + self.seek(-int(self.settings.get("seek_step", 5)) * 1000) elif key == Qt.Key.Key_Right: - self.seek(5000) + 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: diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..2ac4d35 --- /dev/null +++ b/settings.py @@ -0,0 +1,104 @@ +"""Настройки MTS Music: хранятся в .mts-music/settings.json.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from mtsmusic import CONFIG_DIR + +SETTINGS_PATH = CONFIG_DIR / "settings.json" + +DEFAULTS: dict[str, Any] = { + "volume": 80, # громкость 0..100 + "seek_step": 5, # шаг перемотки стрелками, секунды + "autoplay": False, # запускать воспроизведение сразу + "start_queue": "wave", # wave | favorites | history + "wave_from_history": True, # продолжать волну с последних треков +} + +START_QUEUES = ("wave", "favorites", "history") +START_QUEUE_LABELS = { + "wave": "Моя волна", + "favorites": "Избранное", + "history": "История", +} + +_cache: dict[str, Any] | None = None + + +def _coerce(key: str, value: Any) -> Any: + if key == "volume": + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return DEFAULTS[key] + if key == "seek_step": + try: + return max(1, min(60, int(value))) + except (TypeError, ValueError): + return DEFAULTS[key] + if key in ("autoplay", "wave_from_history"): + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("1", "true", "yes", "on", "да", "вкл") + if key == "start_queue": + text = str(value) + return text if text in START_QUEUES else DEFAULTS[key] + return value + + +def load_settings() -> dict[str, Any]: + """Возвращает настройки с дефолтами; файл читается один раз.""" + global _cache + if _cache is not None: + return dict(_cache) + raw: dict[str, Any] = {} + try: + data = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(data, dict): + raw = data + except (OSError, ValueError): + raw = {} + merged = dict(DEFAULTS) + for key, value in raw.items(): + if key in DEFAULTS: + merged[key] = _coerce(key, value) + _cache = merged + return dict(merged) + + +def save_settings(values: dict[str, Any]) -> dict[str, Any]: + """Сохраняет изменённые настройки и возвращает актуальный набор.""" + global _cache + merged = load_settings() + for key, value in values.items(): + if key in DEFAULTS: + merged[key] = _coerce(key, value) + _cache = merged + try: + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + SETTINGS_PATH.write_text( + json.dumps(merged, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError: + pass + return dict(merged) + + +def get_setting(key: str, default: Any = None) -> Any: + return load_settings().get(key, DEFAULTS.get(key, default)) + + +def set_setting(key: str, value: Any) -> Any: + return save_settings({key: value}).get(key) + + +def update_settings(**values: Any) -> dict[str, Any]: + return save_settings(dict(values)) + + +def settings_path() -> Path: + return SETTINGS_PATH diff --git a/tui.py b/tui.py index c90dd89..0b1b799 100644 --- a/tui.py +++ b/tui.py @@ -43,6 +43,12 @@ from mtsmusic import ( resolve_stream, ) from player import MusicPlayer +from settings import ( + START_QUEUE_LABELS, + START_QUEUES, + load_settings, + update_settings, +) COVER_MAX = 46 COVER_MIN = 20 @@ -177,17 +183,9 @@ class FavoritesScreen(Screen): return try: uid = self.app.uid or get_uid(api) - payload = 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 not chunk: - continue - tracks.extend( - api.get("/tracks", trackIds=",".join(chunk)).get("result") or [] - ) + from mtsmusic import fetch_liked_tracks + + tracks = fetch_liked_tracks(api, uid) self.app.call_from_thread(self.fill, tracks) except SystemExit as exc: self.app.call_from_thread(self.failed, str(exc)) @@ -361,6 +359,78 @@ class LyricsScreen(Screen): body.update(Text(text)) +class SettingsScreen(Screen): + BINDINGS = [Binding("escape", "back", "Назад")] + + def compose(self) -> ComposeResult: + yield make_topbar("Настройки") + with VerticalScroll(id="settings-scroll"): + with Horizontal(classes="srow"): + yield Static("Громкость", classes="slabel") + yield Button("−", id="vol-down", classes="sstep") + yield Static("", id="vol-value", classes="svalue") + yield Button("+", id="vol-up", classes="sstep") + with Horizontal(classes="srow"): + yield Static("Шаг перемотки", classes="slabel") + yield Button("−", id="seek-down", classes="sstep") + yield Static("", id="seek-value", classes="svalue") + yield Button("+", id="seek-up", classes="sstep") + with Horizontal(classes="srow"): + yield Static("Автовоспроизведение", classes="slabel") + yield Button("", id="autoplay-toggle", classes="stoggle") + with Horizontal(classes="srow"): + yield Static("Стартовый список", classes="slabel") + yield Button("", id="start-toggle", classes="stoggle") + with Horizontal(classes="srow"): + yield Static("Волна с последних треков", classes="slabel") + yield Button("", id="wave-toggle", classes="stoggle") + yield Static("", id="settings-path") + + def on_mount(self) -> None: + self.refresh_values() + from settings import settings_path + + self.query_one("#settings-path", Static).update(f"Файл: {settings_path()}") + + def refresh_values(self) -> None: + s = self.app.settings + self.query_one("#vol-value", Static).update(f"{s['volume']} %") + self.query_one("#seek-value", Static).update(f"{s['seek_step']} с") + self.query_one("#autoplay-toggle", Button).label = "вкл" if s["autoplay"] else "выкл" + self.query_one("#start-toggle", Button).label = START_QUEUE_LABELS[s["start_queue"]] + self.query_one("#wave-toggle", Button).label = ( + "вкл" if s["wave_from_history"] else "выкл" + ) + + def action_back(self) -> None: + self.app.pop_screen() + + @on(Button.Pressed) + def pressed(self, event: Button.Pressed) -> None: + bid = event.button.id or "" + s = self.app.settings + if bid == "back": + self.action_back() + return + if bid == "vol-down": + self.app.update_setting("volume", max(0, s["volume"] - 5)) + elif bid == "vol-up": + self.app.update_setting("volume", min(100, s["volume"] + 5)) + elif bid == "seek-down": + self.app.update_setting("seek_step", max(1, s["seek_step"] - 1)) + elif bid == "seek-up": + self.app.update_setting("seek_step", min(60, s["seek_step"] + 1)) + elif bid == "autoplay-toggle": + self.app.update_setting("autoplay", not s["autoplay"]) + elif bid == "start-toggle": + order = list(START_QUEUES) + index = order.index(s["start_queue"]) + self.app.update_setting("start_queue", order[(index + 1) % len(order)]) + elif bid == "wave-toggle": + self.app.update_setting("wave_from_history", not s["wave_from_history"]) + self.refresh_values() + + class MenuScreen(ModalScreen[str | None]): BINDINGS = [Binding("escape", "close", "Закрыть")] @@ -372,6 +442,7 @@ class MenuScreen(ModalScreen[str | None]): yield Button("История", id="m-history") yield Button("Поиск", id="m-search") yield Button("Текст песни", id="m-lyrics") + yield Button("Настройки", id="m-settings") yield Static("", id="menu-spacer") yield Button("Выход", id="m-quit", variant="error") @@ -468,6 +539,14 @@ class MusicTui(App): #menu-title { text-style: bold; padding: 0 0 1 0; color: #ffffff; } #menu-panel Button { width: 100%; margin: 1 0; } #menu-spacer { height: 1fr; } + + #settings-scroll { height: 1fr; padding: 1 2; } + .srow { height: 3; align: left middle; } + .slabel { width: 1fr; content-align: left middle; color: #d0d0d0; } + .svalue { width: 8; content-align: center middle; color: #ffffff; } + .sstep { width: 5; min-width: 5; } + .stoggle { width: 16; min-width: 12; } + #settings-path { padding: 1 0; color: #6a6a6a; } """ BINDINGS = [ @@ -489,9 +568,10 @@ class MusicTui(App): def __init__(self, player: str | None = None) -> None: super().__init__() self.explicit_player = player + self.settings = load_settings() self.api: Api | None = None self.uid = "" - self.player = MusicPlayer(player) + self.player = MusicPlayer(player, volume=int(self.settings.get("volume", 80))) self.wave: list[dict] = [] self.favorites: list[dict] = [] self.liked_ids: set[str] = set() @@ -503,6 +583,7 @@ class MusicTui(App): self.play_token = 0 self.cover_token = 0 self.cover_bytes: dict[str, bytes] = {} + self.cover_rendered: dict[tuple[str, int], Text] = {} self.cover_width = 40 # -- разметка --------------------------------------------------------- @@ -596,36 +677,59 @@ class MusicTui(App): return try: self.uid = get_uid(api) - from mtsmusic import fetch_mix_batch + from mtsmusic import fetch_liked_tracks, fetch_mix_batch # Продолжаем волну с последних прослушанных треков, а не с начала. - seed = [str(track.get("id")) for track in self.history[:5]] - batch = fetch_mix_batch(api, seed or None) + 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") ] - payload = api.get(f"/users/{self.uid}/likes/tracks") - refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or [] - liked = {str(ref.get("id")) for ref in refs} - self.call_from_thread(self.apply_initial, wave, liked) + want = self.settings.get("start_queue", "wave") + start: list[dict] = wave + name = "Моя волна" + if want == "favorites": + start = fetch_liked_tracks(api, self.uid) + name = "Избранное" + liked = {str(track.get("id")) for track in start} + else: + payload = api.get(f"/users/{self.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), "История" + self.call_from_thread(self.apply_initial, wave, liked, start, name) except SystemExit as exc: self.call_from_thread(self.notify, f"Ошибка загрузки: {exc}", severity="error") except requests.RequestException as exc: self.call_from_thread(self.notify, f"Сеть недоступна: {exc}", severity="error") - def apply_initial(self, wave: list[dict], liked: set[str]) -> None: + def apply_initial( + self, + wave: list[dict], + liked: set[str], + start: list[dict], + name: str, + ) -> None: self.wave = wave self.liked_ids = liked - self.set_topstatus(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}") - if self.wave: - self.current = self.wave[0] - self.current_index = 0 - self.queue = self.wave - self.queue_name = "Моя волна" - self.update_now_playing() - self.load_cover(self.wave[0]) + if not start: + self.set_topstatus("Ничего не найдено") + return + self.queue = list(start) + self.queue_name = name + self.current = start[0] + self.current_index = 0 + self.set_topstatus(f"{name} · {len(start)} · ♥ {len(self.liked_ids)}") + self.update_now_playing() + self.load_cover(start[0]) + if self.settings.get("autoplay"): + self.play_track(start[0], self.queue, 0) def set_topstatus(self, text: str) -> None: self.query_one("#topstatus", Static).update(text) @@ -677,10 +781,18 @@ class MusicTui(App): if not data: self.show_placeholder_cover() return - try: - self.query_one("#cover", Static).update(cover_renderable(data, self.cover_width)) - except (OSError, ValueError): - self.show_placeholder_cover() + key = (url, self.cover_width) + rendered = self.cover_rendered.get(key) + if rendered is None: + try: + rendered = cover_renderable(data, self.cover_width) + except (OSError, ValueError): + self.show_placeholder_cover() + return + if len(self.cover_rendered) >= COVER_CACHE * 2: + self.cover_rendered.pop(next(iter(self.cover_rendered))) + self.cover_rendered[key] = rendered + self.query_one("#cover", Static).update(rendered) def load_cover(self, track: dict) -> None: self.cover_token += 1 @@ -818,10 +930,10 @@ class MusicTui(App): self.try_next(-1) def action_seek_back(self) -> None: - self.seek(-5) + self.seek(-self.settings["seek_step"]) def action_seek_forward(self) -> None: - self.seek(5) + self.seek(self.settings["seek_step"]) def seek(self, delta: float) -> None: if not self.current or not self.player.is_playing(): @@ -942,9 +1054,22 @@ class MusicTui(App): self.push_screen(SearchScreen()) elif action == "m-lyrics": self.action_show_lyrics() + elif action == "m-settings": + self.push_screen(SettingsScreen()) elif action == "m-quit": self.exit() + # -- настройки -------------------------------------------------------- + + def update_setting(self, key: str, value) -> None: + self.settings = update_settings(**{key: value}) + self.apply_settings() + + def apply_settings(self) -> None: + volume = int(self.settings.get("volume", 80)) + if self.player.volume != volume: + self.run_worker(self.player.set_volume(volume), group="volume", exclusive=True) + def action_show_favorites(self) -> None: self.push_screen(FavoritesScreen())