feat: add Textual TUI player
This commit is contained in:
23
README.md
23
README.md
@@ -29,6 +29,29 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
сохранятся в `.mts-music/credentials.json` с правами `600`.
|
сохранятся в `.mts-music/credentials.json` с правами `600`.
|
||||||
Повторный вход нужен только когда токен перестанет работать.
|
Повторный вход нужен только когда токен перестанет работать.
|
||||||
|
|
||||||
|
## TUI-плеер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python mtsmusic.py tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Интерфейс в терминале: обложка (цветными блоками, нужен truecolor-терминал),
|
||||||
|
название, исполнитель, альбом, прогресс, очередь «Моей волны» и избранное.
|
||||||
|
Мышь работает: кнопки, вкладки и строки списка кликабельны.
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `Space` | пауза / продолжить |
|
||||||
|
| `n` / `→` | следующий трек |
|
||||||
|
| `p` / `←` | предыдущий трек |
|
||||||
|
| `l` | добавить / убрать из избранного |
|
||||||
|
| `f` | перейти к избранному |
|
||||||
|
| `r` | догрузить «Мою волну» |
|
||||||
|
| `q` | выход |
|
||||||
|
|
||||||
|
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
||||||
|
`SIGSTOP`/`SIGCONT`, поэтому отдельный демон не нужен.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
12
mtsmusic.py
12
mtsmusic.py
@@ -797,6 +797,14 @@ def cmd_download(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# cli
|
# cli
|
||||||
|
def cmd_tui(args: argparse.Namespace) -> None:
|
||||||
|
try:
|
||||||
|
from tui import MusicTui
|
||||||
|
except ImportError as exc:
|
||||||
|
die(f"для TUI нужен textual и pillow: {exc}. Установи: pip install -r requirements.txt")
|
||||||
|
MusicTui(player=args.player).run()
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="mtsmusic",
|
prog="mtsmusic",
|
||||||
@@ -848,6 +856,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
lyrics.set_defaults(func=cmd_lyrics)
|
lyrics.set_defaults(func=cmd_lyrics)
|
||||||
|
|
||||||
|
tui = sub.add_parser("tui", help="полноценный TUI-плеер (мышь, обложка, избранное)")
|
||||||
|
tui.add_argument("--player", help="путь к mpv/ffplay")
|
||||||
|
tui.set_defaults(func=cmd_tui)
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
playwright>=1.40
|
playwright>=1.40
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
textual>=0.80
|
||||||
|
pillow>=10
|
||||||
|
|||||||
734
tui.py
Normal file
734
tui.py
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Полноценный TUI-плеер MTS Music на Textual.
|
||||||
|
|
||||||
|
Запуск: python mtsmusic.py tui
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
from rich.text import Text
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
ProgressBar,
|
||||||
|
Static,
|
||||||
|
TabbedContent,
|
||||||
|
TabPane,
|
||||||
|
)
|
||||||
|
|
||||||
|
from mtsmusic import (
|
||||||
|
USER_AGENT,
|
||||||
|
Api,
|
||||||
|
album_id,
|
||||||
|
fetch_mix_batch,
|
||||||
|
fmt_duration,
|
||||||
|
force_ipv4,
|
||||||
|
get_uid,
|
||||||
|
resolve_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
COVER_WIDTH = 38
|
||||||
|
COVER_SIZE = 360
|
||||||
|
WAVE_BATCH = 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# данные треков
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def track_artist(track: dict) -> str:
|
||||||
|
return ", ".join(a.get("name", "?") for a in track.get("artists") or []) or "?"
|
||||||
|
|
||||||
|
|
||||||
|
def track_album(track: dict) -> str:
|
||||||
|
albums = track.get("albums") or []
|
||||||
|
return (albums[0].get("title") or "—") if albums else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def track_cover_url(track: dict) -> str | None:
|
||||||
|
uri = track.get("coverUri")
|
||||||
|
if not uri:
|
||||||
|
albums = track.get("albums") or []
|
||||||
|
uri = albums[0].get("coverUri") if albums else None
|
||||||
|
if not uri:
|
||||||
|
return None
|
||||||
|
uri = uri.replace("%%", f"{COVER_SIZE}x{COVER_SIZE}")
|
||||||
|
if not uri.startswith("http"):
|
||||||
|
uri = "https://" + uri
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
|
def cover_renderable(data: bytes, width: int = COVER_WIDTH) -> Text:
|
||||||
|
"""Картинка в терминале: две вертикальные точки на один символ ▀."""
|
||||||
|
image = Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
|
rows = max(1, width // 2)
|
||||||
|
image = image.resize((width, rows * 2), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
canvas = Text()
|
||||||
|
for y in range(rows):
|
||||||
|
for x in range(width):
|
||||||
|
top = image.getpixel((x, y * 2))
|
||||||
|
bottom = image.getpixel((x, y * 2 + 1))
|
||||||
|
canvas.append(
|
||||||
|
"▀",
|
||||||
|
style=f"#{top[0]:02x}{top[1]:02x}{top[2]:02x} "
|
||||||
|
f"on #{bottom[0]:02x}{bottom[1]:02x}{bottom[2]:02x}",
|
||||||
|
)
|
||||||
|
if y != rows - 1:
|
||||||
|
canvas.append("\n")
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# плеер
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MusicPlayer:
|
||||||
|
"""ffplay/mpv + пауза через SIGSTOP/SIGCONT."""
|
||||||
|
|
||||||
|
def __init__(self, explicit: str | None = None) -> None:
|
||||||
|
self.path = self._pick(explicit)
|
||||||
|
self.proc: asyncio.subprocess.Process | None = None
|
||||||
|
self.paused = False
|
||||||
|
self.started_at = 0.0
|
||||||
|
self.paused_total = 0.0
|
||||||
|
self.paused_at = 0.0
|
||||||
|
self.switching = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pick(explicit: str | None) -> str | None:
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
env = os.environ.get("MTSMUSIC_PLAYER")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
for candidate in ("mpv", "ffplay"):
|
||||||
|
found = shutil.which(candidate)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _command(self, url: str) -> list[str]:
|
||||||
|
name = Path(self.path or "").name
|
||||||
|
if name == "ffplay":
|
||||||
|
return [self.path or "ffplay", "-nodisp", "-autoexit", "-loglevel", "error", url]
|
||||||
|
return [self.path or "mpv", "--no-video", "--really-quiet", url]
|
||||||
|
|
||||||
|
async def play(self, url: str) -> None:
|
||||||
|
await self.stop()
|
||||||
|
if not self.path:
|
||||||
|
raise RuntimeError("не найден mpv/ffplay")
|
||||||
|
self.switching = True
|
||||||
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
|
*self._command(url),
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
self.paused = False
|
||||||
|
self.started_at = time.monotonic()
|
||||||
|
self.paused_total = 0.0
|
||||||
|
self.switching = False
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self.switching = True
|
||||||
|
proc = self.proc
|
||||||
|
if proc and proc.returncode is None:
|
||||||
|
if self.paused:
|
||||||
|
try:
|
||||||
|
proc.send_signal(signal.SIGCONT)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
self.proc = None
|
||||||
|
self.paused = False
|
||||||
|
self.switching = False
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
if self.proc and self.proc.returncode is None and not self.paused:
|
||||||
|
try:
|
||||||
|
self.proc.send_signal(signal.SIGSTOP)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
self.paused = True
|
||||||
|
self.paused_at = time.monotonic()
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
if self.proc and self.proc.returncode is None and self.paused:
|
||||||
|
try:
|
||||||
|
self.proc.send_signal(signal.SIGCONT)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
self.paused = False
|
||||||
|
self.paused_total += time.monotonic() - self.paused_at
|
||||||
|
|
||||||
|
def toggle_pause(self) -> bool:
|
||||||
|
if self.paused:
|
||||||
|
self.resume()
|
||||||
|
else:
|
||||||
|
self.pause()
|
||||||
|
return self.paused
|
||||||
|
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return bool(self.proc and self.proc.returncode is None)
|
||||||
|
|
||||||
|
def elapsed(self) -> float:
|
||||||
|
if not self.proc:
|
||||||
|
return 0.0
|
||||||
|
now = self.paused_at if self.paused else time.monotonic()
|
||||||
|
return max(0.0, now - self.started_at - self.paused_total)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# приложение
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MusicTui(App):
|
||||||
|
TITLE = "MTS Music"
|
||||||
|
SUB_TITLE = "Моя волна"
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Screen { layout: vertical; }
|
||||||
|
|
||||||
|
#main { height: 1fr; }
|
||||||
|
|
||||||
|
#left {
|
||||||
|
width: 44;
|
||||||
|
min-width: 44;
|
||||||
|
padding: 1 1 0 1;
|
||||||
|
border-right: solid $primary-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cover {
|
||||||
|
width: 40;
|
||||||
|
height: 22;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#info {
|
||||||
|
height: auto;
|
||||||
|
padding: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress {
|
||||||
|
margin: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#time {
|
||||||
|
height: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#right { width: 1fr; padding: 0 1; }
|
||||||
|
|
||||||
|
TabbedContent { height: 1fr; }
|
||||||
|
TabPane { height: 1fr; padding: 0; }
|
||||||
|
DataTable { height: 1fr; }
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
height: 3;
|
||||||
|
align: center middle;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls Button {
|
||||||
|
margin: 0 1;
|
||||||
|
min-width: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#like { min-width: 14; }
|
||||||
|
#status { width: 1fr; content-align: right middle; color: $text-muted; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("space", "toggle_pause", "Пауза", show=True),
|
||||||
|
Binding("n", "next_track", "Следующий", show=True),
|
||||||
|
Binding("p", "prev_track", "Предыдущий", show=True),
|
||||||
|
Binding("l", "toggle_like", "В избранное", show=True),
|
||||||
|
Binding("f", "show_favorites", "Избранное", show=True),
|
||||||
|
Binding("r", "refresh_wave", "Обновить волну", show=False),
|
||||||
|
Binding("q", "quit", "Выход", show=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, player: str | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.explicit_player = player
|
||||||
|
self.api: Api | None = None
|
||||||
|
self.uid = ""
|
||||||
|
self.player = MusicPlayer(player)
|
||||||
|
self.wave: list[dict] = []
|
||||||
|
self.favorites: list[dict] = []
|
||||||
|
self.liked_ids: set[str] = set()
|
||||||
|
self.queue: list[dict] = []
|
||||||
|
self.queue_name = "wave"
|
||||||
|
self.current: dict | None = None
|
||||||
|
self.current_index = -1
|
||||||
|
self.play_token = 0
|
||||||
|
|
||||||
|
# -- разметка ---------------------------------------------------------
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
with Horizontal(id="main"):
|
||||||
|
with Vertical(id="left"):
|
||||||
|
yield Static("Загрузка…", id="cover")
|
||||||
|
yield Static("", id="info")
|
||||||
|
yield ProgressBar(total=100, show_eta=False, id="progress")
|
||||||
|
yield Static("0:00 / 0:00", id="time")
|
||||||
|
with Vertical(id="right"):
|
||||||
|
with TabbedContent(id="tabs"):
|
||||||
|
with TabPane("Моя волна", id="tab-wave"):
|
||||||
|
yield DataTable(id="wave-table", cursor_type="row", zebra_stripes=True)
|
||||||
|
with TabPane("Избранное", id="tab-fav"):
|
||||||
|
yield DataTable(id="fav-table", cursor_type="row", zebra_stripes=True)
|
||||||
|
with Horizontal(id="controls"):
|
||||||
|
yield Button("⏮", id="prev", variant="default")
|
||||||
|
yield Button("▶", id="playpause", variant="primary")
|
||||||
|
yield Button("⏭", id="next", variant="default")
|
||||||
|
yield Button("♡ В избранное", id="like", variant="success")
|
||||||
|
yield Button("⟳ Волна", id="refresh", variant="default")
|
||||||
|
yield Static("", id="status")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
wave_keys = self.query_one("#wave-table", DataTable).add_columns(
|
||||||
|
"#", "Трек", "Исполнитель", "Альбом", "Длит.", "♥"
|
||||||
|
)
|
||||||
|
fav_keys = self.query_one("#fav-table", DataTable).add_columns(
|
||||||
|
"#", "Трек", "Исполнитель", "Альбом", "Длит.", "♥"
|
||||||
|
)
|
||||||
|
self.like_columns = {
|
||||||
|
"wave-table": wave_keys[-1],
|
||||||
|
"fav-table": fav_keys[-1],
|
||||||
|
}
|
||||||
|
self.set_interval(0.4, self.tick)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.api = Api()
|
||||||
|
except SystemExit:
|
||||||
|
self.exit(message="Не удалось инициализировать API")
|
||||||
|
return
|
||||||
|
if not self.api.token:
|
||||||
|
self.notify("Сначала войди: python mtsmusic.py login", severity="error", timeout=10)
|
||||||
|
self.set_timer(3, self.exit)
|
||||||
|
return
|
||||||
|
self.load_initial()
|
||||||
|
|
||||||
|
async def on_unmount(self) -> None:
|
||||||
|
await self.player.stop()
|
||||||
|
|
||||||
|
# -- загрузка ---------------------------------------------------------
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True)
|
||||||
|
def load_initial(self) -> None:
|
||||||
|
assert self.api
|
||||||
|
try:
|
||||||
|
self.uid = get_uid(self.api)
|
||||||
|
batch = fetch_mix_batch(self.api)
|
||||||
|
wave = [
|
||||||
|
item["track"]
|
||||||
|
for item in (batch.get("sequence") or [])
|
||||||
|
if item.get("type") == "track" and item.get("track")
|
||||||
|
]
|
||||||
|
likes = self.api.get(f"/users/{self.uid}/likes/tracks")
|
||||||
|
refs = ((likes.get("result") or {}).get("library") or {}).get("tracks") or []
|
||||||
|
ids = [str(ref.get("id")) for ref in refs]
|
||||||
|
favorites: list[dict] = []
|
||||||
|
for start in range(0, len(ids), 50):
|
||||||
|
chunk = ids[start : start + 50]
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
favorites.extend(
|
||||||
|
self.api.get("/tracks", trackIds=",".join(chunk)).get("result") or []
|
||||||
|
)
|
||||||
|
self.call_from_thread(self.apply_initial, wave, favorites)
|
||||||
|
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], favorites: list[dict]) -> None:
|
||||||
|
self.wave = wave
|
||||||
|
self.favorites = favorites
|
||||||
|
self.liked_ids = {str(track.get("id")) for track in favorites}
|
||||||
|
self.fill_table(self.query_one("#wave-table", DataTable), self.wave)
|
||||||
|
self.fill_table(self.query_one("#fav-table", DataTable), self.favorites)
|
||||||
|
self.set_status(f"Волна: {len(self.wave)} · избранное: {len(self.favorites)}")
|
||||||
|
if self.wave:
|
||||||
|
self.select_first("#wave-table")
|
||||||
|
self.current = self.wave[0]
|
||||||
|
self.current_index = 0
|
||||||
|
self.queue = self.wave
|
||||||
|
self.queue_name = "wave"
|
||||||
|
self.update_info()
|
||||||
|
self.load_cover(self.wave[0])
|
||||||
|
|
||||||
|
def fill_table(self, table: DataTable, tracks: list[dict]) -> None:
|
||||||
|
table.clear()
|
||||||
|
for index, track in enumerate(tracks, 1):
|
||||||
|
heart = "♥" if str(track.get("id")) in self.liked_ids else ""
|
||||||
|
table.add_row(
|
||||||
|
str(index),
|
||||||
|
track.get("title") or "?",
|
||||||
|
track_artist(track),
|
||||||
|
track_album(track),
|
||||||
|
fmt_duration(track.get("durationMs")),
|
||||||
|
heart,
|
||||||
|
key=str(track.get("id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_wave_tracks(self, tracks: list[dict]) -> None:
|
||||||
|
table = self.query_one("#wave-table", DataTable)
|
||||||
|
known = {str(track.get("id")) for track in self.wave}
|
||||||
|
added = 0
|
||||||
|
for track in tracks:
|
||||||
|
track_id = str(track.get("id"))
|
||||||
|
if not track_id or track_id in known:
|
||||||
|
continue
|
||||||
|
known.add(track_id)
|
||||||
|
self.wave.append(track)
|
||||||
|
heart = "♥" if track_id in self.liked_ids else ""
|
||||||
|
table.add_row(
|
||||||
|
str(len(self.wave)),
|
||||||
|
track.get("title") or "?",
|
||||||
|
track_artist(track),
|
||||||
|
track_album(track),
|
||||||
|
fmt_duration(track.get("durationMs")),
|
||||||
|
heart,
|
||||||
|
key=track_id,
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
self.set_status(f"Волна: {len(self.wave)} · избранное: {len(self.favorites)}")
|
||||||
|
|
||||||
|
def select_first(self, table_id: str) -> None:
|
||||||
|
table = self.query_one(table_id, DataTable)
|
||||||
|
if table.row_count:
|
||||||
|
table.move_cursor(row=0)
|
||||||
|
|
||||||
|
# -- информация о треке ----------------------------------------------
|
||||||
|
|
||||||
|
def update_info(self) -> None:
|
||||||
|
track = self.current
|
||||||
|
info = self.query_one("#info", Static)
|
||||||
|
if not track:
|
||||||
|
info.update(Text("Ничего не играет", style="dim"))
|
||||||
|
return
|
||||||
|
|
||||||
|
track_id = str(track.get("id"))
|
||||||
|
liked = track_id in self.liked_ids
|
||||||
|
lines = Text()
|
||||||
|
lines.append(track.get("title") or "?", style="bold")
|
||||||
|
lines.append("\n")
|
||||||
|
lines.append(track_artist(track), style="italic")
|
||||||
|
lines.append("\n")
|
||||||
|
lines.append(track_album(track), style="dim")
|
||||||
|
lines.append("\n")
|
||||||
|
lines.append(f"{fmt_duration(track.get('durationMs'))} ")
|
||||||
|
lines.append("♥ в избранном" if liked else "♡ не в избранном", style="red" if liked else "dim")
|
||||||
|
|
||||||
|
info.update(lines)
|
||||||
|
button = self.query_one("#like", Button)
|
||||||
|
button.label = "♥ Убрать" if liked else "♡ В избранное"
|
||||||
|
button.variant = "warning" if liked else "success"
|
||||||
|
self.update_play_button()
|
||||||
|
|
||||||
|
def update_play_button(self) -> None:
|
||||||
|
button = self.query_one("#playpause", Button)
|
||||||
|
button.label = "⏸" if self.player.paused else "▶"
|
||||||
|
|
||||||
|
def update_time(self) -> None:
|
||||||
|
duration = (self.current or {}).get("durationMs") or 0
|
||||||
|
elapsed = min(self.player.elapsed(), duration / 1000 if duration else 0)
|
||||||
|
bar = self.query_one("#progress", ProgressBar)
|
||||||
|
bar.update(total=max(duration / 1000, 1), progress=elapsed)
|
||||||
|
label = self.query_one("#time", Static)
|
||||||
|
label.update(f"{fmt_duration(int(elapsed * 1000))} / {fmt_duration(duration)}")
|
||||||
|
|
||||||
|
def set_status(self, text: str) -> None:
|
||||||
|
self.query_one("#status", Static).update(text)
|
||||||
|
|
||||||
|
# -- обложка ----------------------------------------------------------
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="cover")
|
||||||
|
def load_cover(self, track: dict) -> None:
|
||||||
|
url = track_cover_url(track)
|
||||||
|
if not url:
|
||||||
|
self.call_from_thread(self.show_placeholder_cover)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
force_ipv4()
|
||||||
|
response = requests.get(
|
||||||
|
url, headers={"User-Agent": USER_AGENT}, timeout=20
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
renderable = cover_renderable(response.content)
|
||||||
|
self.call_from_thread(self.show_cover, renderable)
|
||||||
|
except (requests.RequestException, OSError, ValueError):
|
||||||
|
self.call_from_thread(self.show_placeholder_cover)
|
||||||
|
|
||||||
|
def show_cover(self, renderable: Text) -> None:
|
||||||
|
self.query_one("#cover", Static).update(renderable)
|
||||||
|
|
||||||
|
def show_placeholder_cover(self) -> None:
|
||||||
|
self.query_one("#cover", Static).update("♪\n\nнет обложки")
|
||||||
|
|
||||||
|
# -- воспроизведение --------------------------------------------------
|
||||||
|
|
||||||
|
def play_track(self, track: dict, queue: list[dict] | None = None, index: int | None = None) -> None:
|
||||||
|
if queue is not None:
|
||||||
|
self.queue = queue
|
||||||
|
if index is not None:
|
||||||
|
self.current_index = index
|
||||||
|
else:
|
||||||
|
self.current_index = next(
|
||||||
|
(i for i, item in enumerate(self.queue) if str(item.get("id")) == str(track.get("id"))),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
self.current = track
|
||||||
|
self.play_token += 1
|
||||||
|
token = self.play_token
|
||||||
|
self.update_info()
|
||||||
|
self.update_time()
|
||||||
|
self.load_cover(track)
|
||||||
|
self.set_status("Загружаю поток…")
|
||||||
|
self.fetch_stream(token, track)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=False, group="stream")
|
||||||
|
def fetch_stream(self, token: int, track: dict) -> None:
|
||||||
|
assert self.api
|
||||||
|
try:
|
||||||
|
url, item = resolve_stream(self.api, str(track.get("id")))
|
||||||
|
except SystemExit as exc:
|
||||||
|
self.call_from_thread(
|
||||||
|
self.notify, f"Не удалось открыть трек: {exc}", severity="error"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.call_from_thread(
|
||||||
|
self.start_playback, token, track, url, item.get("preview", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_playback(self, token: int, track: dict, url: str, preview: bool) -> None:
|
||||||
|
if token != self.play_token:
|
||||||
|
return
|
||||||
|
self.run_worker(self._play_url(track, url, preview), group="playback")
|
||||||
|
|
||||||
|
async def _play_url(self, track: dict, url: str, preview: bool) -> None:
|
||||||
|
try:
|
||||||
|
await self.player.play(url)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
self.notify(str(exc), severity="error")
|
||||||
|
return
|
||||||
|
self.update_play_button()
|
||||||
|
self.set_status(
|
||||||
|
("preview · " if preview else "")
|
||||||
|
+ f"Играет: {track_artist(track)} — {track.get('title')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_toggle_pause(self) -> None:
|
||||||
|
if not self.player.is_playing():
|
||||||
|
self.action_play_current()
|
||||||
|
return
|
||||||
|
self.player.toggle_pause()
|
||||||
|
self.update_play_button()
|
||||||
|
|
||||||
|
def action_play_current(self) -> None:
|
||||||
|
if self.current:
|
||||||
|
queue = self.queue or self.wave
|
||||||
|
index = self.current_index if 0 <= self.current_index < len(queue) else None
|
||||||
|
self.play_track(self.current, queue, index)
|
||||||
|
|
||||||
|
def try_next(self, direction: int) -> None:
|
||||||
|
queue = self.queue or self.wave
|
||||||
|
if not queue:
|
||||||
|
return
|
||||||
|
index = self.current_index + direction
|
||||||
|
if index >= len(queue):
|
||||||
|
if self.queue_name == "wave":
|
||||||
|
self.load_more_wave(then_play=index)
|
||||||
|
return
|
||||||
|
index = 0
|
||||||
|
if index < 0:
|
||||||
|
index = len(queue) - 1
|
||||||
|
self.current_index = index
|
||||||
|
self.play_track(queue[index], queue, index)
|
||||||
|
|
||||||
|
def action_next_track(self) -> None:
|
||||||
|
self.try_next(1)
|
||||||
|
|
||||||
|
def action_prev_track(self) -> None:
|
||||||
|
self.try_next(-1)
|
||||||
|
|
||||||
|
def action_show_favorites(self) -> None:
|
||||||
|
tabs = self.query_one("#tabs", TabbedContent)
|
||||||
|
tabs.active = "tab-fav"
|
||||||
|
table = self.query_one("#fav-table", DataTable)
|
||||||
|
if table.row_count:
|
||||||
|
table.focus()
|
||||||
|
|
||||||
|
def action_refresh_wave(self) -> None:
|
||||||
|
self.load_more_wave(refresh=True)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="wave")
|
||||||
|
def load_more_wave(self, then_play: int | None = None, refresh: bool = False) -> None:
|
||||||
|
assert self.api
|
||||||
|
try:
|
||||||
|
if refresh:
|
||||||
|
queue = []
|
||||||
|
else:
|
||||||
|
ids = [str(track.get("id")) for track in self.wave[-WAVE_BATCH:]]
|
||||||
|
queue = ids if ids else []
|
||||||
|
batch = fetch_mix_batch(self.api, queue or None)
|
||||||
|
tracks = [
|
||||||
|
item["track"]
|
||||||
|
for item in (batch.get("sequence") or [])
|
||||||
|
if item.get("type") == "track" and item.get("track")
|
||||||
|
]
|
||||||
|
self.call_from_thread(self.apply_more_wave, tracks, then_play)
|
||||||
|
except SystemExit as exc:
|
||||||
|
self.call_from_thread(self.notify, f"Ошибка волны: {exc}", severity="error")
|
||||||
|
|
||||||
|
def apply_more_wave(self, tracks: list[dict], then_play: int | None) -> None:
|
||||||
|
if not tracks:
|
||||||
|
self.notify("Волна не вернула новых треков", severity="warning")
|
||||||
|
return
|
||||||
|
self.add_wave_tracks(tracks)
|
||||||
|
if then_play is not None and then_play < len(self.queue):
|
||||||
|
self.play_track(self.queue[then_play], self.queue, then_play)
|
||||||
|
|
||||||
|
# -- избранное --------------------------------------------------------
|
||||||
|
|
||||||
|
def action_toggle_like(self) -> None:
|
||||||
|
if not self.current:
|
||||||
|
return
|
||||||
|
self.toggle_like(self.current)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="like")
|
||||||
|
def toggle_like(self, track: dict) -> None:
|
||||||
|
assert self.api
|
||||||
|
track_id = str(track.get("id"))
|
||||||
|
album = album_id(track)
|
||||||
|
if not album:
|
||||||
|
self.call_from_thread(self.notify, "У трека нет альбома", severity="error")
|
||||||
|
return
|
||||||
|
liked = track_id in self.liked_ids
|
||||||
|
try:
|
||||||
|
if liked:
|
||||||
|
self.api.post(
|
||||||
|
f"/users/{self.uid}/likes/tracks/remove",
|
||||||
|
**{"track-ids": f"{track_id}:{album}"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.api.post(
|
||||||
|
f"/users/{self.uid}/likes/tracks/add",
|
||||||
|
**{"track-id": f"{track_id}:{album}"},
|
||||||
|
)
|
||||||
|
except SystemExit as exc:
|
||||||
|
self.call_from_thread(self.notify, f"Ошибка избранного: {exc}", severity="error")
|
||||||
|
return
|
||||||
|
self.call_from_thread(self.apply_like, track, not liked)
|
||||||
|
|
||||||
|
def apply_like(self, track: dict, liked: bool) -> None:
|
||||||
|
track_id = str(track.get("id"))
|
||||||
|
if liked:
|
||||||
|
self.liked_ids.add(track_id)
|
||||||
|
if not any(str(item.get("id")) == track_id for item in self.favorites):
|
||||||
|
self.favorites.insert(0, track)
|
||||||
|
else:
|
||||||
|
self.liked_ids.discard(track_id)
|
||||||
|
self.favorites = [
|
||||||
|
item for item in self.favorites if str(item.get("id")) != track_id
|
||||||
|
]
|
||||||
|
|
||||||
|
wave_table = self.query_one("#wave-table", DataTable)
|
||||||
|
if track_id in {str(item.get("id")) for item in self.wave}:
|
||||||
|
try:
|
||||||
|
wave_table.update_cell(
|
||||||
|
track_id,
|
||||||
|
self.like_columns["wave-table"],
|
||||||
|
"♥" if liked else "",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.fill_table(self.query_one("#fav-table", DataTable), self.favorites)
|
||||||
|
self.update_info()
|
||||||
|
self.notify(
|
||||||
|
"♥ Добавлено в избранное" if liked else "♡ Убрано из избранного",
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
self.set_status(f"Волна: {len(self.wave)} · избранное: {len(self.favorites)}")
|
||||||
|
|
||||||
|
# -- события ----------------------------------------------------------
|
||||||
|
|
||||||
|
@on(DataTable.RowSelected)
|
||||||
|
def row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
table = event.data_table
|
||||||
|
track_id = str(event.row_key.value)
|
||||||
|
if table.id == "wave-table":
|
||||||
|
queue, name = self.wave, "wave"
|
||||||
|
else:
|
||||||
|
queue, name = self.favorites, "fav"
|
||||||
|
index = next(
|
||||||
|
(i for i, track in enumerate(queue) if str(track.get("id")) == track_id),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
if index < 0:
|
||||||
|
return
|
||||||
|
self.queue_name = name
|
||||||
|
self.play_track(queue[index], queue, index)
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
handlers = {
|
||||||
|
"prev": self.action_prev_track,
|
||||||
|
"playpause": self.action_toggle_pause,
|
||||||
|
"next": self.action_next_track,
|
||||||
|
"like": self.action_toggle_like,
|
||||||
|
"refresh": self.action_refresh_wave,
|
||||||
|
}
|
||||||
|
handler = handlers.get(event.button.id or "")
|
||||||
|
if handler:
|
||||||
|
handler()
|
||||||
|
|
||||||
|
# -- таймер -----------------------------------------------------------
|
||||||
|
|
||||||
|
def tick(self) -> None:
|
||||||
|
proc = self.player.proc
|
||||||
|
if proc is None:
|
||||||
|
if self.current:
|
||||||
|
self.update_time()
|
||||||
|
return
|
||||||
|
if self.player.switching:
|
||||||
|
return
|
||||||
|
if proc.returncode is not None:
|
||||||
|
# Трек закончился — сразу переходим к следующему.
|
||||||
|
self.player.proc = None
|
||||||
|
self.action_next_track()
|
||||||
|
return
|
||||||
|
self.update_time()
|
||||||
Reference in New Issue
Block a user