feat: redesign TUI as a compact phone-style player
This commit is contained in:
27
README.md
27
README.md
@@ -35,22 +35,33 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
.venv/bin/python mtsmusic.py tui
|
.venv/bin/python mtsmusic.py tui
|
||||||
```
|
```
|
||||||
|
|
||||||
Интерфейс в терминале: обложка (цветными блоками, нужен truecolor-терминал),
|
Интерфейс в терминале сделан как экран телефона: по центру обложка (цветными
|
||||||
название, исполнитель, альбом, прогресс, очередь «Моей волны» и избранное.
|
блоками, нужен truecolor-терминал), ниже — большая кнопка play/pause, по бокам
|
||||||
Мышь работает: кнопки, вкладки и строки списка кликабельны.
|
— предыдущий и следующий треки. Под ними — «Текст» и «В избранное». Кнопка
|
||||||
|
«☰» в левом верхнем углу открывает боковое меню: «Моя волна», избранное,
|
||||||
|
история прослушивания, поиск, текст песни и выход.
|
||||||
|
|
||||||
|
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
|
||||||
|
(клик по прогрессу перематывает трек).
|
||||||
|
|
||||||
| Клавиша | Действие |
|
| Клавиша | Действие |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| `Space` | пауза / продолжить |
|
| `Space` | пауза / продолжить |
|
||||||
| `n` / `→` | следующий трек |
|
| `←` / `→` | перемотка на −5 / +5 секунд |
|
||||||
| `p` / `←` | предыдущий трек |
|
| `n` / `p` | следующий / предыдущий трек |
|
||||||
|
| `t` | текст песни |
|
||||||
| `l` | добавить / убрать из избранного |
|
| `l` | добавить / убрать из избранного |
|
||||||
| `f` | перейти к избранному |
|
| `m` | открыть меню |
|
||||||
| `r` | догрузить «Мою волну» |
|
| `w` | список «Моей волны» |
|
||||||
|
| `f` | избранное |
|
||||||
|
| `h` | история |
|
||||||
|
| `s` | поиск |
|
||||||
| `q` | выход |
|
| `q` | выход |
|
||||||
|
|
||||||
|
История хранится в `.mts-music/history.json` (последние 100 треков).
|
||||||
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
||||||
`SIGSTOP`/`SIGCONT`, поэтому отдельный демон не нужен.
|
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает поток с нужной секунды,
|
||||||
|
поэтому отдельный демон не нужен.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|||||||
72
media.py
Normal file
72
media.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Shared track helpers and local history storage."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mtsmusic import CONFIG_DIR
|
||||||
|
|
||||||
|
HISTORY_PATH = CONFIG_DIR / "history.json"
|
||||||
|
HISTORY_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
|
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, size: int = 360) -> 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"{size}x{size}")
|
||||||
|
if not uri.startswith("http"):
|
||||||
|
uri = "https://" + uri
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
|
def load_history() -> list[dict]:
|
||||||
|
try:
|
||||||
|
data = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
seen: set[str] = set()
|
||||||
|
history: list[dict] = []
|
||||||
|
for track in data:
|
||||||
|
if not isinstance(track, dict):
|
||||||
|
continue
|
||||||
|
track_id = str(track.get("id") or "")
|
||||||
|
if not track_id or track_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(track_id)
|
||||||
|
history.append(track)
|
||||||
|
return history[:HISTORY_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def record_history(track: dict) -> list[dict]:
|
||||||
|
"""Добавляет трек в начало истории и сохраняет последние 100."""
|
||||||
|
track_id = str(track.get("id") or "")
|
||||||
|
if not track_id:
|
||||||
|
return load_history()
|
||||||
|
history = [item for item in load_history() if str(item.get("id")) != track_id]
|
||||||
|
history.insert(0, track)
|
||||||
|
history = history[:HISTORY_LIMIT]
|
||||||
|
try:
|
||||||
|
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
HISTORY_PATH.write_text(
|
||||||
|
json.dumps(history, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return history
|
||||||
143
player.py
Normal file
143
player.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Playback engine for the MTS Music clients.
|
||||||
|
|
||||||
|
ffplay supports pause via SIGSTOP/SIGCONT. Seeking restarts the stream with
|
||||||
|
`-ss` at the requested offset, which keeps the implementation tiny and avoids
|
||||||
|
requiring mpv/libmpv.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class MusicPlayer:
|
||||||
|
def __init__(self, explicit: str | None = None) -> None:
|
||||||
|
self.path = self._pick(explicit)
|
||||||
|
self.proc: asyncio.subprocess.Process | None = None
|
||||||
|
self.url: str | None = None
|
||||||
|
self.offset = 0.0
|
||||||
|
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, start: float = 0.0) -> list[str]:
|
||||||
|
name = Path(self.path or "").name
|
||||||
|
if name == "ffplay":
|
||||||
|
command = [
|
||||||
|
self.path or "ffplay",
|
||||||
|
"-nodisp",
|
||||||
|
"-autoexit",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
]
|
||||||
|
if start > 0:
|
||||||
|
command += ["-ss", f"{start:.3f}"]
|
||||||
|
return command + [url]
|
||||||
|
command = [self.path or "mpv", "--no-video", "--really-quiet"]
|
||||||
|
if start > 0:
|
||||||
|
command.append(f"--start={start:.3f}")
|
||||||
|
return command + [url]
|
||||||
|
|
||||||
|
async def play(self, url: str, start: float = 0.0) -> None:
|
||||||
|
await self.stop()
|
||||||
|
if not self.path:
|
||||||
|
raise RuntimeError("не найден mpv/ffplay")
|
||||||
|
self.switching = True
|
||||||
|
self.url = url
|
||||||
|
self.offset = max(0.0, start)
|
||||||
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
|
*self._command(url, self.offset),
|
||||||
|
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, self.offset + now - self.started_at - self.paused_total)
|
||||||
|
|
||||||
|
async def seek(self, delta: float, duration: float) -> None:
|
||||||
|
"""Seek by delta seconds. Restarts ffplay at the new position."""
|
||||||
|
if not self.url or not self.is_playing():
|
||||||
|
return
|
||||||
|
limit = max(1.0, duration - 1.0) if duration else None
|
||||||
|
target = max(0.0, self.elapsed() + delta)
|
||||||
|
if limit:
|
||||||
|
target = min(target, limit)
|
||||||
|
was_paused = self.paused
|
||||||
|
await self.play(self.url, target)
|
||||||
|
if was_paused:
|
||||||
|
self.pause()
|
||||||
Reference in New Issue
Block a user