feat: add settings (volume, seek step, start queue) with TUI/Qt pages

- settings.py persists to .mts-music/settings.json; CLI 'settings' command
- player gains volume support (ffplay -volume / mpv --volume)
- TUI settings screen and Qt settings page (volume, output device, seek step,
  autoplay, start queue, wave-from-history)
- start queue + autoplay applied at startup; arrows use configured seek step
- shared fetch_liked_tracks helper; cache TUI cover renders and Qt scaled cover
This commit is contained in:
jads
2026-10-23 22:44:55 +03:00
parent b64c2dcd12
commit 637ac23b73
6 changed files with 574 additions and 90 deletions

View File

@@ -46,8 +46,8 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
цветная обложка. Обложка по центру, строго под ней по той же ширине — цветная обложка. Обложка по центру, строго под ней по той же ширине —
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое «Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни и меню: «Моя волна», избранное, история прослушивания, поиск, текст песни,
выход. «Настройки» и выход.
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
(клик по прогрессу перематывает трек). (клик по прогрессу перематывает трек).
@@ -79,7 +79,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause, Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause,
переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает
боковую панель с «Моей волной», избранным, историей и поиском. боковую панель с «Моей волной», избранным, историей, поиском и настройками.
Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает Воспроизведение — через `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 ```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 download 12345678 -o track.mp3
# настройки
.venv/bin/python mtsmusic.py settings
.venv/bin/python mtsmusic.py settings -s volume=60 seek_step=10
``` ```
Токен можно передать и через переменную окружения: Токен можно передать и через переменную окружения:

View File

@@ -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 {} 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: def print_mix_item(index: int, item: dict) -> None:
track = item.get("track") or {} track = item.get("track") or {}
heart = "♥" if item.get("liked") else " " heart = "♥" if item.get("liked") else " "
@@ -841,6 +856,33 @@ def cmd_qt(args: argparse.Namespace) -> None:
raise SystemExit(qt_main()) 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: def print_tui_check() -> None:
import platform import platform
@@ -936,6 +978,16 @@ def build_parser() -> argparse.ArgumentParser:
qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)") qt = sub.add_parser("qt", help="настольное Qt-приложение (PySide6)")
qt.set_defaults(func=cmd_qt) 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 = sub.add_parser("url", help="получить прямую ссылку на MP3")
url.add_argument("track_id") url.add_argument("track_id")
url.set_defaults(func=cmd_url) url.set_defaults(func=cmd_url)

View File

@@ -16,7 +16,7 @@ from pathlib import Path
class MusicPlayer: 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.path = self._pick(explicit)
self.proc: asyncio.subprocess.Process | None = None self.proc: asyncio.subprocess.Process | None = None
self.url: str | None = None self.url: str | None = None
@@ -26,6 +26,7 @@ class MusicPlayer:
self.paused_total = 0.0 self.paused_total = 0.0
self.paused_at = 0.0 self.paused_at = 0.0
self.switching = False self.switching = False
self.volume = max(0, min(100, int(volume)))
@staticmethod @staticmethod
def _pick(explicit: str | None) -> str | None: def _pick(explicit: str | None) -> str | None:
@@ -47,17 +48,34 @@ class MusicPlayer:
self.path or "ffplay", self.path or "ffplay",
"-nodisp", "-nodisp",
"-autoexit", "-autoexit",
"-volume",
str(self.volume),
"-loglevel", "-loglevel",
"error", "error",
] ]
if start > 0: if start > 0:
command += ["-ss", f"{start:.3f}"] command += ["-ss", f"{start:.3f}"]
return command + [url] 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: if start > 0:
command.append(f"--start={start:.3f}") command.append(f"--start={start:.3f}")
return command + [url] 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: async def play(self, url: str, start: float = 0.0) -> None:
await self.stop() await self.stop()
if not self.path: if not self.path:

258
qt_app.py
View File

