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:
35
README.md
35
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
Токен можно передать и через переменную окружения:
|
Токен можно передать и через переменную окружения:
|
||||||
|
|||||||
52
mtsmusic.py
52
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 {}
|
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)
|
||||||
|
|||||||
22
player.py
22
player.py
@@ -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
258
qt_app.py
@@ -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
104
settings.py
Normal 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
193
tui.py
@@ -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())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user