@@ -31,12 +31,19 @@ from PySide6.QtGui import (
QPen, QPen,
QPixmap, QPixmap,
) )
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer from PySide6.QtMultimedia import (
QAudioDevice,
QAudioOutput,
QMediaDevices,
QMediaPlayer,
)
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
QAbstractButton, QAbstractButton,
QCheckBox,
QComboBox,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -67,6 +74,7 @@ from media import (
from mtsmusic import ( from mtsmusic import (
USER_AGENT, USER_AGENT,
Api, Api,
fetch_liked_tracks,
fetch_mix_batch, fetch_mix_batch,
fmt_duration, fmt_duration,
force_ipv4, force_ipv4,
@@ -76,6 +84,12 @@ from mtsmusic import (
lyrics_text, lyrics_text,
resolve_stream, resolve_stream,
) )
from settings import (
START_QUEUE_LABELS,
START_QUEUES,
load_settings,
update_settings,
)
APP_QSS = """ APP_QSS = """
QWidget { QWidget {
@@ -140,6 +154,23 @@ QLineEdit {
padding: 9px 12px; font-size: 14px; selection-background-color: #ff3b5c; padding: 9px 12px; font-size: 14px; selection-background-color: #ff3b5c;
} }
QLineEdit:focus { border-color: rgba(255,59,92,0.53); } 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 { #searchBtn {
background: #ff3b5c; border: none; border-radius: 13px; background: #ff3b5c; border: none; border-radius: 13px;
padding: 9px 16px; font-weight: 600; padding: 9px 16px; font-weight: 600;
@@ -245,6 +276,8 @@ class CoverView(QWidget):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._pixmap: QPixmap | None = None self._pixmap: QPixmap | None = None
self._scaled: QPixmap | None = None
self._scaled_side = 0
self.setMinimumSize(220, 220) self.setMinimumSize(220, 220)
self.setSizePolicy( self.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
@@ -252,6 +285,8 @@ class CoverView(QWidget):
def set_pixmap(self, pixmap: QPixmap | None) -> None: def set_pixmap(self, pixmap: QPixmap | None) -> None:
self._pixmap = pixmap self._pixmap = pixmap
self._scaled = None
self._scaled_side = 0
self.update() self.update()
def paintEvent(self, event) -> None: # noqa: N802 - Qt API def paintEvent(self, event) -> None: # noqa: N802 - Qt API
@@ -267,12 +302,15 @@ class CoverView(QWidget):
painter.setClipPath(clip) painter.setClipPath(clip)
if self._pixmap and not self._pixmap.isNull(): if self._pixmap and not self._pixmap.isNull():
scaled = self._pixmap.scaled( if self._scaled is None or self._scaled_side != int(side):
int(side), self._scaled = self._pixmap.scaled(
int(side), int(side),
Qt.AspectRatioMode.KeepAspectRatioByExpanding, int(side),
Qt.TransformationMode.SmoothTransformation, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
) Qt.TransformationMode.SmoothTransformation,
)
self._scaled_side = int(side)
scaled = self._scaled
dx = x - (scaled.width() - side) / 2 dx = x - (scaled.width() - side) / 2
dy = y - (scaled.height() - side) / 2 dy = y - (scaled.height() - side) / 2
painter.drawPixmap(int(dx), int(dy), scaled) painter.drawPixmap(int(dx), int(dy), scaled)
@@ -405,6 +443,7 @@ class QtMusicApp(QMainWindow):
def __init__(self, api: Api) -> None: def __init__(self, api: Api) -> None:
super().__init__() super().__init__()
self.api = api self.api = api
self.settings = load_settings()
self.uid = "" self.uid = ""
self.wave: list[dict] = [] self.wave: list[dict] = []
self.queue: list[dict] = [] self.queue: list[dict] = []
@@ -436,7 +475,7 @@ class QtMusicApp(QMainWindow):
self.player = QMediaPlayer(self) self.player = QMediaPlayer(self)
self.audio = QAudioOutput(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.setAudioOutput(self.audio)
self.player.positionChanged.connect(self.on_position) self.player.positionChanged.connect(self.on_position)
self.player.durationChanged.connect(self.on_player_duration) self.player.durationChanged.connect(self.on_player_duration)
@@ -450,11 +489,6 @@ class QtMusicApp(QMainWindow):
self._update_cover_size() self._update_cover_size()
self.play_btn.setFocus() 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) QTimer.singleShot(0, self.load_initial)
# -- построение интерфейса ------------------------------------------- # -- построение интерфейса -------------------------------------------
@@ -523,6 +557,9 @@ class QtMusicApp(QMainWindow):
) )
self.pages.addWidget(self.lyrics_page) self.pages.addWidget(self.lyrics_page)
self.settings_page = self._build_settings_page()
self.pages.addWidget(self.settings_page)
self._setup_tab_order() self._setup_tab_order()
def _setup_tab_order(self) -> None: def _setup_tab_order(self) -> None:
@@ -759,6 +796,124 @@ class QtMusicApp(QMainWindow):
layout.addWidget(body, 1) layout.addWidget(body, 1)
return page, title, status, body 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: def _build_drawer(self) -> None:
central = self.centralWidget() central = self.centralWidget()
self.scrim = Scrim(central) self.scrim = Scrim(central)
@@ -784,6 +939,7 @@ class QtMusicApp(QMainWindow):
("История", self.show_history), ("История", self.show_history),
("Поиск", self.show_search), ("Поиск", self.show_search),
("Текст песни", self.show_lyrics), ("Текст песни", self.show_lyrics),
("Настройки", self.show_settings),
): ):
button = QPushButton(text) button = QPushButton(text)
button.setCursor(Qt.CursorShape.PointingHandCursor) button.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -923,34 +1079,51 @@ class QtMusicApp(QMainWindow):
self.set_top_status("Загрузка…") self.set_top_status("Загрузка…")
self.run_task(self._fetch_initial, self.apply_initial, self.on_error) 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 api = self.api
uid = self.uid or get_uid(api) uid = self.uid or get_uid(api)
# Продолжаем волну с последних прослушанных треков, а не с начала. # Продолжаем волну с последних прослушанных треков, а не с начала.
seed = [str(track.get("id")) for track in self.history[:5]] if self.settings.get("wave_from_history", True):
batch = fetch_mix_batch(api, seed or None) seed = [str(track.get("id")) for track in self.history[:5]]
else:
seed = None
batch = fetch_mix_batch(api, seed)
wave = [ wave = [
item["track"] item["track"]
for item in (batch.get("sequence") or []) for item in (batch.get("sequence") or [])
if item.get("type") == "track" and item.get("track") if item.get("type") == "track" and item.get("track")
] ]
payload = api.get(f"/users/{uid}/likes/tracks") want = self.settings.get("start_queue", "wave")
refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or [] start: list[dict] = wave
liked = {str(ref.get("id")) for ref in refs} name = "Моя волна"
return uid, wave, liked if want == "favorites":
start = fetch_liked_tracks(api, uid)
def apply_initial(self, result: tuple[str, list[dict], set[str]]) -> None: name = "Избранное"
self.uid, self.wave, self.liked_ids = result liked = {str(track.get("id")) for track in start}
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: 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("Ничего не найдено") 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 playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
self.play_btn.setIcon(media_icon("pause" if playing else "play", 80)) 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: def load_cover(self, track: dict) -> None:
@@ -1347,17 +1513,7 @@ class QtMusicApp(QMainWindow):
def _fetch_favorites(self) -> list[dict]: def _fetch_favorites(self) -> list[dict]:
uid = self.uid or get_uid(self.api) uid = self.uid or get_uid(self.api)
payload = self.api.get(f"/users/{uid}/likes/tracks") return fetch_liked_tracks(self.api, uid)
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: def apply_favorites(self, tracks: list[dict]) -> None:
self._favorites_loading = False self._favorites_loading = False
@@ -1447,9 +1603,9 @@ class QtMusicApp(QMainWindow):
if key == Qt.Key.Key_Space: if key == Qt.Key.Key_Space:
self.toggle_play() self.toggle_play()
elif key == Qt.Key.Key_Left: 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: 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: elif key == Qt.Key.Key_N:
self.advance(1) self.advance(1)
elif key == Qt.Key.Key_P: elif key == Qt.Key.Key_P:

104
settings.py Normal file
View File

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

193
tui.py
View File

@@ -43,6 +43,12 @@ from mtsmusic import (
resolve_stream, resolve_stream,
) )
from player import MusicPlayer from player import MusicPlayer
from settings import (
START_QUEUE_LABELS,
START_QUEUES,
load_settings,
update_settings,
)
COVER_MAX = 46 COVER_MAX = 46
COVER_MIN = 20 COVER_MIN = 20
@@ -177,17 +183,9 @@ class FavoritesScreen(Screen):
return return
try: try:
uid = self.app.uid or get_uid(api) uid = self.app.uid or get_uid(api)
payload = api.get(f"/users/{uid}/likes/tracks") from mtsmusic import fetch_liked_tracks
refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or []
ids = [str(ref.get("id")) for ref in refs[:300]] tracks = fetch_liked_tracks(api, uid)
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 []
)
self.app.call_from_thread(self.fill, tracks) self.app.call_from_thread(self.fill, tracks)
except SystemExit as exc: except SystemExit as exc:
self.app.call_from_thread(self.failed, str(exc)) self.app.call_from_thread(self.failed, str(exc))
@@ -361,6 +359,78 @@ class LyricsScreen(Screen):
body.update(Text(text)) 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]): class MenuScreen(ModalScreen[str | None]):
BINDINGS = [Binding("escape", "close", "Закрыть")] BINDINGS = [Binding("escape", "close", "Закрыть")]
@@ -372,6 +442,7 @@ class MenuScreen(ModalScreen[str | None]):
yield Button("История", id="m-history") yield Button("История", id="m-history")
yield Button("Поиск", id="m-search") yield Button("Поиск", id="m-search")
yield Button("Текст песни", id="m-lyrics") yield Button("Текст песни", id="m-lyrics")
yield Button("Настройки", id="m-settings")
yield Static("", id="menu-spacer") yield Static("", id="menu-spacer")
yield Button("Выход", id="m-quit", variant="error") 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-title { text-style: bold; padding: 0 0 1 0; color: #ffffff; }
#menu-panel Button { width: 100%; margin: 1 0; } #menu-panel Button { width: 100%; margin: 1 0; }
#menu-spacer { height: 1fr; } #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 = [ BINDINGS = [
@@ -489,9 +568,10 @@ class MusicTui(App):
def __init__(self, player: str | None = None) -> None: def __init__(self, player: str | None = None) -> None:
super().__init__() super().__init__()
self.explicit_player = player self.explicit_player = player
self.settings = load_settings()
self.api: Api | None = None self.api: Api | None = None
self.uid = "" self.uid = ""
self.player = MusicPlayer(player) self.player = MusicPlayer(player, volume=int(self.settings.get("volume", 80)))
self.wave: list[dict] = [] self.wave: list[dict] = []
self.favorites: list[dict] = [] self.favorites: list[dict] = []
self.liked_ids: set[str] = set() self.liked_ids: set[str] = set()
@@ -503,6 +583,7 @@ class MusicTui(App):
self.play_token = 0 self.play_token = 0
self.cover_token = 0 self.cover_token = 0
self.cover_bytes: dict[str, bytes] = {} self.cover_bytes: dict[str, bytes] = {}
self.cover_rendered: dict[tuple[str, int], Text] = {}
self.cover_width = 40 self.cover_width = 40
# -- разметка --------------------------------------------------------- # -- разметка ---------------------------------------------------------
@@ -596,36 +677,59 @@ class MusicTui(App):
return return
try: try:
self.uid = get_uid(api) 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]] if self.settings.get("wave_from_history", True):
batch = fetch_mix_batch(api, seed or None) seed = [str(track.get("id")) for track in self.history[:5]]
else:
seed = None
batch = fetch_mix_batch(api, seed)
wave = [ wave = [
item["track"] item["track"]
for item in (batch.get("sequence") or []) for item in (batch.get("sequence") or [])
if item.get("type") == "track" and item.get("track") if item.get("type") == "track" and item.get("track")
] ]
payload = api.get(f"/users/{self.uid}/likes/tracks") want = self.settings.get("start_queue", "wave")
refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or [] start: list[dict] = wave
liked = {str(ref.get("id")) for ref in refs} name = "Моя волна"
self.call_from_thread(self.apply_initial, wave, liked) 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: except SystemExit as exc:
self.call_from_thread(self.notify, f"Ошибка загрузки: {exc}", severity="error") self.call_from_thread(self.notify, f"Ошибка загрузки: {exc}", severity="error")
except requests.RequestException as exc: except requests.RequestException as exc:
self.call_from_thread(self.notify, f"Сеть недоступна: {exc}", severity="error") 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.wave = wave
self.liked_ids = liked self.liked_ids = liked
self.set_topstatus(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}") if not start:
if self.wave: self.set_topstatus("Ничего не найдено")
self.current = self.wave[0] return
self.current_index = 0 self.queue = list(start)
self.queue = self.wave self.queue_name = name
self.queue_name = "Моя волна" self.current = start[0]
self.update_now_playing() self.current_index = 0
self.load_cover(self.wave[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: def set_topstatus(self, text: str) -> None:
self.query_one("#topstatus", Static).update(text) self.query_one("#topstatus", Static).update(text)
@@ -677,10 +781,18 @@ class MusicTui(App):
if not data: if not data:
self.show_placeholder_cover() self.show_placeholder_cover()
return return
try: key = (url, self.cover_width)
self.query_one("#cover", Static).update(cover_renderable(data, self.cover_width)) rendered = self.cover_rendered.get(key)
except (OSError, ValueError): if rendered is None:
self.show_placeholder_cover() 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: def load_cover(self, track: dict) -> None:
self.cover_token += 1 self.cover_token += 1
@@ -818,10 +930,10 @@ class MusicTui(App):
self.try_next(-1) self.try_next(-1)
def action_seek_back(self) -> None: def action_seek_back(self) -> None:
self.seek(-5) self.seek(-self.settings["seek_step"])
def action_seek_forward(self) -> None: def action_seek_forward(self) -> None:
self.seek(5) self.seek(self.settings["seek_step"])
def seek(self, delta: float) -> None: def seek(self, delta: float) -> None:
if not self.current or not self.player.is_playing(): if not self.current or not self.player.is_playing():
@@ -942,9 +1054,22 @@ class MusicTui(App):
self.push_screen(SearchScreen()) self.push_screen(SearchScreen())
elif action == "m-lyrics": elif action == "m-lyrics":
self.action_show_lyrics() self.action_show_lyrics()
elif action == "m-settings":
self.push_screen(SettingsScreen())
elif action == "m-quit": elif action == "m-quit":
self.exit() 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: def action_show_favorites(self) -> None:
self.push_screen(FavoritesScreen()) self.push_screen(FavoritesScreen())