Compare commits
5 Commits
5b202984fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8043d8b80 | ||
|
|
de9bf80d11 | ||
|
|
ab595f6ff6 | ||
|
|
e71f5623a7 | ||
|
|
4460791a95 |
105
README.md
105
README.md
@@ -1,8 +1,9 @@
|
|||||||
# MTS Music — another client
|
# MTS Music — another client
|
||||||
|
|
||||||
Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск,
|
Неофициальный клиент для **своего** аккаунта MTS Music: «Моя волна», поиск,
|
||||||
избранное, тексты песен, воспроизведение и скачивание в MP3. Есть компактный
|
избранное, тексты песен, воспроизведение и скачивание в MP3 с полными
|
||||||
TUI-плеер и настольное Qt-приложение в стиле телефона.
|
ID3-тегами. Есть компактный TUI-плеер и настольное Qt-приложение в стиле
|
||||||
|
телефона.
|
||||||
|
|
||||||
> Только для личного использования с собственной платной подпиской MTS.
|
> Только для личного использования с собственной платной подпиской MTS.
|
||||||
> Проект не связан с МТС и Яндексом; контент не перераспространяется.
|
> Проект не связан с МТС и Яндексом; контент не перераспространяется.
|
||||||
@@ -50,7 +51,7 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
|
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
|
||||||
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
|
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
|
||||||
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни,
|
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни,
|
||||||
«Настройки» и выход.
|
«Скачать трек», «Скачать список», «Настройки» и выход.
|
||||||
|
|
||||||
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
|
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
|
||||||
(клик по прогрессу перематывает трек).
|
(клик по прогрессу перематывает трек).
|
||||||
@@ -67,12 +68,20 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
| `f` | избранное |
|
| `f` | избранное |
|
||||||
| `h` | история |
|
| `h` | история |
|
||||||
| `s` | поиск |
|
| `s` | поиск |
|
||||||
|
| `d` | скачать выделенный трек в списке |
|
||||||
| `q` | выход |
|
| `q` | выход |
|
||||||
|
|
||||||
История хранится в `.mts-music/history.json` (последние 100 треков).
|
История хранится в `.mts-music/history.json` (последние 100 треков).
|
||||||
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
Плеер — `ffplay` (или `mpv`, если установлен). Пауза реализована через
|
||||||
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает поток с нужной секунды,
|
`SIGSTOP`/`SIGCONT`, а перемотка перезапускает воспроизведение с нужной
|
||||||
поэтому отдельный демон не нужен.
|
секунды, поэтому отдельный демон не нужен.
|
||||||
|
|
||||||
|
Треки не транслируются напрямую: сначала они скачиваются в локальный кэш
|
||||||
|
`.mts-music/cache/` (до 512 МБ, старые файлы удаляются автоматически), а плеер
|
||||||
|
играет уже готовый MP3. Так надёжнее — сервер Яндекса закрывает соединение,
|
||||||
|
если клиент читает поток медленнее реального времени, из-за чего трек
|
||||||
|
обрывался через полминуты и клиент переключался на следующий. Заодно кэш
|
||||||
|
убирает задержку при повторном прослушивании и делает перемотку мгновенной.
|
||||||
|
|
||||||
## Qt-приложение
|
## Qt-приложение
|
||||||
|
|
||||||
@@ -82,7 +91,8 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
|
|
||||||
Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause,
|
Отдельное окно в стиле телефона: крупная цветная обложка, кнопка play/pause,
|
||||||
переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает
|
переключение треков, перемотка, текст песни и избранное. Кнопка «☰» открывает
|
||||||
боковую панель с «Моей волной», избранным, историей, поиском и настройками.
|
боковую панель с «Моей волной», избранным, историей, поиском, скачиванием
|
||||||
|
и настройками.
|
||||||
|
|
||||||
Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает
|
Воспроизведение — через `QMediaPlayer`/FFmpeg из PySide6, поэтому работает
|
||||||
перемотка и системный звук. По полосе прогресса можно кликнуть в любое место —
|
перемотка и системный звук. По полосе прогресса можно кликнуть в любое место —
|
||||||
@@ -96,6 +106,53 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
«Моя волна» продолжается с последних прослушанных треков (берётся история), а не
|
«Моя волна» продолжается с последних прослушанных треков (берётся история), а не
|
||||||
начинается каждый раз с одного и того же места.
|
начинается каждый раз с одного и того же места.
|
||||||
|
|
||||||
|
## Скачивание и метаданные
|
||||||
|
|
||||||
|
В меню обоих клиентов пункт «Скачать» открывает выбор: текущий трек, всё
|
||||||
|
избранное, 20 треков «Моей волны» или результаты поиска; папка выбирается
|
||||||
|
диалогом в Qt и вводится вручную в TUI, прогресс и число ошибок видны на
|
||||||
|
экране. Быстрые пункты «Скачать трек»/«Скачать список» (и клавиша `d` на
|
||||||
|
экране списка в TUI) сразу кладут в папку `download_dir` без диалога. В CLI
|
||||||
|
это команда `download` с теми же источниками (`--likes`, `--wave`, `--search`).
|
||||||
|
|
||||||
|
Файлы кладутся в папку `download_dir` (`~/Music/MTS Music` по умолчанию),
|
||||||
|
имя — `Исполнитель - Название.mp3`; существующие файлы не перезаписываются,
|
||||||
|
при совпадении добавляется ` (2)`, ` (3)` и т.д. (`--force` перезаписывает).
|
||||||
|
|
||||||
|
В скачанный файл пишется максимум метаданных ID3v2.3 (через `mutagen`, он в
|
||||||
|
`requirements.txt`):
|
||||||
|
|
||||||
|
- название, все исполнители (`TPE1`), альбом (`TALB`) и альбомный исполнитель
|
||||||
|
(`TPE2`);
|
||||||
|
- номер трека и диска (`TRCK`/`TPOS`), год и дата релиза (`TYER`/`TDAT`),
|
||||||
|
жанр как в API (`TCON`), лейбл (`TPUB`), длительность (`TLEN`);
|
||||||
|
- обложка 600×600 (`APIC`);
|
||||||
|
- текст песни (`USLT`) и синхронный текст (`SYLT`), если он есть: сначала
|
||||||
|
пробуем MTS, затем [LRCLIB](https://lrclib.net);
|
||||||
|
- ссылка на трек (`WOAR`/`COMM`), пометка `explicit` (`ITUNESADVISORY`) и
|
||||||
|
служебные поля с id трека, альбома и `realId` (`TXXX`).
|
||||||
|
|
||||||
|
Без `mutagen` или при `--no-tags` теги не пишутся, скачивание при этом
|
||||||
|
работает как обычно.
|
||||||
|
|
||||||
|
## Управление из системной панели (MPRIS)
|
||||||
|
|
||||||
|
При запуске TUI и Qt-приложение регистрируются в сессионном D-Bus как источник
|
||||||
|
звука (`org.mpris.MediaPlayer2.mtsmusic`). Поэтому плеер виден системе и им можно
|
||||||
|
управлять из панели GNOME — например, расширением
|
||||||
|
[media-controls](https://github.com/sakithb/media-controls), — а также через
|
||||||
|
`playerctl` и другие MPRIS-клиенты.
|
||||||
|
|
||||||
|
Что работает из панели:
|
||||||
|
|
||||||
|
- play/pause, следующий/предыдущий трек;
|
||||||
|
- перемотка (слайдер и seek);
|
||||||
|
- отображение названия, исполнителя, альбома, длительности и цветной обложки;
|
||||||
|
- громкость.
|
||||||
|
|
||||||
|
Для этого нужен `dbus-next` (он уже в `requirements.txt`). Без сессионного
|
||||||
|
D-Bus или без `dbus-next` приложение просто работает как обычно, без интеграции.
|
||||||
|
|
||||||
## Настройки
|
## Настройки
|
||||||
|
|
||||||
Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной
|
Настройки открываются из меню (пункт «Настройки» в TUI и Qt) или из командной
|
||||||
@@ -116,10 +173,12 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
| `autoplay` | `false` | начинать воспроизведение сразу при запуске |
|
| `autoplay` | `false` | начинать воспроизведение сразу при запуске |
|
||||||
| `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` |
|
| `start_queue` | `wave` | что открыть первым: `wave`, `favorites` или `history` |
|
||||||
| `wave_from_history` | `true` | продолжать «Мою волну» с последних треков |
|
| `wave_from_history` | `true` | продолжать «Мою волну» с последних треков |
|
||||||
|
| `download_dir` | `~/Music/MTS Music` | куда сохранять скачанные MP3 |
|
||||||
|
|
||||||
В Qt-приложении на странице настроек дополнительно выбирается устройство вывода
|
В Qt-приложении на странице настроек дополнительно выбирается устройство вывода
|
||||||
звука. Громкость меняется на лету, остальные настройки применяются к новым
|
звука и папка загрузок (кнопка «Выбрать…»). В TUI путь задаётся в настройках
|
||||||
трекам и при следующем запуске.
|
пунктом «Папка загрузок». Громкость меняется на лету, остальные настройки
|
||||||
|
применяются к новым трекам и при следующем запуске.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
@@ -136,10 +195,12 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
# сразу слушать подборку (Ctrl-C — остановить)
|
# сразу слушать подборку (Ctrl-C — остановить)
|
||||||
.venv/bin/python mtsmusic.py mix -n 5 --play
|
.venv/bin/python mtsmusic.py mix -n 5 --play
|
||||||
|
|
||||||
# избранное: добавить / убрать / показать
|
# избранное: добавить / убрать / показать текстом
|
||||||
.venv/bin/python mtsmusic.py like 12345678
|
.venv/bin/python mtsmusic.py like 12345678
|
||||||
.venv/bin/python mtsmusic.py unlike 12345678
|
.venv/bin/python mtsmusic.py unlike 12345678
|
||||||
.venv/bin/python mtsmusic.py likes -n 20
|
.venv/bin/python mtsmusic.py likes # все любимые треки
|
||||||
|
.venv/bin/python mtsmusic.py likes -n 20 # только первые 20
|
||||||
|
.venv/bin/python mtsmusic.py likes --ids # только ID, для скриптов
|
||||||
|
|
||||||
# текст песни: показать, сохранить, или с таймкодами
|
# текст песни: показать, сохранить, или с таймкодами
|
||||||
.venv/bin/python mtsmusic.py lyrics 12345678
|
.venv/bin/python mtsmusic.py lyrics 12345678
|
||||||
@@ -155,8 +216,25 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
|||||||
# воспроизведение (нужен mpv или ffplay)
|
# воспроизведение (нужен mpv или ffplay)
|
||||||
.venv/bin/python mtsmusic.py play 12345678
|
.venv/bin/python mtsmusic.py play 12345678
|
||||||
|
|
||||||
# скачивание
|
# скачивание: в папку из download_dir, имя «Исполнитель - Название.mp3»
|
||||||
|
.venv/bin/python mtsmusic.py download 12345678
|
||||||
|
|
||||||
|
# несколько треков или своя папка
|
||||||
|
.venv/bin/python mtsmusic.py download 12345678 87654321 -d ~/Music/MTS
|
||||||
|
|
||||||
|
# как в мобильном приложении: избранное, волна или результаты поиска
|
||||||
|
.venv/bin/python mtsmusic.py download --likes -d ~/Music/MTS
|
||||||
|
.venv/bin/python mtsmusic.py download --wave -n 20 -d ~/Music/MTS
|
||||||
|
.venv/bin/python mtsmusic.py download --search "Кино" -n 10 -d ~/Music/MTS
|
||||||
|
|
||||||
|
# один трек с явным именем файла, перезапись существующих
|
||||||
.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 download --likes -d ~/Music/MTS --force
|
||||||
|
|
||||||
|
# то же самое есть в меню TUI и Qt — пункт «Скачать»
|
||||||
|
|
||||||
|
# без ID3-тегов (быстрее, без обложки и текста)
|
||||||
|
.venv/bin/python mtsmusic.py download 12345678 --no-tags
|
||||||
|
|
||||||
# настройки
|
# настройки
|
||||||
.venv/bin/python mtsmusic.py settings
|
.venv/bin/python mtsmusic.py settings
|
||||||
@@ -198,6 +276,11 @@ TUI нужно запускать в настоящем терминале (stdi
|
|||||||
- Использовать только для личного прослушивания, не перераздавать контент.
|
- Использовать только для личного прослушивания, не перераздавать контент.
|
||||||
- Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно
|
- Если `api.music.yandex.net` недоступен, проверь IPv6: клиент принудительно
|
||||||
ходит по IPv4.
|
ходит по IPv4.
|
||||||
|
- Кэш треков лежит в `.mts-music/cache/`. Его можно удалить в любой момент —
|
||||||
|
при следующем запуске треки скачаются заново.
|
||||||
|
- `download` (и пункт «Скачать» в TUI/Qt) по умолчанию не перезаписывает уже
|
||||||
|
скачанные файлы: повторный запуск докачает только новое. Флаг `--force`
|
||||||
|
в CLI включает перезапись.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
8
media.py
8
media.py
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from mtsmusic import CONFIG_DIR
|
from mtsmusic import CONFIG_DIR
|
||||||
|
|
||||||
@@ -12,7 +11,8 @@ HISTORY_LIMIT = 100
|
|||||||
|
|
||||||
|
|
||||||
def track_artist(track: dict) -> str:
|
def track_artist(track: dict) -> str:
|
||||||
return ", ".join(a.get("name", "?") for a in track.get("artists") or []) or "?"
|
return ", ".join(a.get("name", "?")
|
||||||
|
for a in track.get("artists") or []) or "?"
|
||||||
|
|
||||||
|
|
||||||
def track_album(track: dict) -> str:
|
def track_album(track: dict) -> str:
|
||||||
@@ -58,7 +58,9 @@ def record_history(track: dict) -> list[dict]:
|
|||||||
track_id = str(track.get("id") or "")
|
track_id = str(track.get("id") or "")
|
||||||
if not track_id:
|
if not track_id:
|
||||||
return load_history()
|
return load_history()
|
||||||
history = [item for item in load_history() if str(item.get("id")) != track_id]
|
history = [
|
||||||
|
item for item in load_history() if str(
|
||||||
|
item.get("id")) != track_id]
|
||||||
history.insert(0, track)
|
history.insert(0, track)
|
||||||
history = history[:HISTORY_LIMIT]
|
history = history[:HISTORY_LIMIT]
|
||||||
try:
|
try:
|
||||||
|
|||||||
381
mpris.py
Normal file
381
mpris.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
"""MPRIS (D-Bus) интеграция: система видит плеер как источник звука.
|
||||||
|
|
||||||
|
Благодаря этому плеером можно управлять из панели GNOME (расширение
|
||||||
|
media-controls), из `playerctl` и т.п.: play/pause, следующий/предыдущий,
|
||||||
|
перемотка, метаданные и обложка.
|
||||||
|
|
||||||
|
Модуль необязательный: если нет `dbus-next` или сессионного D-Bus, всё
|
||||||
|
превращается в no-op, и приложения работают как обычно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
log = logging.getLogger("mpris")
|
||||||
|
|
||||||
|
try: # pragma: no cover - зависит от окружения
|
||||||
|
from dbus_next import Variant
|
||||||
|
from dbus_next.aio import MessageBus
|
||||||
|
from dbus_next.constants import PropertyAccess
|
||||||
|
from dbus_next.service import (
|
||||||
|
ServiceInterface,
|
||||||
|
dbus_property,
|
||||||
|
method,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
HAVE_DBUS = True
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
HAVE_DBUS = False
|
||||||
|
|
||||||
|
BUS_NAME = "org.mpris.MediaPlayer2.mtsmusic"
|
||||||
|
OBJECT_PATH = "/org/mpris/MediaPlayer2"
|
||||||
|
|
||||||
|
|
||||||
|
def track_object_path(track_id: Any) -> str:
|
||||||
|
"""D-Bus object path для mpris:trackid (только [A-Za-z0-9_])."""
|
||||||
|
safe = "".join(ch if ch.isalnum()
|
||||||
|
else "_" for ch in str(track_id)) or "unknown"
|
||||||
|
return f"/mtsmusic/track/{safe}"
|
||||||
|
|
||||||
|
|
||||||
|
if HAVE_DBUS:
|
||||||
|
|
||||||
|
class _RootInterface(ServiceInterface):
|
||||||
|
def __init__(self, service: "MprisService"):
|
||||||
|
super().__init__("org.mpris.MediaPlayer2")
|
||||||
|
self._svc = service
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Raise(self):
|
||||||
|
self._svc.dispatch("raise")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Quit(self):
|
||||||
|
self._svc.dispatch("quit")
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def Identity(self) -> "s": # noqa: F821
|
||||||
|
return self._svc.identity
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def DesktopEntry(self) -> "s": # noqa: F821
|
||||||
|
return self._svc.desktop_entry
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanQuit(self) -> "b": # noqa: F821
|
||||||
|
return True
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanRaise(self) -> "b": # noqa: F821
|
||||||
|
return True
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def HasTrackList(self) -> "b": # noqa: F821
|
||||||
|
return False
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def SupportedUriSchemes(self) -> "as": # noqa: F722
|
||||||
|
return []
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def SupportedMimeTypes(self) -> "as": # noqa: F722
|
||||||
|
return []
|
||||||
|
|
||||||
|
class _PlayerInterface(ServiceInterface):
|
||||||
|
def __init__(self, service: "MprisService"):
|
||||||
|
super().__init__("org.mpris.MediaPlayer2.Player")
|
||||||
|
self._svc = service
|
||||||
|
|
||||||
|
# -- методы ------------------------------------------------------
|
||||||
|
@method()
|
||||||
|
def Play(self):
|
||||||
|
self._svc.dispatch("play")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Pause(self):
|
||||||
|
self._svc.dispatch("pause")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def PlayPause(self):
|
||||||
|
self._svc.dispatch("play_pause")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Stop(self):
|
||||||
|
self._svc.dispatch("stop")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Next(self):
|
||||||
|
self._svc.dispatch("next")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Previous(self):
|
||||||
|
self._svc.dispatch("previous")
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def Seek(self, offset: "x"): # noqa: F821
|
||||||
|
self._svc.dispatch("seek", int(offset))
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def SetPosition(self, track_id: "o", position: "x"): # noqa: F821
|
||||||
|
self._svc.dispatch("set_position", str(track_id), int(position))
|
||||||
|
|
||||||
|
@method()
|
||||||
|
def OpenUri(self, uri: "s"): # noqa: F821
|
||||||
|
self._svc.dispatch("open_uri", str(uri))
|
||||||
|
|
||||||
|
# -- сигналы -----------------------------------------------------
|
||||||
|
@signal()
|
||||||
|
def Seeked(self, position: "x") -> "x": # noqa: F821
|
||||||
|
return position
|
||||||
|
|
||||||
|
# -- свойства ----------------------------------------------------
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def PlaybackStatus(self) -> "s": # noqa: F821
|
||||||
|
return self._svc.playback_status
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def Metadata(self) -> "a{sv}": # noqa: F722
|
||||||
|
return self._svc.metadata
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def Position(self) -> "x": # noqa: F821
|
||||||
|
return int(self._svc.position)
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def MinimumRate(self) -> "d": # noqa: F821
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def MaximumRate(self) -> "d": # noqa: F821
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanGoNext(self) -> "b": # noqa: F821
|
||||||
|
return self._svc.can_next
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanGoPrevious(self) -> "b": # noqa: F821
|
||||||
|
return self._svc.can_previous
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanPlay(self) -> "b": # noqa: F821
|
||||||
|
return self._svc.can_play
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanPause(self) -> "b": # noqa: F821
|
||||||
|
return self._svc.can_pause
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanSeek(self) -> "b": # noqa: F821
|
||||||
|
return self._svc.can_seek
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READ)
|
||||||
|
def CanControl(self) -> "b": # noqa: F821
|
||||||
|
return True
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READWRITE)
|
||||||
|
def Volume(self) -> "d": # noqa: F821
|
||||||
|
return float(self._svc.volume)
|
||||||
|
|
||||||
|
@Volume.setter
|
||||||
|
def Volume(self, value: "d"): # noqa: F821
|
||||||
|
self._svc.dispatch("volume", float(value))
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READWRITE)
|
||||||
|
def LoopStatus(self) -> "s": # noqa: F821
|
||||||
|
return self._svc.loop_status
|
||||||
|
|
||||||
|
@LoopStatus.setter
|
||||||
|
def LoopStatus(self, value: "s"): # noqa: F821
|
||||||
|
self._svc.loop_status = str(value)
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READWRITE)
|
||||||
|
def Shuffle(self) -> "b": # noqa: F821
|
||||||
|
return self._svc.shuffle
|
||||||
|
|
||||||
|
@Shuffle.setter
|
||||||
|
def Shuffle(self, value: "b"): # noqa: F821
|
||||||
|
self._svc.shuffle = bool(value)
|
||||||
|
|
||||||
|
@dbus_property(access=PropertyAccess.READWRITE)
|
||||||
|
def Rate(self) -> "d": # noqa: F821
|
||||||
|
return float(self._svc.rate)
|
||||||
|
|
||||||
|
@Rate.setter
|
||||||
|
def Rate(self, value: "d"): # noqa: F821
|
||||||
|
self._svc.rate = float(value)
|
||||||
|
|
||||||
|
|
||||||
|
class MprisService:
|
||||||
|
"""Публикует MPRIS на сессионном D-Bus; состояние задаётся снаружи.
|
||||||
|
|
||||||
|
``handler`` — объект с необязательными методами ``on_play``,
|
||||||
|
``on_pause``, ``on_play_pause``, ``on_next``, ``on_previous``,
|
||||||
|
``on_stop``, ``on_seek(offset_us)``, ``on_set_position(track_path, us)``,
|
||||||
|
``on_volume(value)``, ``on_raise()``, ``on_quit()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
handler: Any,
|
||||||
|
identity: str = "MTS Music",
|
||||||
|
desktop_entry: str = "mts-music",
|
||||||
|
):
|
||||||
|
self.handler = handler
|
||||||
|
self.identity = identity
|
||||||
|
self.desktop_entry = desktop_entry
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
self.playback_status = "Stopped"
|
||||||
|
self.metadata: dict[str, Any] = {}
|
||||||
|
self.position = 0
|
||||||
|
self.volume = 1.0
|
||||||
|
self.can_next = False
|
||||||
|
self.can_previous = False
|
||||||
|
self.can_play = True
|
||||||
|
self.can_pause = True
|
||||||
|
self.can_seek = False
|
||||||
|
self.loop_status = "None"
|
||||||
|
self.shuffle = False
|
||||||
|
self.rate = 1.0
|
||||||
|
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._bus: Any = None
|
||||||
|
self._player: Any = None
|
||||||
|
self._root: Any = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
if HAVE_DBUS:
|
||||||
|
self._player = _PlayerInterface(self)
|
||||||
|
self._root = _RootInterface(self)
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run, name="mpris-dbus", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
# -- жизненный цикл ---------------------------------------------------
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
self._loop = loop
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(self._connect())
|
||||||
|
loop.run_forever()
|
||||||
|
except Exception: # pragma: no cover - окружение без D-Bus
|
||||||
|
log.debug("MPRIS недоступен", exc_info=True)
|
||||||
|
|
||||||
|
async def _connect(self):
|
||||||
|
self._bus = await MessageBus().connect()
|
||||||
|
self._bus.export(OBJECT_PATH, self._player)
|
||||||
|
self._bus.export(OBJECT_PATH, self._root)
|
||||||
|
await self._bus.request_name(BUS_NAME)
|
||||||
|
self.enabled = True
|
||||||
|
log.debug("MPRIS зарегистрирован: %s", BUS_NAME)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._loop and self._loop.is_running():
|
||||||
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||||
|
|
||||||
|
# -- диспетчеризация команд из D-Bus ---------------------------------
|
||||||
|
|
||||||
|
def dispatch(self, action: str, *args: Any):
|
||||||
|
callback: Callable[..., Any] | None = getattr(
|
||||||
|
self.handler, "on_" + action, None
|
||||||
|
)
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(*args)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
log.debug("ошибка обработчика MPRIS: %s", action, exc_info=True)
|
||||||
|
|
||||||
|
def _emit(self, changed: dict[str, Any]):
|
||||||
|
if not (self.enabled and self._loop):
|
||||||
|
return
|
||||||
|
self._loop.call_soon_threadsafe(
|
||||||
|
self._player.emit_properties_changed, changed)
|
||||||
|
|
||||||
|
# -- обновление состояния (из основного потока) ----------------------
|
||||||
|
|
||||||
|
def update_playback_status(self, status: str):
|
||||||
|
if status == self.playback_status:
|
||||||
|
return
|
||||||
|
self.playback_status = status
|
||||||
|
self.can_seek = status != "Stopped" or self.can_seek
|
||||||
|
self._emit({"PlaybackStatus": status})
|
||||||
|
|
||||||
|
def update_metadata(
|
||||||
|
self,
|
||||||
|
track_id: Any,
|
||||||
|
title: str = "",
|
||||||
|
artist: str = "",
|
||||||
|
album: str = "",
|
||||||
|
duration_ms: int = 0,
|
||||||
|
art_url: str = "",
|
||||||
|
):
|
||||||
|
if not HAVE_DBUS:
|
||||||
|
return
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"mpris:trackid": Variant("o", track_object_path(track_id)),
|
||||||
|
"mpris:length": Variant("x", int(duration_ms) * 1000),
|
||||||
|
"xesam:title": Variant("s", title or ""),
|
||||||
|
"xesam:artist": Variant("as", [artist] if artist else []),
|
||||||
|
"xesam:album": Variant("s", album or ""),
|
||||||
|
}
|
||||||
|
if art_url:
|
||||||
|
metadata["mpris:artUrl"] = Variant("s", art_url)
|
||||||
|
self.metadata = metadata
|
||||||
|
self._emit({"Metadata": metadata})
|
||||||
|
|
||||||
|
def clear_metadata(self):
|
||||||
|
self.metadata = {}
|
||||||
|
self._emit({"Metadata": self.metadata})
|
||||||
|
|
||||||
|
def update_position(self, position_us: int):
|
||||||
|
# Position в MPRIS не рассылается через PropertiesChanged — клиенты
|
||||||
|
# читают его сами (или ориентируются на сигнал Seeked).
|
||||||
|
self.position = max(0, int(position_us))
|
||||||
|
|
||||||
|
def notify_seeked(self, position_us: int):
|
||||||
|
self.update_position(position_us)
|
||||||
|
if self.enabled and self._loop:
|
||||||
|
self._loop.call_soon_threadsafe(
|
||||||
|
self._player.Seeked, int(position_us))
|
||||||
|
|
||||||
|
def update_volume(self, volume: float):
|
||||||
|
volume = max(0.0, min(1.0, float(volume)))
|
||||||
|
if abs(volume - self.volume) < 1e-4:
|
||||||
|
return
|
||||||
|
self.volume = volume
|
||||||
|
self._emit({"Volume": volume})
|
||||||
|
|
||||||
|
def update_capabilities(
|
||||||
|
self,
|
||||||
|
can_next: bool | None = None,
|
||||||
|
can_previous: bool | None = None,
|
||||||
|
can_play: bool | None = None,
|
||||||
|
can_pause: bool | None = None,
|
||||||
|
can_seek: bool | None = None,
|
||||||
|
):
|
||||||
|
changed: dict[str, Any] = {}
|
||||||
|
if can_next is not None and can_next != self.can_next:
|
||||||
|
self.can_next = can_next
|
||||||
|
changed["CanGoNext"] = can_next
|
||||||
|
if can_previous is not None and can_previous != self.can_previous:
|
||||||
|
self.can_previous = can_previous
|
||||||
|
changed["CanGoPrevious"] = can_previous
|
||||||
|
if can_play is not None and can_play != self.can_play:
|
||||||
|
self.can_play = can_play
|
||||||
|
changed["CanPlay"] = can_play
|
||||||
|
if can_pause is not None and can_pause != self.can_pause:
|
||||||
|
self.can_pause = can_pause
|
||||||
|
changed["CanPause"] = can_pause
|
||||||
|
if can_seek is not None and can_seek != self.can_seek:
|
||||||
|
self.can_seek = can_seek
|
||||||
|
changed["CanSeek"] = can_seek
|
||||||
|
if changed:
|
||||||
|
self._emit(changed)
|
||||||
732
mtsmusic.py
732
mtsmusic.py
File diff suppressed because it is too large
Load Diff
10
player.py
10
player.py
@@ -67,7 +67,8 @@ class MusicPlayer:
|
|||||||
return command + [url]
|
return command + [url]
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> None:
|
async def set_volume(self, volume: int) -> None:
|
||||||
"""Меняет громкость; ffplay/mpv не умеют на лету, поэтому перезапускаем."""
|
"""Меняет громкость; ffplay/mpv не умеют на лету, поэтому
|
||||||
|
перезапускаем."""
|
||||||
self.volume = max(0, min(100, int(volume)))
|
self.volume = max(0, min(100, int(volume)))
|
||||||
if self.url and self.is_playing():
|
if self.url and self.is_playing():
|
||||||
position = self.elapsed()
|
position = self.elapsed()
|
||||||
@@ -145,7 +146,12 @@ class MusicPlayer:
|
|||||||
if not self.proc:
|
if not self.proc:
|
||||||
return 0.0
|
return 0.0
|
||||||
now = self.paused_at if self.paused else time.monotonic()
|
now = self.paused_at if self.paused else time.monotonic()
|
||||||
return max(0.0, self.offset + now - self.started_at - self.paused_total)
|
return max(
|
||||||
|
0.0,
|
||||||
|
self.offset +
|
||||||
|
now -
|
||||||
|
self.started_at -
|
||||||
|
self.paused_total)
|
||||||
|
|
||||||
async def seek(self, delta: float, duration: float) -> None:
|
async def seek(self, delta: float, duration: float) -> None:
|
||||||
"""Seek by delta seconds. Restarts ffplay at the new position."""
|
"""Seek by delta seconds. Restarts ffplay at the new position."""
|
||||||
|
|||||||
640
qt_app.py
640
qt_app.py
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
from PySide6.QtCore import (
|
||||||
QEasingCurve,
|
QEasingCurve,
|
||||||
@@ -44,6 +45,7 @@ from PySide6.QtWidgets import (
|
|||||||
QAbstractButton,
|
QAbstractButton,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QFileDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -52,6 +54,7 @@ from PySide6.QtWidgets import (
|
|||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSlider,
|
QSlider,
|
||||||
@@ -74,6 +77,8 @@ from media import (
|
|||||||
from mtsmusic import (
|
from mtsmusic import (
|
||||||
USER_AGENT,
|
USER_AGENT,
|
||||||
Api,
|
Api,
|
||||||
|
collect_download_tracks,
|
||||||
|
download_tracks,
|
||||||
fetch_liked_tracks,
|
fetch_liked_tracks,
|
||||||
fetch_mix_batch,
|
fetch_mix_batch,
|
||||||
fmt_duration,
|
fmt_duration,
|
||||||
@@ -82,7 +87,7 @@ from mtsmusic import (
|
|||||||
lrclib_lyrics,
|
lrclib_lyrics,
|
||||||
lyrics_response,
|
lyrics_response,
|
||||||
lyrics_text,
|
lyrics_text,
|
||||||
resolve_stream,
|
fetch_track_file,
|
||||||
)
|
)
|
||||||
from settings import (
|
from settings import (
|
||||||
START_QUEUE_LABELS,
|
START_QUEUE_LABELS,
|
||||||
@@ -90,6 +95,7 @@ from settings import (
|
|||||||
load_settings,
|
load_settings,
|
||||||
update_settings,
|
update_settings,
|
||||||
)
|
)
|
||||||
|
from mpris import MprisService, track_object_path
|
||||||
|
|
||||||
APP_QSS = """
|
APP_QSS = """
|
||||||
QWidget {
|
QWidget {
|
||||||
@@ -110,7 +116,9 @@ QLabel { background: transparent; }
|
|||||||
#albumLabel { color: #6f6f82; font-size: 12px; }
|
#albumLabel { color: #6f6f82; font-size: 12px; }
|
||||||
#timeLabel { color: #8b8b9e; font-size: 11px; }
|
#timeLabel { color: #8b8b9e; font-size: 11px; }
|
||||||
#queueLabel { color: #6f6f82; font-size: 11px; }
|
#queueLabel { color: #6f6f82; font-size: 11px; }
|
||||||
QSlider::groove:horizontal { height: 4px; background: #2a2a36; border-radius: 2px; }
|
QSlider::groove:horizontal {
|
||||||
|
height: 4px; background: #2a2a36; border-radius: 2px;
|
||||||
|
}
|
||||||
QSlider::sub-page:horizontal { background: #ff3b5c; border-radius: 2px; }
|
QSlider::sub-page:horizontal { background: #ff3b5c; border-radius: 2px; }
|
||||||
QSlider::handle:horizontal {
|
QSlider::handle:horizontal {
|
||||||
width: 12px; margin: -4px 0; border-radius: 6px; background: #ffffff;
|
width: 12px; margin: -4px 0; border-radius: 6px; background: #ffffff;
|
||||||
@@ -118,24 +126,32 @@ QSlider::handle:horizontal {
|
|||||||
QSlider::handle:horizontal:hover { background: #ffd9df; }
|
QSlider::handle:horizontal:hover { background: #ffd9df; }
|
||||||
#playBtn { background: #ff3b5c; border: none; border-radius: 38px; }
|
#playBtn { background: #ff3b5c; border: none; border-radius: 38px; }
|
||||||
#playBtn:hover { background: #ff5574; }
|
#playBtn:hover { background: #ff5574; }
|
||||||
#prevBtn, #nextBtn { background: rgba(255,255,255,0.05); border: none; border-radius: 22px; }
|
#prevBtn, #nextBtn {
|
||||||
|
background: rgba(255,255,255,0.05); border: none; border-radius: 22px;
|
||||||
|
}
|
||||||
#prevBtn:hover, #nextBtn:hover { background: rgba(255,255,255,0.11); }
|
#prevBtn:hover, #nextBtn:hover { background: rgba(255,255,255,0.11); }
|
||||||
#actionBtn {
|
#actionBtn {
|
||||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px;
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px;
|
||||||
padding: 8px 18px; font-size: 12px;
|
padding: 8px 18px; font-size: 12px;
|
||||||
}
|
}
|
||||||
#actionBtn:hover { background: rgba(255,255,255,0.09); }
|
#actionBtn:hover { background: rgba(255,255,255,0.09); }
|
||||||
#actionBtn[liked="true"] { color: #ff8296; border-color: rgba(255,59,92,0.40); }
|
#actionBtn[liked="true"] {
|
||||||
|
color: #ff8296; border-color: rgba(255,59,92,0.40);
|
||||||
|
}
|
||||||
#menuBtn:focus, #prevBtn:focus, #nextBtn:focus, #playBtn:focus,
|
#menuBtn:focus, #prevBtn:focus, #nextBtn:focus, #playBtn:focus,
|
||||||
#actionBtn:focus, #backBtn:focus, #searchBtn:focus {
|
#actionBtn:focus, #backBtn:focus, #searchBtn:focus {
|
||||||
border: 2px solid #ffffff;
|
border: 2px solid #ffffff;
|
||||||
}
|
}
|
||||||
QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; }
|
QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; }
|
||||||
#drawer { background: #16161f; border-right: 1px solid rgba(255,255,255,0.06); }
|
#drawer {
|
||||||
|
background: #16161f; border-right: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
#drawerTitle { font-size: 18px; font-weight: 700; padding: 4px 2px 10px 2px; }
|
#drawerTitle { font-size: 18px; font-weight: 700; padding: 4px 2px 10px 2px; }
|
||||||
#drawerBrand { color: #6f6f82; font-size: 11px; padding: 0 2px 12px 2px; }
|
#drawerBrand { color: #6f6f82; font-size: 11px; padding: 0 2px 12px 2px; }
|
||||||
#drawer QPushButton {
|
#drawer QPushButton {
|
||||||
text-align: left; background: transparent; border: none; border-radius: 10px;
|
text-align: left; background: transparent;
|
||||||
|
border: none; border-radius: 10px;
|
||||||
padding: 11px 12px; font-size: 14px; color: #e8e8f0;
|
padding: 11px 12px; font-size: 14px; color: #e8e8f0;
|
||||||
}
|
}
|
||||||
#drawer QPushButton:hover { background: rgba(255,255,255,0.06); }
|
#drawer QPushButton:hover { background: rgba(255,255,255,0.06); }
|
||||||
@@ -150,7 +166,8 @@ QSlider:focus { border: 1px solid #ffffff; border-radius: 4px; }
|
|||||||
QListWidget { background: transparent; border: none; outline: 0; }
|
QListWidget { background: transparent; border: none; outline: 0; }
|
||||||
QListWidget::item { border: none; }
|
QListWidget::item { border: none; }
|
||||||
QLineEdit {
|
QLineEdit {
|
||||||
background: #1b1b25; border: 1px solid rgba(255,255,255,0.07); border-radius: 13px;
|
background: #1b1b25; border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 13px;
|
||||||
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); }
|
||||||
@@ -169,7 +186,8 @@ QComboBox QAbstractItemView {
|
|||||||
QCheckBox { color: #dcdce6; font-size: 13px; spacing: 8px; }
|
QCheckBox { color: #dcdce6; font-size: 13px; spacing: 8px; }
|
||||||
QCheckBox::indicator { width: 16px; height: 16px; }
|
QCheckBox::indicator { width: 16px; height: 16px; }
|
||||||
QCheckBox::indicator:unchecked {
|
QCheckBox::indicator:unchecked {
|
||||||
border: 1px solid rgba(255,255,255,0.35); border-radius: 4px; background: #1b1b25;
|
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;
|
||||||
@@ -187,14 +205,16 @@ QScrollBar::handle:vertical {
|
|||||||
QScrollBar::add-line, QScrollBar::sub-line { height: 0; }
|
QScrollBar::add-line, QScrollBar::sub-line { height: 0; }
|
||||||
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
|
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
|
||||||
#toast {
|
#toast {
|
||||||
background: #23232f; border: 1px solid rgba(255,255,255,0.09); border-radius: 14px;
|
background: #23232f; border: 1px solid rgba(255,255,255,0.09);
|
||||||
|
border-radius: 14px;
|
||||||
padding: 10px 16px; color: #f2f2f7; font-size: 12px;
|
padding: 10px 16px; color: #f2f2f7; font-size: 12px;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def media_icon(kind: str, size: int = 64) -> QIcon:
|
def media_icon(kind: str, size: int = 64) -> QIcon:
|
||||||
"""Рисует белые иконки плеера, чтобы они выглядели одинаково в любой теме."""
|
"""Рисует белые иконки плеера, чтобы они выглядели одинаково
|
||||||
|
в любой теме."""
|
||||||
pixmap = QPixmap(size, size)
|
pixmap = QPixmap(size, size)
|
||||||
pixmap.fill(Qt.GlobalColor.transparent)
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
painter = QPainter(pixmap)
|
painter = QPainter(pixmap)
|
||||||
@@ -210,10 +230,31 @@ def media_icon(kind: str, size: int = 64) -> QIcon:
|
|||||||
path.closeSubpath()
|
path.closeSubpath()
|
||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
elif kind == "pause":
|
elif kind == "pause":
|
||||||
painter.drawRoundedRect(QRectF(20 * s, 16 * s, 9 * s, 32 * s), 2 * s, 2 * s)
|
painter.drawRoundedRect(
|
||||||
painter.drawRoundedRect(QRectF(35 * s, 16 * s, 9 * s, 32 * s), 2 * s, 2 * s)
|
QRectF(
|
||||||
|
20 * s,
|
||||||
|
16 * s,
|
||||||
|
9 * s,
|
||||||
|
32 * s),
|
||||||
|
2 * s,
|
||||||
|
2 * s)
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(
|
||||||
|
35 * s,
|
||||||
|
16 * s,
|
||||||
|
9 * s,
|
||||||
|
32 * s),
|
||||||
|
2 * s,
|
||||||
|
2 * s)
|
||||||
elif kind == "prev":
|
elif kind == "prev":
|
||||||
painter.drawRoundedRect(QRectF(15 * s, 14 * s, 7 * s, 36 * s), 2 * s, 2 * s)
|
painter.drawRoundedRect(
|
||||||
|
QRectF(
|
||||||
|
15 * s,
|
||||||
|
14 * s,
|
||||||
|
7 * s,
|
||||||
|
36 * s),
|
||||||
|
2 * s,
|
||||||
|
2 * s)
|
||||||
path = QPainterPath()
|
path = QPainterPath()
|
||||||
path.moveTo(52 * s, 14 * s)
|
path.moveTo(52 * s, 14 * s)
|
||||||
path.lineTo(52 * s, 50 * s)
|
path.lineTo(52 * s, 50 * s)
|
||||||
@@ -221,7 +262,14 @@ def media_icon(kind: str, size: int = 64) -> QIcon:
|
|||||||
path.closeSubpath()
|
path.closeSubpath()
|
||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
elif kind == "next":
|
elif kind == "next":
|
||||||
painter.drawRoundedRect(QRectF(42 * s, 14 * s, 7 * s, 36 * s), 2 * s, 2 * s)
|
painter.drawRoundedRect(
|
||||||
|
QRectF(
|
||||||
|
42 * s,
|
||||||
|
14 * s,
|
||||||
|
7 * s,
|
||||||
|
36 * s),
|
||||||
|
2 * s,
|
||||||
|
2 * s)
|
||||||
path = QPainterPath()
|
path = QPainterPath()
|
||||||
path.moveTo(12 * s, 14 * s)
|
path.moveTo(12 * s, 14 * s)
|
||||||
path.lineTo(12 * s, 50 * s)
|
path.lineTo(12 * s, 50 * s)
|
||||||
@@ -270,6 +318,46 @@ class Task(QRunnable):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressTask(QRunnable):
|
||||||
|
"""Задача, которая умеет присылать прогресс в GUI-поток."""
|
||||||
|
|
||||||
|
class Signals(QObject):
|
||||||
|
progress = Signal(object)
|
||||||
|
done = Signal(object, object)
|
||||||
|
failed = Signal(object, str)
|
||||||
|
|
||||||
|
def __init__(self, fn) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.fn = fn
|
||||||
|
self.signals = ProgressTask.Signals()
|
||||||
|
self.setAutoDelete(False)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self.fn(self._emit_progress)
|
||||||
|
except SystemExit as exc:
|
||||||
|
self._emit_failed(str(exc) or "ошибка")
|
||||||
|
except Exception as exc: # noqa: BLE001 - сообщение показываем в UI
|
||||||
|
self._emit_failed(str(exc) or exc.__class__.__name__)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.signals.done.emit(self, result)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _emit_progress(self, event: dict) -> None:
|
||||||
|
try:
|
||||||
|
self.signals.progress.emit(event)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _emit_failed(self, message: str) -> None:
|
||||||
|
try:
|
||||||
|
self.signals.failed.emit(self, message)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CoverView(QWidget):
|
class CoverView(QWidget):
|
||||||
"""Обложка со скруглениями и аккуратным плейсхолдером."""
|
"""Обложка со скруглениями и аккуратным плейсхолдером."""
|
||||||
|
|
||||||
@@ -319,14 +407,14 @@ class CoverView(QWidget):
|
|||||||
gradient.setColorAt(0.0, QColor("#252533"))
|
gradient.setColorAt(0.0, QColor("#252533"))
|
||||||
gradient.setColorAt(1.0, QColor("#15151d"))
|
gradient.setColorAt(1.0, QColor("#15151d"))
|
||||||
painter.fillRect(rect, gradient)
|
painter.fillRect(rect, gradient)
|
||||||
painter.setPen(QColor(255,255,255,48))
|
painter.setPen(QColor(255, 255, 255, 48))
|
||||||
font = QFont()
|
font = QFont()
|
||||||
font.setPointSize(64)
|
font.setPointSize(64)
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "♪")
|
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "♪")
|
||||||
|
|
||||||
painter.setClipping(False)
|
painter.setClipping(False)
|
||||||
painter.setPen(QPen(QColor(255,255,255,31), 1))
|
painter.setPen(QPen(QColor(255, 255, 255, 31), 1))
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 20, 20)
|
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 20, 20)
|
||||||
painter.end()
|
painter.end()
|
||||||
@@ -421,7 +509,12 @@ class TrackDelegate(QStyledItemDelegate):
|
|||||||
|
|
||||||
painter.setPen(QColor("#6f6f82"))
|
painter.setPen(QColor("#6f6f82"))
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
QRectF(rect.right() - duration_width, rect.top(), duration_width, 22),
|
QRectF(
|
||||||
|
rect.right() -
|
||||||
|
duration_width,
|
||||||
|
rect.top(),
|
||||||
|
duration_width,
|
||||||
|
22),
|
||||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
duration,
|
duration,
|
||||||
)
|
)
|
||||||
@@ -439,6 +532,55 @@ class Scrim(QFrame):
|
|||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class MprisBridge(QObject):
|
||||||
|
"""Принимает команды MPRIS из D-Bus и перекладывает их в поток Qt."""
|
||||||
|
|
||||||
|
play = Signal()
|
||||||
|
pause = Signal()
|
||||||
|
playPause = Signal()
|
||||||
|
stop = Signal()
|
||||||
|
next = Signal()
|
||||||
|
previous = Signal()
|
||||||
|
seek = Signal(int)
|
||||||
|
setPosition = Signal(str, int)
|
||||||
|
volume = Signal(float)
|
||||||
|
raiseWindow = Signal()
|
||||||
|
quitApp = Signal()
|
||||||
|
|
||||||
|
def on_play(self) -> None:
|
||||||
|
self.play.emit()
|
||||||
|
|
||||||
|
def on_pause(self) -> None:
|
||||||
|
self.pause.emit()
|
||||||
|
|
||||||
|
def on_play_pause(self) -> None:
|
||||||
|
self.playPause.emit()
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
self.stop.emit()
|
||||||
|
|
||||||
|
def on_next(self) -> None:
|
||||||
|
self.next.emit()
|
||||||
|
|
||||||
|
def on_previous(self) -> None:
|
||||||
|
self.previous.emit()
|
||||||
|
|
||||||
|
def on_seek(self, offset: int) -> None:
|
||||||
|
self.seek.emit(int(offset))
|
||||||
|
|
||||||
|
def on_set_position(self, track_id: str, position: int) -> None:
|
||||||
|
self.setPosition.emit(str(track_id), int(position))
|
||||||
|
|
||||||
|
def on_volume(self, value: float) -> None:
|
||||||
|
self.volume.emit(float(value))
|
||||||
|
|
||||||
|
def on_raise(self) -> None:
|
||||||
|
self.raiseWindow.emit()
|
||||||
|
|
||||||
|
def on_quit(self) -> None:
|
||||||
|
self.quitApp.emit()
|
||||||
|
|
||||||
|
|
||||||
class QtMusicApp(QMainWindow):
|
class QtMusicApp(QMainWindow):
|
||||||
def __init__(self, api: Api) -> None:
|
def __init__(self, api: Api) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -452,6 +594,7 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.current_index = -1
|
self.current_index = -1
|
||||||
self.liked_ids: set[str] = set()
|
self.liked_ids: set[str] = set()
|
||||||
self.favorites: list[dict] = []
|
self.favorites: list[dict] = []
|
||||||
|
self.search_results: list[dict] = []
|
||||||
self.history: list[dict] = load_history()
|
self.history: list[dict] = load_history()
|
||||||
self.cover_cache: dict[str, QPixmap] = {}
|
self.cover_cache: dict[str, QPixmap] = {}
|
||||||
self.current_cover_url = ""
|
self.current_cover_url = ""
|
||||||
@@ -489,6 +632,23 @@ class QtMusicApp(QMainWindow):
|
|||||||
self._update_cover_size()
|
self._update_cover_size()
|
||||||
self.play_btn.setFocus()
|
self.play_btn.setFocus()
|
||||||
|
|
||||||
|
# MPRIS: система видит плеер как источник звука (панель GNOME,
|
||||||
|
# playerctl).
|
||||||
|
self.mpris_bridge = MprisBridge(self)
|
||||||
|
self.mpris_bridge.play.connect(self.mpris_play)
|
||||||
|
self.mpris_bridge.pause.connect(self.mpris_pause)
|
||||||
|
self.mpris_bridge.playPause.connect(self.toggle_play)
|
||||||
|
self.mpris_bridge.stop.connect(self.mpris_stop)
|
||||||
|
self.mpris_bridge.next.connect(lambda: self.advance(1))
|
||||||
|
self.mpris_bridge.previous.connect(lambda: self.advance(-1))
|
||||||
|
self.mpris_bridge.seek.connect(self.mpris_seek)
|
||||||
|
self.mpris_bridge.setPosition.connect(self.mpris_set_position)
|
||||||
|
self.mpris_bridge.volume.connect(self.mpris_set_volume)
|
||||||
|
self.mpris_bridge.raiseWindow.connect(self.mpris_raise)
|
||||||
|
self.mpris_bridge.quitApp.connect(self.close)
|
||||||
|
self.mpris = MprisService(self.mpris_bridge, identity="MTS Music")
|
||||||
|
self.mpris.update_volume(self.audio.volume())
|
||||||
|
|
||||||
QTimer.singleShot(0, self.load_initial)
|
QTimer.singleShot(0, self.load_initial)
|
||||||
|
|
||||||
# -- построение интерфейса -------------------------------------------
|
# -- построение интерфейса -------------------------------------------
|
||||||
@@ -542,9 +702,12 @@ class QtMusicApp(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.pages.addWidget(self.wave_page)
|
self.pages.addWidget(self.wave_page)
|
||||||
|
|
||||||
self.search_page, self.search_input, self.search_list, self.search_status = (
|
(
|
||||||
self._build_search_page()
|
self.search_page,
|
||||||
)
|
self.search_input,
|
||||||
|
self.search_list,
|
||||||
|
self.search_status,
|
||||||
|
) = self._build_search_page()
|
||||||
self.search_list._queue_name = "Поиск"
|
self.search_list._queue_name = "Поиск"
|
||||||
self.search_list._tracks = []
|
self.search_list._tracks = []
|
||||||
self.search_list.itemDoubleClicked.connect(
|
self.search_list.itemDoubleClicked.connect(
|
||||||
@@ -552,14 +715,20 @@ class QtMusicApp(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.pages.addWidget(self.search_page)
|
self.pages.addWidget(self.search_page)
|
||||||
|
|
||||||
self.lyrics_page, self.lyrics_title, self.lyrics_status, self.lyrics_body = (
|
(
|
||||||
self._build_lyrics_page()
|
self.lyrics_page,
|
||||||
)
|
self.lyrics_title,
|
||||||
|
self.lyrics_status,
|
||||||
|
self.lyrics_body,
|
||||||
|
) = self._build_lyrics_page()
|
||||||
self.pages.addWidget(self.lyrics_page)
|
self.pages.addWidget(self.lyrics_page)
|
||||||
|
|
||||||
self.settings_page = self._build_settings_page()
|
self.settings_page = self._build_settings_page()
|
||||||
self.pages.addWidget(self.settings_page)
|
self.pages.addWidget(self.settings_page)
|
||||||
|
|
||||||
|
self.download_page = self._build_download_page()
|
||||||
|
self.pages.addWidget(self.download_page)
|
||||||
|
|
||||||
self._setup_tab_order()
|
self._setup_tab_order()
|
||||||
|
|
||||||
def _setup_tab_order(self) -> None:
|
def _setup_tab_order(self) -> None:
|
||||||
@@ -642,8 +811,10 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.slider.setRange(0, 0)
|
self.slider.setRange(0, 0)
|
||||||
self.slider.seekRequested.connect(self.on_slider_seek)
|
self.slider.seekRequested.connect(self.on_slider_seek)
|
||||||
self.slider.actionTriggered.connect(self.on_slider_action)
|
self.slider.actionTriggered.connect(self.on_slider_action)
|
||||||
self.slider.scrubStarted.connect(lambda: setattr(self, "_seeking", True))
|
self.slider.scrubStarted.connect(
|
||||||
self.slider.scrubFinished.connect(lambda: setattr(self, "_seeking", False))
|
lambda: setattr(self, "_seeking", True))
|
||||||
|
self.slider.scrubFinished.connect(
|
||||||
|
lambda: setattr(self, "_seeking", False))
|
||||||
layout.addWidget(self.slider)
|
layout.addWidget(self.slider)
|
||||||
layout.addSpacing(3)
|
layout.addSpacing(3)
|
||||||
|
|
||||||
@@ -728,7 +899,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def _build_list_page(self, title: str) -> tuple[QWidget, QListWidget, QLabel]:
|
def _build_list_page(
|
||||||
|
self, title: str) -> tuple[QWidget, QListWidget, QLabel]:
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
layout = QVBoxLayout(page)
|
layout = QVBoxLayout(page)
|
||||||
layout.setContentsMargins(16, 10, 16, 12)
|
layout.setContentsMargins(16, 10, 16, 12)
|
||||||
@@ -747,7 +919,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
layout.addWidget(widget, 1)
|
layout.addWidget(widget, 1)
|
||||||
return page, widget, status
|
return page, widget, status
|
||||||
|
|
||||||
def _build_search_page(self) -> tuple[QWidget, QLineEdit, QListWidget, QLabel]:
|
def _build_search_page(
|
||||||
|
self) -> tuple[QWidget, QLineEdit, QListWidget, QLabel]:
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
layout = QVBoxLayout(page)
|
layout = QVBoxLayout(page)
|
||||||
layout.setContentsMargins(16, 10, 16, 12)
|
layout.setContentsMargins(16, 10, 16, 12)
|
||||||
@@ -864,18 +1037,40 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.start_combo.setObjectName("settingCombo")
|
self.start_combo.setObjectName("settingCombo")
|
||||||
for key in START_QUEUES:
|
for key in START_QUEUES:
|
||||||
self.start_combo.addItem(START_QUEUE_LABELS[key], key)
|
self.start_combo.addItem(START_QUEUE_LABELS[key], key)
|
||||||
start_index = self.start_combo.findData(self.settings.get("start_queue", "wave"))
|
start_index = self.start_combo.findData(
|
||||||
|
self.settings.get("start_queue", "wave"))
|
||||||
self.start_combo.setCurrentIndex(max(0, start_index))
|
self.start_combo.setCurrentIndex(max(0, start_index))
|
||||||
self.start_combo.currentIndexChanged.connect(self.on_start_changed)
|
self.start_combo.currentIndexChanged.connect(self.on_start_changed)
|
||||||
start_row.addWidget(start_label)
|
start_row.addWidget(start_label)
|
||||||
start_row.addWidget(self.start_combo, 1)
|
start_row.addWidget(self.start_combo, 1)
|
||||||
layout.addLayout(start_row)
|
layout.addLayout(start_row)
|
||||||
|
|
||||||
self.wave_check = QCheckBox("Продолжать «Мою волну» с последних треков")
|
self.wave_check = QCheckBox(
|
||||||
self.wave_check.setChecked(bool(self.settings.get("wave_from_history", True)))
|
"Продолжать «Мою волну» с последних треков")
|
||||||
|
self.wave_check.setChecked(
|
||||||
|
bool(
|
||||||
|
self.settings.get(
|
||||||
|
"wave_from_history",
|
||||||
|
True)))
|
||||||
self.wave_check.toggled.connect(self.on_wave_from_history_toggled)
|
self.wave_check.toggled.connect(self.on_wave_from_history_toggled)
|
||||||
layout.addWidget(self.wave_check)
|
layout.addWidget(self.wave_check)
|
||||||
|
|
||||||
|
dir_row = QHBoxLayout()
|
||||||
|
dir_row.setSpacing(10)
|
||||||
|
dir_label = QLabel("Папка загрузок")
|
||||||
|
dir_label.setObjectName("settingLabel")
|
||||||
|
self.download_dir_edit = QLineEdit(
|
||||||
|
str(self.settings.get("download_dir") or ""))
|
||||||
|
self.download_dir_edit.setObjectName("settingCombo")
|
||||||
|
self.download_dir_edit.setReadOnly(True)
|
||||||
|
dir_button = QPushButton("Выбрать…")
|
||||||
|
dir_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
dir_button.clicked.connect(self.choose_download_dir)
|
||||||
|
dir_row.addWidget(dir_label)
|
||||||
|
dir_row.addWidget(self.download_dir_edit, 1)
|
||||||
|
dir_row.addWidget(dir_button)
|
||||||
|
layout.addLayout(dir_row)
|
||||||
|
|
||||||
hint = QLabel("Настройки сохраняются в .mts-music/settings.json")
|
hint = QLabel("Настройки сохраняются в .mts-music/settings.json")
|
||||||
hint.setObjectName("pageStatus")
|
hint.setObjectName("pageStatus")
|
||||||
hint.setWordWrap(True)
|
hint.setWordWrap(True)
|
||||||
@@ -883,6 +1078,150 @@ class QtMusicApp(QMainWindow):
|
|||||||
layout.addStretch(1)
|
layout.addStretch(1)
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
def _build_download_page(self) -> QWidget:
|
||||||
|
page = QWidget()
|
||||||
|
layout = QVBoxLayout(page)
|
||||||
|
layout.setContentsMargins(18, 10, 18, 14)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
layout.addLayout(self._page_header("Скачать"))
|
||||||
|
|
||||||
|
dir_label = QLabel("Папка для сохранения")
|
||||||
|
dir_label.setObjectName("settingLabel")
|
||||||
|
layout.addWidget(dir_label)
|
||||||
|
|
||||||
|
dir_row = QHBoxLayout()
|
||||||
|
dir_row.setSpacing(8)
|
||||||
|
self.download_dir = QLineEdit(
|
||||||
|
str(self.settings.get("download_dir")
|
||||||
|
or Path.home() / "Music" / "MTS")
|
||||||
|
)
|
||||||
|
self.download_dir.setObjectName("downloadDir")
|
||||||
|
dir_row.addWidget(self.download_dir, 1)
|
||||||
|
browse = QPushButton("Обзор…")
|
||||||
|
browse.setObjectName("actionBtn")
|
||||||
|
browse.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
browse.clicked.connect(self.browse_download_dir)
|
||||||
|
dir_row.addWidget(browse)
|
||||||
|
layout.addLayout(dir_row)
|
||||||
|
|
||||||
|
source_label = QLabel("Что скачать")
|
||||||
|
source_label.setObjectName("settingLabel")
|
||||||
|
layout.addWidget(source_label)
|
||||||
|
|
||||||
|
self.download_source = QComboBox()
|
||||||
|
self.download_source.setObjectName("settingCombo")
|
||||||
|
for text, kind in (
|
||||||
|
("Текущий трек", "current"),
|
||||||
|
("Всё избранное", "likes"),
|
||||||
|
("Моя волна (20 треков)", "wave"),
|
||||||
|
("Результаты поиска", "search"),
|
||||||
|
):
|
||||||
|
self.download_source.addItem(text, kind)
|
||||||
|
layout.addWidget(self.download_source)
|
||||||
|
|
||||||
|
self.download_btn = QPushButton("Скачать")
|
||||||
|
self.download_btn.setObjectName("actionBtn")
|
||||||
|
self.download_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.download_btn.clicked.connect(self.start_download)
|
||||||
|
layout.addWidget(self.download_btn)
|
||||||
|
|
||||||
|
self.download_progress = QProgressBar()
|
||||||
|
self.download_progress.setRange(0, 1)
|
||||||
|
self.download_progress.setValue(0)
|
||||||
|
self.download_progress.setTextVisible(False)
|
||||||
|
layout.addWidget(self.download_progress)
|
||||||
|
|
||||||
|
self.download_status = QLabel(
|
||||||
|
"Уже скачанные файлы пропускаются, имена — "
|
||||||
|
"«Исполнитель - Название.mp3»."
|
||||||
|
)
|
||||||
|
self.download_status.setObjectName("pageStatus")
|
||||||
|
self.download_status.setWordWrap(True)
|
||||||
|
layout.addWidget(self.download_status)
|
||||||
|
layout.addStretch(1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
# -- скачивание -------------------------------------------------------
|
||||||
|
|
||||||
|
def show_download(self) -> None:
|
||||||
|
self._show(self.download_page)
|
||||||
|
|
||||||
|
def browse_download_dir(self) -> None:
|
||||||
|
directory = QFileDialog.getExistingDirectory(
|
||||||
|
self, "Папка для скачивания", self.download_dir.text().strip()
|
||||||
|
)
|
||||||
|
if directory:
|
||||||
|
self.download_dir.setText(directory)
|
||||||
|
|
||||||
|
def start_download(self) -> None:
|
||||||
|
raw = self.download_dir.text().strip()
|
||||||
|
if not raw:
|
||||||
|
self.toast("Укажи папку для сохранения")
|
||||||
|
return
|
||||||
|
destination = Path(raw).expanduser()
|
||||||
|
self.update_setting("download_dir", str(destination))
|
||||||
|
if hasattr(self, "download_dir_edit"):
|
||||||
|
self.download_dir_edit.setText(str(destination))
|
||||||
|
kind = self.download_source.currentData()
|
||||||
|
if kind == "current" and not self.current:
|
||||||
|
self.toast("Сначала выбери трек")
|
||||||
|
return
|
||||||
|
if kind == "search" and not self.search_results:
|
||||||
|
self.toast("Сначала выполни поиск")
|
||||||
|
return
|
||||||
|
self.download_btn.setEnabled(False)
|
||||||
|
self.download_progress.setRange(0, 0)
|
||||||
|
self.download_status.setText("Готовлю список…")
|
||||||
|
self.run_progress_task(
|
||||||
|
lambda progress: self._download_job(kind, destination, progress),
|
||||||
|
self.download_done,
|
||||||
|
self.download_failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _download_job(self, kind: str, destination: Path, progress) -> tuple:
|
||||||
|
if kind == "current":
|
||||||
|
tracks = [self.current]
|
||||||
|
elif kind == "likes":
|
||||||
|
tracks = collect_download_tracks(self.api, likes=True)
|
||||||
|
elif kind == "wave":
|
||||||
|
tracks = collect_download_tracks(self.api, wave=True, number=20)
|
||||||
|
else:
|
||||||
|
tracks = list(self.search_results)
|
||||||
|
if not tracks:
|
||||||
|
raise ValueError("нечего скачивать")
|
||||||
|
saved, skipped, failed = download_tracks(
|
||||||
|
self.api, tracks, destination, on_event=progress
|
||||||
|
)
|
||||||
|
return saved, skipped, failed, destination
|
||||||
|
|
||||||
|
def on_download_progress(self, event: dict) -> None:
|
||||||
|
track = event["track"]
|
||||||
|
self.download_progress.setRange(0, event["total"])
|
||||||
|
self.download_progress.setValue(event["index"] - 1)
|
||||||
|
title = f"{track_artist(track)} — {track.get('title') or '?'}"
|
||||||
|
self.download_status.setText(
|
||||||
|
f"[{event['index']}/{event['total']}] {title}")
|
||||||
|
|
||||||
|
def download_done(self, result: tuple) -> None:
|
||||||
|
saved, skipped, failed, destination = result
|
||||||
|
self.download_btn.setEnabled(True)
|
||||||
|
self.download_progress.setRange(0, 1)
|
||||||
|
self.download_progress.setValue(1)
|
||||||
|
self.set_top_status(self.queue_name)
|
||||||
|
message = (
|
||||||
|
f"Готово: {saved} сохранено, {skipped} уже было, "
|
||||||
|
f"{failed} ошибок"
|
||||||
|
)
|
||||||
|
self.download_status.setText(f"{message}\n{destination}")
|
||||||
|
self.toast(message)
|
||||||
|
|
||||||
|
def download_failed(self, message: str) -> None:
|
||||||
|
self.download_btn.setEnabled(True)
|
||||||
|
self.download_progress.setRange(0, 1)
|
||||||
|
self.download_progress.setValue(0)
|
||||||
|
self.download_status.setText(f"Ошибка: {message}")
|
||||||
|
self.toast(message)
|
||||||
|
|
||||||
# -- настройки --------------------------------------------------------
|
# -- настройки --------------------------------------------------------
|
||||||
|
|
||||||
def update_setting(self, key: str, value) -> None:
|
def update_setting(self, key: str, value) -> None:
|
||||||
@@ -892,6 +1231,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.volume_value.setText(f"{value} %")
|
self.volume_value.setText(f"{value} %")
|
||||||
self.audio.setVolume(value / 100)
|
self.audio.setVolume(value / 100)
|
||||||
self.update_setting("volume", value)
|
self.update_setting("volume", value)
|
||||||
|
if getattr(self, "mpris", None):
|
||||||
|
self.mpris.update_volume(value / 100)
|
||||||
|
|
||||||
def on_device_changed(self, index: int) -> None:
|
def on_device_changed(self, index: int) -> None:
|
||||||
device = self.device_combo.itemData(index)
|
device = self.device_combo.itemData(index)
|
||||||
@@ -911,6 +1252,58 @@ class QtMusicApp(QMainWindow):
|
|||||||
def on_wave_from_history_toggled(self, checked: bool) -> None:
|
def on_wave_from_history_toggled(self, checked: bool) -> None:
|
||||||
self.update_setting("wave_from_history", checked)
|
self.update_setting("wave_from_history", checked)
|
||||||
|
|
||||||
|
def choose_download_dir(self) -> None:
|
||||||
|
current = str(self.settings.get("download_dir") or "")
|
||||||
|
chosen = QFileDialog.getExistingDirectory(
|
||||||
|
self, "Папка загрузок", current)
|
||||||
|
if not chosen:
|
||||||
|
return
|
||||||
|
self.update_setting("download_dir", chosen)
|
||||||
|
self.download_dir_edit.setText(chosen)
|
||||||
|
self.toast(f"Папка загрузок: {chosen}")
|
||||||
|
|
||||||
|
# -- быстрое скачивание из меню ---------------------------------------
|
||||||
|
|
||||||
|
def download_current(self) -> None:
|
||||||
|
self.close_drawer()
|
||||||
|
if not self.current:
|
||||||
|
self.toast("Сначала выбери трек")
|
||||||
|
return
|
||||||
|
self._quick_download([self.current])
|
||||||
|
|
||||||
|
def download_queue(self) -> None:
|
||||||
|
self.close_drawer()
|
||||||
|
tracks = self.queue or ([self.current] if self.current else [])
|
||||||
|
if not tracks:
|
||||||
|
self.toast("Список пуст")
|
||||||
|
return
|
||||||
|
self._quick_download(list(tracks))
|
||||||
|
|
||||||
|
def _quick_download(self, tracks: list[dict]) -> None:
|
||||||
|
dest = str(self.settings.get("download_dir") or "")
|
||||||
|
if not dest:
|
||||||
|
self.toast("Укажи папку загрузок в настройках")
|
||||||
|
return
|
||||||
|
destination = Path(dest).expanduser()
|
||||||
|
self.download_btn.setEnabled(False)
|
||||||
|
self.download_progress.setRange(0, 0)
|
||||||
|
self.download_status.setText("Готовлю список…")
|
||||||
|
self.set_top_status("Скачиваю…")
|
||||||
|
self.run_progress_task(
|
||||||
|
lambda progress: self._download_tracks_job(
|
||||||
|
tracks, destination, progress),
|
||||||
|
self.download_done,
|
||||||
|
self.download_failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _download_tracks_job(
|
||||||
|
self, tracks: list[dict], destination: Path, progress
|
||||||
|
) -> tuple:
|
||||||
|
saved, skipped, failed = download_tracks(
|
||||||
|
self.api, tracks, destination, on_event=progress
|
||||||
|
)
|
||||||
|
return saved, skipped, failed, destination
|
||||||
|
|
||||||
def show_settings(self) -> None:
|
def show_settings(self) -> None:
|
||||||
self._show(self.settings_page)
|
self._show(self.settings_page)
|
||||||
|
|
||||||
@@ -938,7 +1331,10 @@ class QtMusicApp(QMainWindow):
|
|||||||
("Избранное", self.show_favorites),
|
("Избранное", self.show_favorites),
|
||||||
("История", self.show_history),
|
("История", self.show_history),
|
||||||
("Поиск", self.show_search),
|
("Поиск", self.show_search),
|
||||||
|
("Скачать", self.show_download),
|
||||||
("Текст песни", self.show_lyrics),
|
("Текст песни", self.show_lyrics),
|
||||||
|
("Скачать трек", self.download_current),
|
||||||
|
("Скачать список", self.download_queue),
|
||||||
("Настройки", self.show_settings),
|
("Настройки", self.show_settings),
|
||||||
):
|
):
|
||||||
button = QPushButton(text)
|
button = QPushButton(text)
|
||||||
@@ -1059,6 +1455,16 @@ class QtMusicApp(QMainWindow):
|
|||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
self.pool.start(task)
|
self.pool.start(task)
|
||||||
|
|
||||||
|
def run_progress_task(self, fn, done, failed) -> None:
|
||||||
|
task = ProgressTask(fn)
|
||||||
|
task.done_cb = done
|
||||||
|
task.failed_cb = failed
|
||||||
|
task.signals.done.connect(self._task_done)
|
||||||
|
task.signals.failed.connect(self._task_failed)
|
||||||
|
task.signals.progress.connect(self.on_download_progress)
|
||||||
|
self._tasks.add(task)
|
||||||
|
self.pool.start(task)
|
||||||
|
|
||||||
def _task_done(self, task: Task, result) -> None:
|
def _task_done(self, task: Task, result) -> None:
|
||||||
if task not in self._tasks:
|
if task not in self._tasks:
|
||||||
return
|
return
|
||||||
@@ -1079,7 +1485,11 @@ 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], list[dict], 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)
|
||||||
# Продолжаем волну с последних прослушанных треков, а не с начала.
|
# Продолжаем волну с последних прослушанных треков, а не с начала.
|
||||||
@@ -1102,7 +1512,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
liked = {str(track.get("id")) for track in start}
|
liked = {str(track.get("id")) for track in start}
|
||||||
else:
|
else:
|
||||||
payload = api.get(f"/users/{uid}/likes/tracks")
|
payload = api.get(f"/users/{uid}/likes/tracks")
|
||||||
refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or []
|
refs = ((payload.get("result") or {}).get(
|
||||||
|
"library") or {}).get("tracks") or []
|
||||||
liked = {str(ref.get("id")) for ref in refs}
|
liked = {str(ref.get("id")) for ref in refs}
|
||||||
if want == "history":
|
if want == "history":
|
||||||
start, name = list(self.history), "История"
|
start, name = list(self.history), "История"
|
||||||
@@ -1142,18 +1553,23 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.duration_label.setText(fmt_millis(duration))
|
self.duration_label.setText(fmt_millis(duration))
|
||||||
self.update_like_button()
|
self.update_like_button()
|
||||||
self.update_play_button()
|
self.update_play_button()
|
||||||
|
self._update_mpris_metadata()
|
||||||
|
|
||||||
def update_like_button(self, liked: bool | None = None) -> None:
|
def update_like_button(self, liked: bool | None = None) -> None:
|
||||||
if liked is None:
|
if liked is None:
|
||||||
liked = bool(self.current and str(self.current.get("id")) in self.liked_ids)
|
liked = bool(
|
||||||
|
self.current and str(
|
||||||
|
self.current.get("id")) in self.liked_ids)
|
||||||
self.like_btn.setText("♥ В избранном" if liked else "♡ В избранное")
|
self.like_btn.setText("♥ В избранном" if liked else "♡ В избранное")
|
||||||
self.like_btn.setProperty("liked", "true" if liked else "false")
|
self.like_btn.setProperty("liked", "true" if liked else "false")
|
||||||
self.like_btn.style().unpolish(self.like_btn)
|
self.like_btn.style().unpolish(self.like_btn)
|
||||||
self.like_btn.style().polish(self.like_btn)
|
self.like_btn.style().polish(self.like_btn)
|
||||||
|
|
||||||
def update_play_button(self, *args) -> None:
|
def update_play_button(self, *args) -> None:
|
||||||
playing = self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
|
state = self.player.playbackState()
|
||||||
|
playing = state == 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))
|
||||||
|
self._update_mpris_status()
|
||||||
|
|
||||||
# -- обложка ----------------------------------------------------------
|
# -- обложка ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -1169,7 +1585,9 @@ class QtMusicApp(QMainWindow):
|
|||||||
return
|
return
|
||||||
self.cover.set_pixmap(None)
|
self.cover.set_pixmap(None)
|
||||||
request = QNetworkRequest(QUrl(url))
|
request = QNetworkRequest(QUrl(url))
|
||||||
request.setHeader(QNetworkRequest.KnownHeaders.UserAgentHeader, USER_AGENT)
|
request.setHeader(
|
||||||
|
QNetworkRequest.KnownHeaders.UserAgentHeader,
|
||||||
|
USER_AGENT)
|
||||||
reply = self.covers.get(request)
|
reply = self.covers.get(request)
|
||||||
|
|
||||||
def done() -> None:
|
def done() -> None:
|
||||||
@@ -1190,7 +1608,10 @@ class QtMusicApp(QMainWindow):
|
|||||||
|
|
||||||
# -- воспроизведение --------------------------------------------------
|
# -- воспроизведение --------------------------------------------------
|
||||||
|
|
||||||
def play_from_list(self, widget: QListWidget, item: QListWidgetItem) -> None:
|
def play_from_list(
|
||||||
|
self,
|
||||||
|
widget: QListWidget,
|
||||||
|
item: QListWidgetItem) -> None:
|
||||||
tracks = getattr(widget, "_tracks", [])
|
tracks = getattr(widget, "_tracks", [])
|
||||||
index = widget.row(item)
|
index = widget.row(item)
|
||||||
if not (0 <= index < len(tracks)):
|
if not (0 <= index < len(tracks)):
|
||||||
@@ -1226,7 +1647,7 @@ class QtMusicApp(QMainWindow):
|
|||||||
self.set_top_status("Загрузка…")
|
self.set_top_status("Загрузка…")
|
||||||
|
|
||||||
self.run_task(
|
self.run_task(
|
||||||
lambda: resolve_stream(self.api, str(track.get("id"))),
|
lambda: fetch_track_file(self.api, str(track.get("id"))),
|
||||||
lambda result: self.start_playback(token, track, result),
|
lambda result: self.start_playback(token, track, result),
|
||||||
self.on_error,
|
self.on_error,
|
||||||
)
|
)
|
||||||
@@ -1236,9 +1657,9 @@ class QtMusicApp(QMainWindow):
|
|||||||
) -> None:
|
) -> None:
|
||||||
if token != self.play_token:
|
if token != self.play_token:
|
||||||
return
|
return
|
||||||
url, item = result
|
path, item = result
|
||||||
self._preview = bool(item.get("preview"))
|
self._preview = bool(item.get("preview"))
|
||||||
self.player.setSource(QUrl(url))
|
self.player.setSource(QUrl.fromLocalFile(str(path)))
|
||||||
self.player.play()
|
self.player.play()
|
||||||
self.history = record_history(track)
|
self.history = record_history(track)
|
||||||
title = str(track.get("title") or "")
|
title = str(track.get("title") or "")
|
||||||
@@ -1266,6 +1687,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
if duration > 1:
|
if duration > 1:
|
||||||
self.slider.setValue(min(position, duration))
|
self.slider.setValue(min(position, duration))
|
||||||
self.elapsed_label.setText(fmt_millis(position))
|
self.elapsed_label.setText(fmt_millis(position))
|
||||||
|
if getattr(self, "mpris", None):
|
||||||
|
self.mpris.update_position(int(position) * 1000)
|
||||||
|
|
||||||
def on_player_duration(self, duration: int) -> None:
|
def on_player_duration(self, duration: int) -> None:
|
||||||
track_duration = int((self.current or {}).get("durationMs") or 0)
|
track_duration = int((self.current or {}).get("durationMs") or 0)
|
||||||
@@ -1286,22 +1709,115 @@ class QtMusicApp(QMainWindow):
|
|||||||
self._seeking = False
|
self._seeking = False
|
||||||
self.player.setPosition(value)
|
self.player.setPosition(value)
|
||||||
self.elapsed_label.setText(fmt_millis(value))
|
self.elapsed_label.setText(fmt_millis(value))
|
||||||
|
if getattr(self, "mpris", None):
|
||||||
|
self.mpris.notify_seeked(value * 1000)
|
||||||
|
|
||||||
def on_slider_action(self, action: int) -> None:
|
def on_slider_action(self, action: int) -> None:
|
||||||
# Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает ClickSlider).
|
# Колесо и стрелки, когда слайдер в фокусе (клик обрабатывает
|
||||||
|
# ClickSlider).
|
||||||
if self.slider.maximum() > 0:
|
if self.slider.maximum() > 0:
|
||||||
value = self.slider.value()
|
value = self.slider.value()
|
||||||
self.player.setPosition(value)
|
self.player.setPosition(value)
|
||||||
self.elapsed_label.setText(fmt_millis(value))
|
self.elapsed_label.setText(fmt_millis(value))
|
||||||
|
if getattr(self, "mpris", None):
|
||||||
|
self.mpris.notify_seeked(value * 1000)
|
||||||
|
|
||||||
def seek(self, delta_ms: int) -> None:
|
def seek(self, delta_ms: int) -> None:
|
||||||
if self.player.source().isEmpty():
|
if self.player.source().isEmpty():
|
||||||
return
|
return
|
||||||
duration = self.slider.maximum()
|
duration = self.slider.maximum()
|
||||||
position = max(0, min(self.player.position() + delta_ms, max(0, duration - 500)))
|
position = max(0, min(self.player.position() +
|
||||||
|
delta_ms, max(0, duration - 500)))
|
||||||
self.player.setPosition(position)
|
self.player.setPosition(position)
|
||||||
self.slider.setValue(position)
|
self.slider.setValue(position)
|
||||||
self.elapsed_label.setText(fmt_millis(position))
|
self.elapsed_label.setText(fmt_millis(position))
|
||||||
|
if getattr(self, "mpris", None):
|
||||||
|
self.mpris.notify_seeked(position * 1000)
|
||||||
|
|
||||||
|
# -- MPRIS ------------------------------------------------------------
|
||||||
|
|
||||||
|
def mpris_play(self) -> None:
|
||||||
|
state = self.player.playbackState()
|
||||||
|
if state != QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self.toggle_play()
|
||||||
|
|
||||||
|
def mpris_pause(self) -> None:
|
||||||
|
state = self.player.playbackState()
|
||||||
|
if state == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self.toggle_play()
|
||||||
|
|
||||||
|
def mpris_stop(self) -> None:
|
||||||
|
self.player.stop()
|
||||||
|
self.slider.setValue(0)
|
||||||
|
self.elapsed_label.setText("0:00")
|
||||||
|
self.update_play_button()
|
||||||
|
|
||||||
|
def mpris_seek(self, offset_us: int) -> None:
|
||||||
|
if self.player.source().isEmpty():
|
||||||
|
return
|
||||||
|
duration = self.slider.maximum()
|
||||||
|
position = max(0, min(self.player.position() +
|
||||||
|
offset_us // 1000, max(0, duration - 500)))
|
||||||
|
self.player.setPosition(position)
|
||||||
|
self.slider.setValue(position)
|
||||||
|
self.elapsed_label.setText(fmt_millis(position))
|
||||||
|
self.mpris.notify_seeked(position * 1000)
|
||||||
|
|
||||||
|
def mpris_set_position(self, track_path: str, position_us: int) -> None:
|
||||||
|
if not self.current:
|
||||||
|
return
|
||||||
|
if track_object_path(self.current.get("id")) != track_path:
|
||||||
|
return
|
||||||
|
position = max(0, position_us // 1000)
|
||||||
|
self.player.setPosition(position)
|
||||||
|
self.slider.setValue(position)
|
||||||
|
self.elapsed_label.setText(fmt_millis(position))
|
||||||
|
self.mpris.update_position(position_us)
|
||||||
|
|
||||||
|
def mpris_set_volume(self, value: float) -> None:
|
||||||
|
value = max(0.0, min(1.0, value))
|
||||||
|
self.audio.setVolume(value)
|
||||||
|
self.volume_slider.setValue(int(round(value * 100)))
|
||||||
|
|
||||||
|
def mpris_raise(self) -> None:
|
||||||
|
self.showNormal()
|
||||||
|
self.raise_()
|
||||||
|
self.activateWindow()
|
||||||
|
|
||||||
|
def _update_mpris_status(self) -> None:
|
||||||
|
if not getattr(self, "mpris", None):
|
||||||
|
return
|
||||||
|
state = self.player.playbackState()
|
||||||
|
if state == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
status = "Playing"
|
||||||
|
elif state == QMediaPlayer.PlaybackState.PausedState:
|
||||||
|
status = "Paused"
|
||||||
|
else:
|
||||||
|
status = "Stopped"
|
||||||
|
self.mpris.update_playback_status(status)
|
||||||
|
|
||||||
|
def _update_mpris_metadata(self) -> None:
|
||||||
|
if not getattr(self, "mpris", None):
|
||||||
|
return
|
||||||
|
track = self.current
|
||||||
|
if not track:
|
||||||
|
self.mpris.clear_metadata()
|
||||||
|
self.mpris.update_capabilities(
|
||||||
|
can_next=False, can_previous=False, can_seek=False
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.mpris.update_metadata(
|
||||||
|
track.get("id"),
|
||||||
|
title=str(track.get("title") or ""),
|
||||||
|
artist=track_artist(track),
|
||||||
|
album=track_album(track),
|
||||||
|
duration_ms=int(track.get("durationMs") or 0),
|
||||||
|
art_url=track_cover_url(track) or "",
|
||||||
|
)
|
||||||
|
has_queue = bool(self.queue or self.wave)
|
||||||
|
self.mpris.update_capabilities(
|
||||||
|
can_next=has_queue, can_previous=has_queue, can_seek=True
|
||||||
|
)
|
||||||
|
|
||||||
def advance(self, direction: int) -> None:
|
def advance(self, direction: int) -> None:
|
||||||
queue = self.queue or self.wave
|
queue = self.queue or self.wave
|
||||||
@@ -1329,13 +1845,18 @@ class QtMusicApp(QMainWindow):
|
|||||||
if item.get("type") == "track" and item.get("track")
|
if item.get("type") == "track" and item.get("track")
|
||||||
]
|
]
|
||||||
|
|
||||||
self.run_task(fetch, lambda tracks: self.apply_more_wave(tracks, then_play), self.on_error)
|
self.run_task(
|
||||||
|
fetch, lambda tracks: self.apply_more_wave(
|
||||||
|
tracks, then_play), self.on_error)
|
||||||
|
|
||||||
def apply_more_wave(self, tracks: list[dict], then_play: int) -> None:
|
def apply_more_wave(self, tracks: list[dict], then_play: int) -> None:
|
||||||
known = {str(track.get("id")) for track in self.wave}
|
known = {str(track.get("id")) for track in self.wave}
|
||||||
added = [track for track in tracks if str(track.get("id")) not in known]
|
added = [
|
||||||
|
track for track in tracks if str(
|
||||||
|
track.get("id")) not in known]
|
||||||
self.wave.extend(added)
|
self.wave.extend(added)
|
||||||
self.set_top_status(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}")
|
self.set_top_status(
|
||||||
|
f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}")
|
||||||
if self.queue_name == "Моя волна":
|
if self.queue_name == "Моя волна":
|
||||||
self.queue = list(self.wave)
|
self.queue = list(self.wave)
|
||||||
if then_play < len(self.queue) and added:
|
if then_play < len(self.queue) and added:
|
||||||
@@ -1374,7 +1895,12 @@ class QtMusicApp(QMainWindow):
|
|||||||
)
|
)
|
||||||
return track_id
|
return track_id
|
||||||
|
|
||||||
self.run_task(request, lambda _: self.apply_like(track, not liked), self.like_failed)
|
self.run_task(
|
||||||
|
request,
|
||||||
|
lambda _: self.apply_like(
|
||||||
|
track,
|
||||||
|
not liked),
|
||||||
|
self.like_failed)
|
||||||
|
|
||||||
def apply_like(self, track: dict, liked: bool) -> None:
|
def apply_like(self, track: dict, liked: bool) -> None:
|
||||||
track_id = str(track.get("id"))
|
track_id = str(track.get("id"))
|
||||||
@@ -1386,7 +1912,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
if self.current and str(self.current.get("id")) == track_id:
|
if self.current and str(self.current.get("id")) == track_id:
|
||||||
self.update_like_button(liked)
|
self.update_like_button(liked)
|
||||||
self.set_top_status(f"{self.queue_name} · ♥ {len(self.liked_ids)}")
|
self.set_top_status(f"{self.queue_name} · ♥ {len(self.liked_ids)}")
|
||||||
self.toast("♥ Добавлено в избранное" if liked else "♡ Убрано из избранного")
|
self.toast(
|
||||||
|
"♥ Добавлено в избранное" if liked else "♡ Убрано из избранного")
|
||||||
if self.pages.currentWidget() is self.favorites_page:
|
if self.pages.currentWidget() is self.favorites_page:
|
||||||
self.load_favorites(force=True)
|
self.load_favorites(force=True)
|
||||||
|
|
||||||
@@ -1502,14 +2029,20 @@ class QtMusicApp(QMainWindow):
|
|||||||
if self._favorites_loading:
|
if self._favorites_loading:
|
||||||
return
|
return
|
||||||
if self.favorites and not force:
|
if self.favorites and not force:
|
||||||
self.fill_track_list(self.favorites_list, self.favorites, "Избранное")
|
self.fill_track_list(
|
||||||
|
self.favorites_list,
|
||||||
|
self.favorites,
|
||||||
|
"Избранное")
|
||||||
self.favorites_status.setText(f"{len(self.favorites)} трек(ов)")
|
self.favorites_status.setText(f"{len(self.favorites)} трек(ов)")
|
||||||
return
|
return
|
||||||
self._favorites_loading = True
|
self._favorites_loading = True
|
||||||
self.favorites_status.setText("Загружаю…")
|
self.favorites_status.setText("Загружаю…")
|
||||||
if not self.favorites:
|
if not self.favorites:
|
||||||
self.favorites_list.clear()
|
self.favorites_list.clear()
|
||||||
self.run_task(self._fetch_favorites, self.apply_favorites, self.favorites_failed)
|
self.run_task(
|
||||||
|
self._fetch_favorites,
|
||||||
|
self.apply_favorites,
|
||||||
|
self.favorites_failed)
|
||||||
|
|
||||||
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)
|
||||||
@@ -1543,14 +2076,13 @@ class QtMusicApp(QMainWindow):
|
|||||||
**{"page-size": 25},
|
**{"page-size": 25},
|
||||||
nocorrect="true",
|
nocorrect="true",
|
||||||
)
|
)
|
||||||
return (
|
return (((payload.get("result") or {}).get(
|
||||||
((payload.get("result") or {}).get("tracks") or {}).get("results")
|
"tracks") or {}).get("results") or [])
|
||||||
or []
|
|
||||||
)
|
|
||||||
|
|
||||||
self.run_task(search, self.apply_search, self.search_failed)
|
self.run_task(search, self.apply_search, self.search_failed)
|
||||||
|
|
||||||
def apply_search(self, tracks: list[dict]) -> None:
|
def apply_search(self, tracks: list[dict]) -> None:
|
||||||
|
self.search_results = tracks
|
||||||
self.fill_track_list(self.search_list, tracks, "Поиск")
|
self.fill_track_list(self.search_list, tracks, "Поиск")
|
||||||
self.search_status.setText(
|
self.search_status.setText(
|
||||||
f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено"
|
f"{len(tracks)} результат(ов)" if tracks else "Ничего не найдено"
|
||||||
@@ -1629,6 +2161,8 @@ class QtMusicApp(QMainWindow):
|
|||||||
|
|
||||||
def closeEvent(self, event) -> None: # noqa: N802
|
def closeEvent(self, event) -> None: # noqa: N802
|
||||||
self.player.stop()
|
self.player.stop()
|
||||||
|
if getattr(self, "mpris", None):
|
||||||
|
self.mpris.close()
|
||||||
self.pool.clear()
|
self.pool.clear()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ playwright>=1.40
|
|||||||
requests>=2.31
|
requests>=2.31
|
||||||
textual>=0.80
|
textual>=0.80
|
||||||
pillow>=10
|
pillow>=10
|
||||||
|
dbus-next>=0.2.3
|
||||||
|
mutagen>=1.47
|
||||||
|
|||||||
11
settings.py
11
settings.py
@@ -10,12 +10,15 @@ from mtsmusic import CONFIG_DIR
|
|||||||
|
|
||||||
SETTINGS_PATH = CONFIG_DIR / "settings.json"
|
SETTINGS_PATH = CONFIG_DIR / "settings.json"
|
||||||
|
|
||||||
|
DEFAULT_DOWNLOAD_DIR = str(Path.home() / "Music" / "MTS Music")
|
||||||
|
|
||||||
DEFAULTS: dict[str, Any] = {
|
DEFAULTS: dict[str, Any] = {
|
||||||
"volume": 80, # громкость 0..100
|
"volume": 80, # громкость 0..100
|
||||||
"seek_step": 5, # шаг перемотки стрелками, секунды
|
"seek_step": 5, # шаг перемотки стрелками, секунды
|
||||||
"autoplay": False, # запускать воспроизведение сразу
|
"autoplay": False, # запускать воспроизведение сразу
|
||||||
"start_queue": "wave", # wave | favorites | history
|
"start_queue": "wave", # wave | favorites | history
|
||||||
"wave_from_history": True, # продолжать волну с последних треков
|
"wave_from_history": True, # продолжать волну с последних треков
|
||||||
|
"download_dir": DEFAULT_DOWNLOAD_DIR, # куда сохранять скачанные MP3
|
||||||
}
|
}
|
||||||
|
|
||||||
START_QUEUES = ("wave", "favorites", "history")
|
START_QUEUES = ("wave", "favorites", "history")
|
||||||
@@ -42,10 +45,16 @@ def _coerce(key: str, value: Any) -> Any:
|
|||||||
if key in ("autoplay", "wave_from_history"):
|
if key in ("autoplay", "wave_from_history"):
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
return str(value).strip().lower() in ("1", "true", "yes", "on", "да", "вкл")
|
return str(value).strip().lower() in (
|
||||||
|
"1", "true", "yes", "on", "да", "вкл")
|
||||||
if key == "start_queue":
|
if key == "start_queue":
|
||||||
text = str(value)
|
text = str(value)
|
||||||
return text if text in START_QUEUES else DEFAULTS[key]
|
return text if text in START_QUEUES else DEFAULTS[key]
|
||||||
|
if key == "download_dir":
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return DEFAULTS[key]
|
||||||
|
return str(Path(text).expanduser())
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
239
tags.py
Normal file
239
tags.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""ID3-теги для скачанных треков: максимум метаданных.
|
||||||
|
|
||||||
|
Пишем ID3v2.3: название, исполнителей, альбом и альбомного исполнителя,
|
||||||
|
позицию трека и диска, год и дату, жанр, лейбл, длительность, ссылку,
|
||||||
|
служебные поля (id в Яндекс.Музыке и MTS, explicit), обложку 600×600 и
|
||||||
|
текст песни (обычный USLT и синхронный SYLT, если он есть).
|
||||||
|
|
||||||
|
Модуль необязательный: без `mutagen` теги просто не пишутся.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from media import track_cover_url
|
||||||
|
from mtsmusic import (
|
||||||
|
USER_AGENT,
|
||||||
|
force_ipv4,
|
||||||
|
lrclib_lyrics,
|
||||||
|
lyrics_response,
|
||||||
|
lyrics_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mutagen.id3 import (
|
||||||
|
APIC,
|
||||||
|
COMM,
|
||||||
|
SYLT,
|
||||||
|
TALB,
|
||||||
|
TCON,
|
||||||
|
TDAT,
|
||||||
|
TIT2,
|
||||||
|
TLEN,
|
||||||
|
TPE1,
|
||||||
|
TPE2,
|
||||||
|
TPOS,
|
||||||
|
TPUB,
|
||||||
|
TRCK,
|
||||||
|
TXXX,
|
||||||
|
TYER,
|
||||||
|
USLT,
|
||||||
|
WOAR,
|
||||||
|
ID3,
|
||||||
|
)
|
||||||
|
|
||||||
|
HAVE_MUTAGEN = True
|
||||||
|
except Exception: # pragma: no cover - зависит от окружения
|
||||||
|
HAVE_MUTAGEN = False
|
||||||
|
|
||||||
|
log = logging.getLogger("tags")
|
||||||
|
|
||||||
|
COVER_SIZE = 600
|
||||||
|
_LRC_LINE = re.compile(r"\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]")
|
||||||
|
|
||||||
|
|
||||||
|
def _names(items) -> str:
|
||||||
|
return ", ".join(str(item.get("name") or "") for item in items or [])
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_cover(track: dict) -> bytes | None:
|
||||||
|
"""Скачивает обложку 600×600 для встраивания в APIC."""
|
||||||
|
url = track_cover_url(track, COVER_SIZE)
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
force_ipv4()
|
||||||
|
response = requests.get(
|
||||||
|
url, headers={"User-Agent": USER_AGENT}, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
except requests.RequestException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mts_lyrics(api, track_id: str, fmt: str) -> str:
|
||||||
|
try:
|
||||||
|
response = lyrics_response(api, track_id, fmt)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if response.status_code != 200:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
text = lyrics_text(api, response)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if "Подключите подписку" in text:
|
||||||
|
return ""
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_lyrics(api, track: dict) -> tuple[str, str]:
|
||||||
|
"""(обычный текст, синхронный LRC). Пробуем MTS, затем LRCLIB."""
|
||||||
|
track_id = str(track.get("id"))
|
||||||
|
plain = _mts_lyrics(api, track_id, "TEXT")
|
||||||
|
synced = _mts_lyrics(api, track_id, "LRC")
|
||||||
|
if not plain:
|
||||||
|
plain = (lrclib_lyrics(track, want_sync=False) or "").strip()
|
||||||
|
if not synced:
|
||||||
|
synced = (lrclib_lyrics(track, want_sync=True) or "").strip()
|
||||||
|
if synced and not _LRC_LINE.search(synced):
|
||||||
|
synced = ""
|
||||||
|
return plain, synced
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lrc(text: str) -> list[tuple[str, int]]:
|
||||||
|
"""LRC → [(строка, миллисекунды)] для синхронного SYLT."""
|
||||||
|
entries: list[tuple[str, int]] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
stamps = list(_LRC_LINE.finditer(line))
|
||||||
|
if not stamps:
|
||||||
|
continue
|
||||||
|
content = _LRC_LINE.sub("", line).strip()
|
||||||
|
for stamp in stamps:
|
||||||
|
minutes = int(stamp.group(1))
|
||||||
|
seconds = int(stamp.group(2))
|
||||||
|
fraction = (stamp.group(3) or "0").ljust(3, "0")[:3]
|
||||||
|
millis = (minutes * 60 + seconds) * 1000 + int(fraction)
|
||||||
|
entries.append((content, millis))
|
||||||
|
entries.sort(key=lambda item: item[1])
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_mime(data: bytes) -> str:
|
||||||
|
return "image/png" if data[:8].startswith(b"\x89PNG") else "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def write_tags(
|
||||||
|
track: dict,
|
||||||
|
path: Path,
|
||||||
|
plain_lyrics: str = "",
|
||||||
|
synced_lyrics: str = "",
|
||||||
|
cover: bytes | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Проставляет ID3v2.3-теги в уже скачанном файле."""
|
||||||
|
if not HAVE_MUTAGEN:
|
||||||
|
return
|
||||||
|
album = (track.get("albums") or [{}])[0]
|
||||||
|
artist = _names(track.get("artists"))
|
||||||
|
album_artist = _names(album.get("artists")) or artist
|
||||||
|
track_id = str(track.get("id") or "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tags = ID3(path)
|
||||||
|
except Exception:
|
||||||
|
tags = ID3()
|
||||||
|
|
||||||
|
def add(frame) -> None:
|
||||||
|
tags.add(frame)
|
||||||
|
|
||||||
|
if track.get("title"):
|
||||||
|
add(TIT2(encoding=3, text=str(track["title"])))
|
||||||
|
if artist:
|
||||||
|
add(TPE1(encoding=3, text=artist))
|
||||||
|
if album_artist:
|
||||||
|
add(TPE2(encoding=3, text=album_artist))
|
||||||
|
if album.get("title"):
|
||||||
|
add(TALB(encoding=3, text=str(album["title"])))
|
||||||
|
|
||||||
|
position = album.get("trackPosition") or {}
|
||||||
|
index = position.get("index")
|
||||||
|
if index:
|
||||||
|
count = album.get("trackCount")
|
||||||
|
add(TRCK(encoding=3, text=f"{index}/{count}" if count else str(index)))
|
||||||
|
volume = position.get("volume")
|
||||||
|
if volume:
|
||||||
|
add(TPOS(encoding=3, text=str(volume)))
|
||||||
|
|
||||||
|
if album.get("year"):
|
||||||
|
add(TYER(encoding=3, text=str(album["year"])))
|
||||||
|
release = str(album.get("releaseDate") or "")
|
||||||
|
if release[:10].count("-") == 2:
|
||||||
|
_year, month, day = release[:10].split("-")
|
||||||
|
add(TDAT(encoding=3, text=f"{day}{month}")) # формат DDMM
|
||||||
|
|
||||||
|
if album.get("genre"):
|
||||||
|
add(TCON(encoding=3, text=str(album["genre"])))
|
||||||
|
labels = _names(album.get("labels"))
|
||||||
|
if labels:
|
||||||
|
add(TPUB(encoding=3, text=labels))
|
||||||
|
if track.get("durationMs"):
|
||||||
|
add(TLEN(encoding=3, text=str(int(track["durationMs"]))))
|
||||||
|
|
||||||
|
if track_id:
|
||||||
|
add(COMM(
|
||||||
|
encoding=3,
|
||||||
|
lang="XXX",
|
||||||
|
desc="",
|
||||||
|
text=f"https://music.yandex.ru/track/{track_id}",
|
||||||
|
))
|
||||||
|
add(WOAR(url=f"https://music.yandex.ru/track/{track_id}"))
|
||||||
|
|
||||||
|
def add_text(desc: str, value) -> None:
|
||||||
|
if value not in (None, ""):
|
||||||
|
add(TXXX(encoding=3, desc=desc, text=str(value)))
|
||||||
|
|
||||||
|
add_text("YANDEX_MUSIC_TRACK_ID", track.get("id"))
|
||||||
|
add_text("YANDEX_MUSIC_REAL_ID", track.get("realId"))
|
||||||
|
add_text("YANDEX_MUSIC_ALBUM_ID", album.get("id"))
|
||||||
|
add_text("MTS_MUSIC_SOURCE", track.get("trackSource"))
|
||||||
|
add_text("YANDEX_MUSIC_TYPE", track.get("type"))
|
||||||
|
if track.get("contentWarning") == "explicit":
|
||||||
|
add_text("ITUNESADVISORY", 1)
|
||||||
|
|
||||||
|
if cover:
|
||||||
|
add(APIC(
|
||||||
|
encoding=3,
|
||||||
|
mime=_cover_mime(cover),
|
||||||
|
type=3,
|
||||||
|
desc="Cover",
|
||||||
|
data=cover,
|
||||||
|
))
|
||||||
|
if plain_lyrics:
|
||||||
|
add(USLT(encoding=3, lang="XXX", desc="", text=plain_lyrics))
|
||||||
|
if synced_lyrics:
|
||||||
|
entries = parse_lrc(synced_lyrics)
|
||||||
|
if entries:
|
||||||
|
add(SYLT(
|
||||||
|
encoding=3,
|
||||||
|
lang="XXX",
|
||||||
|
format=2,
|
||||||
|
type=1,
|
||||||
|
desc="",
|
||||||
|
text=entries,
|
||||||
|
))
|
||||||
|
|
||||||
|
tags.save(path, v2_version=3)
|
||||||
|
|
||||||
|
|
||||||
|
def tag_track(api, track: dict, path: Path) -> None:
|
||||||
|
"""Собирает метаданные (текст, обложка) и пишет их в файл."""
|
||||||
|
if not HAVE_MUTAGEN:
|
||||||
|
return
|
||||||
|
plain, synced = fetch_lyrics(api, track)
|
||||||
|
cover = fetch_cover(track)
|
||||||
|
write_tags(track, Path(path), plain, synced, cover)
|
||||||
628
tui.py
628
tui.py
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -34,15 +35,18 @@ from media import (
|
|||||||
from mtsmusic import (
|
from mtsmusic import (
|
||||||
USER_AGENT,
|
USER_AGENT,
|
||||||
Api,
|
Api,
|
||||||
|
collect_download_tracks,
|
||||||
|
download_tracks,
|
||||||
|
fetch_track_file,
|
||||||
fmt_duration,
|
fmt_duration,
|
||||||
force_ipv4,
|
force_ipv4,
|
||||||
get_uid,
|
get_uid,
|
||||||
lrclib_lyrics,
|
lrclib_lyrics,
|
||||||
lyrics_response,
|
lyrics_response,
|
||||||
lyrics_text,
|
lyrics_text,
|
||||||
resolve_stream,
|
|
||||||
)
|
)
|
||||||
from player import MusicPlayer
|
from player import MusicPlayer
|
||||||
|
from mpris import MprisService, track_object_path
|
||||||
from settings import (
|
from settings import (
|
||||||
START_QUEUE_LABELS,
|
START_QUEUE_LABELS,
|
||||||
START_QUEUES,
|
START_QUEUES,
|
||||||
@@ -114,7 +118,10 @@ def track_row(track: dict) -> tuple[str, str, str, str]:
|
|||||||
|
|
||||||
|
|
||||||
class ListScreen(Screen):
|
class ListScreen(Screen):
|
||||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Назад"),
|
||||||
|
Binding("d", "download", "Скачать"),
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, title: str, tracks: list[dict]) -> None:
|
def __init__(self, title: str, tracks: list[dict]) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -140,6 +147,11 @@ class ListScreen(Screen):
|
|||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_download(self) -> None:
|
||||||
|
index = self.query_one("#list-table", DataTable).cursor_row
|
||||||
|
if 0 <= index < len(self.tracks):
|
||||||
|
self.app.download_tracks([self.tracks[index]], "трек")
|
||||||
|
|
||||||
@on(Button.Pressed, "#back")
|
@on(Button.Pressed, "#back")
|
||||||
def back_pressed(self) -> None:
|
def back_pressed(self) -> None:
|
||||||
self.action_back()
|
self.action_back()
|
||||||
@@ -148,16 +160,19 @@ class ListScreen(Screen):
|
|||||||
def row_selected(self, event: DataTable.RowSelected) -> None:
|
def row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
track_id = str(event.row_key.value)
|
track_id = str(event.row_key.value)
|
||||||
index = next(
|
index = next(
|
||||||
(i for i, item in enumerate(self.tracks) if str(item.get("id")) == track_id),
|
(i for i, item in enumerate(
|
||||||
-1,
|
self.tracks) if str(
|
||||||
)
|
item.get("id")) == track_id), -1, )
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.app.play_from_list(self.tracks, index, self.list_title)
|
self.app.play_from_list(self.tracks, index, self.list_title)
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|
||||||
class FavoritesScreen(Screen):
|
class FavoritesScreen(Screen):
|
||||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Назад"),
|
||||||
|
Binding("d", "download", "Скачать"),
|
||||||
|
]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield make_topbar("Избранное")
|
yield make_topbar("Избранное")
|
||||||
@@ -172,6 +187,12 @@ class FavoritesScreen(Screen):
|
|||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_download(self) -> None:
|
||||||
|
index = self.query_one("#list-table", DataTable).cursor_row
|
||||||
|
tracks = self.app.favorites
|
||||||
|
if 0 <= index < len(tracks):
|
||||||
|
self.app.download_tracks([tracks[index]], "трек")
|
||||||
|
|
||||||
@on(Button.Pressed, "#back")
|
@on(Button.Pressed, "#back")
|
||||||
def back_pressed(self) -> None:
|
def back_pressed(self) -> None:
|
||||||
self.action_back()
|
self.action_back()
|
||||||
@@ -208,9 +229,8 @@ class FavoritesScreen(Screen):
|
|||||||
track_id = str(event.row_key.value)
|
track_id = str(event.row_key.value)
|
||||||
tracks = self.app.favorites
|
tracks = self.app.favorites
|
||||||
index = next(
|
index = next(
|
||||||
(i for i, item in enumerate(tracks) if str(item.get("id")) == track_id),
|
(i for i, item in enumerate(tracks) if str(
|
||||||
-1,
|
item.get("id")) == track_id), -1, )
|
||||||
)
|
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.app.play_from_list(tracks, index, "Избранное")
|
self.app.play_from_list(tracks, index, "Избранное")
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
@@ -222,7 +242,10 @@ class HistoryScreen(ListScreen):
|
|||||||
|
|
||||||
|
|
||||||
class SearchScreen(Screen):
|
class SearchScreen(Screen):
|
||||||
BINDINGS = [Binding("escape", "back", "Назад")]
|
BINDINGS = [
|
||||||
|
Binding("escape", "back", "Назад"),
|
||||||
|
Binding("d", "download", "Скачать"),
|
||||||
|
]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield make_topbar("Поиск")
|
yield make_topbar("Поиск")
|
||||||
@@ -241,6 +264,11 @@ class SearchScreen(Screen):
|
|||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_download(self) -> None:
|
||||||
|
index = self.query_one("#list-table", DataTable).cursor_row
|
||||||
|
if 0 <= index < len(self.results):
|
||||||
|
self.app.download_tracks([self.results[index]], "трек")
|
||||||
|
|
||||||
@on(Button.Pressed, "#back")
|
@on(Button.Pressed, "#back")
|
||||||
def back_pressed(self) -> None:
|
def back_pressed(self) -> None:
|
||||||
self.action_back()
|
self.action_back()
|
||||||
@@ -274,13 +302,15 @@ class SearchScreen(Screen):
|
|||||||
**{"page-size": 25},
|
**{"page-size": 25},
|
||||||
nocorrect="true",
|
nocorrect="true",
|
||||||
)
|
)
|
||||||
tracks = (((payload.get("result") or {}).get("tracks") or {}).get("results")) or []
|
tracks = (((payload.get("result") or {}).get(
|
||||||
|
"tracks") or {}).get("results")) 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))
|
||||||
|
|
||||||
def fill(self, tracks: list[dict]) -> None:
|
def fill(self, tracks: list[dict]) -> None:
|
||||||
self.results = tracks
|
self.results = tracks
|
||||||
|
self.app.search_results = tracks
|
||||||
table = self.query_one("#list-table", DataTable)
|
table = self.query_one("#list-table", DataTable)
|
||||||
table.clear()
|
table.clear()
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
@@ -296,9 +326,9 @@ class SearchScreen(Screen):
|
|||||||
def row_selected(self, event: DataTable.RowSelected) -> None:
|
def row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
track_id = str(event.row_key.value)
|
track_id = str(event.row_key.value)
|
||||||
index = next(
|
index = next(
|
||||||
(i for i, item in enumerate(self.results) if str(item.get("id")) == track_id),
|
(i for i, item in enumerate(
|
||||||
-1,
|
self.results) if str(
|
||||||
)
|
item.get("id")) == track_id), -1, )
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.app.play_from_list(self.results, index, "Поиск")
|
self.app.play_from_list(self.results, index, "Поиск")
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
@@ -384,27 +414,45 @@ class SettingsScreen(Screen):
|
|||||||
with Horizontal(classes="srow"):
|
with Horizontal(classes="srow"):
|
||||||
yield Static("Волна с последних треков", classes="slabel")
|
yield Static("Волна с последних треков", classes="slabel")
|
||||||
yield Button("", id="wave-toggle", classes="stoggle")
|
yield Button("", id="wave-toggle", classes="stoggle")
|
||||||
|
with Horizontal(classes="srow"):
|
||||||
|
yield Static("Папка загрузок", classes="slabel")
|
||||||
|
yield Button("Изменить", id="dir-edit", classes="stoggle")
|
||||||
|
yield Static("", id="dir-value")
|
||||||
yield Static("", id="settings-path")
|
yield Static("", id="settings-path")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.refresh_values()
|
self.refresh_values()
|
||||||
from settings import settings_path
|
from settings import settings_path
|
||||||
|
|
||||||
self.query_one("#settings-path", Static).update(f"Файл: {settings_path()}")
|
self.query_one(
|
||||||
|
"#settings-path",
|
||||||
|
Static).update(
|
||||||
|
f"Файл: {
|
||||||
|
settings_path()}")
|
||||||
|
|
||||||
def refresh_values(self) -> None:
|
def refresh_values(self) -> None:
|
||||||
s = self.app.settings
|
s = self.app.settings
|
||||||
self.query_one("#vol-value", Static).update(f"{s['volume']} %")
|
self.query_one("#vol-value", Static).update(f"{s['volume']} %")
|
||||||
self.query_one("#seek-value", Static).update(f"{s['seek_step']} с")
|
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(
|
||||||
self.query_one("#start-toggle", Button).label = START_QUEUE_LABELS[s["start_queue"]]
|
"#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 = (
|
self.query_one("#wave-toggle", Button).label = (
|
||||||
"вкл" if s["wave_from_history"] else "выкл"
|
"вкл" if s["wave_from_history"] else "выкл"
|
||||||
)
|
)
|
||||||
|
self.query_one("#dir-value", Static).update(
|
||||||
|
str(s.get("download_dir") or ""))
|
||||||
|
|
||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def dir_chosen(self, value: str | None) -> None:
|
||||||
|
if value:
|
||||||
|
self.app.update_setting("download_dir", value)
|
||||||
|
self.refresh_values()
|
||||||
|
|
||||||
@on(Button.Pressed)
|
@on(Button.Pressed)
|
||||||
def pressed(self, event: Button.Pressed) -> None:
|
def pressed(self, event: Button.Pressed) -> None:
|
||||||
bid = event.button.id or ""
|
bid = event.button.id or ""
|
||||||
@@ -425,12 +473,155 @@ class SettingsScreen(Screen):
|
|||||||
elif bid == "start-toggle":
|
elif bid == "start-toggle":
|
||||||
order = list(START_QUEUES)
|
order = list(START_QUEUES)
|
||||||
index = order.index(s["start_queue"])
|
index = order.index(s["start_queue"])
|
||||||
self.app.update_setting("start_queue", order[(index + 1) % len(order)])
|
self.app.update_setting(
|
||||||
|
"start_queue", order[(index + 1) % len(order)])
|
||||||
elif bid == "wave-toggle":
|
elif bid == "wave-toggle":
|
||||||
self.app.update_setting("wave_from_history", not s["wave_from_history"])
|
self.app.update_setting(
|
||||||
|
"wave_from_history",
|
||||||
|
not s["wave_from_history"])
|
||||||
|
elif bid == "dir-edit":
|
||||||
|
self.app.push_screen(
|
||||||
|
PathScreen(
|
||||||
|
str(s.get("download_dir") or ""),
|
||||||
|
"Папка загрузок",
|
||||||
|
),
|
||||||
|
self.dir_chosen)
|
||||||
|
return
|
||||||
self.refresh_values()
|
self.refresh_values()
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadScreen(Screen):
|
||||||
|
"""Скачивание текущего трека, избранного, волны или результатов поиска."""
|
||||||
|
|
||||||
|
BINDINGS = [Binding("escape", "back", "Назад")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield make_topbar("Скачать")
|
||||||
|
yield Static("Папка для сохранения:", id="screen-status")
|
||||||
|
yield Input(
|
||||||
|
value=str(
|
||||||
|
self.app.settings.get("download_dir")
|
||||||
|
or Path.home() / "Music" / "MTS"
|
||||||
|
),
|
||||||
|
id="download-dir",
|
||||||
|
)
|
||||||
|
with Horizontal(id="download-actions"):
|
||||||
|
yield Button("Текущий трек", id="dl-current")
|
||||||
|
yield Button("Избранное", id="dl-likes")
|
||||||
|
yield Button("Моя волна (20)", id="dl-wave")
|
||||||
|
yield Button(
|
||||||
|
"Результаты поиска",
|
||||||
|
id="dl-search",
|
||||||
|
disabled=not self.app.search_results,
|
||||||
|
)
|
||||||
|
yield Static("", id="download-status")
|
||||||
|
yield ProgressBar(
|
||||||
|
total=100,
|
||||||
|
show_eta=False,
|
||||||
|
show_percentage=True,
|
||||||
|
id="download-progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#back")
|
||||||
|
def back_pressed(self) -> None:
|
||||||
|
self.action_back()
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def pressed(self, event: Button.Pressed) -> None:
|
||||||
|
bid = event.button.id or ""
|
||||||
|
kinds = {
|
||||||
|
"dl-current": "current",
|
||||||
|
"dl-likes": "likes",
|
||||||
|
"dl-wave": "wave",
|
||||||
|
"dl-search": "search",
|
||||||
|
}
|
||||||
|
kind = kinds.get(bid)
|
||||||
|
if not kind:
|
||||||
|
return
|
||||||
|
raw = self.query_one("#download-dir", Input).value.strip()
|
||||||
|
if not raw:
|
||||||
|
self.notify("Укажи папку для сохранения", severity="warning")
|
||||||
|
return
|
||||||
|
if kind == "current" and not self.app.current:
|
||||||
|
self.notify("Сначала выбери трек", severity="warning")
|
||||||
|
return
|
||||||
|
self.query_one("#download-progress", ProgressBar).update(progress=0)
|
||||||
|
self.query_one("#download-status", Static).update("Готовлю список…")
|
||||||
|
self.run_download(kind, Path(raw).expanduser())
|
||||||
|
|
||||||
|
def set_status(self, text: str) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
self.query_one("#download-status", Static).update(text)
|
||||||
|
|
||||||
|
def download_event(self, event: dict) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
track = event["track"]
|
||||||
|
title = f"{track_artist(track)} — {track.get('title') or '?'}"
|
||||||
|
self.query_one("#download-progress", ProgressBar).update(
|
||||||
|
progress=event["index"], total=event["total"]
|
||||||
|
)
|
||||||
|
self.set_status(f"[{event['index']}/{event['total']}] {title}")
|
||||||
|
|
||||||
|
def done(self, saved: int, skipped: int, failed: int,
|
||||||
|
directory: Path) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
message = (
|
||||||
|
f"Готово: {saved} сохранено, {skipped} уже было, "
|
||||||
|
f"{failed} ошибок"
|
||||||
|
)
|
||||||
|
self.set_status(f"{message}\n{directory}")
|
||||||
|
self.notify(message, timeout=6)
|
||||||
|
|
||||||
|
def failed(self, message: str) -> None:
|
||||||
|
if not self.is_mounted:
|
||||||
|
return
|
||||||
|
self.set_status(f"Ошибка: {message}")
|
||||||
|
self.notify(message, severity="error", timeout=8)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="download")
|
||||||
|
def run_download(self, kind: str, directory: Path) -> None:
|
||||||
|
api = self.app.api
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if kind == "current":
|
||||||
|
track = self.app.current
|
||||||
|
if not track:
|
||||||
|
self.app.call_from_thread(self.failed, "трек не выбран")
|
||||||
|
return
|
||||||
|
tracks = [track]
|
||||||
|
elif kind == "likes":
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.set_status, "Получаю избранное…")
|
||||||
|
tracks = collect_download_tracks(api, likes=True)
|
||||||
|
elif kind == "wave":
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.set_status, "Получаю «Мою волну»…")
|
||||||
|
tracks = collect_download_tracks(api, wave=True, number=20)
|
||||||
|
else:
|
||||||
|
tracks = list(self.app.search_results)
|
||||||
|
if not tracks:
|
||||||
|
self.app.call_from_thread(self.failed, "нечего скачивать")
|
||||||
|
return
|
||||||
|
|
||||||
|
def progress(event: dict) -> None:
|
||||||
|
self.app.call_from_thread(self.download_event, event)
|
||||||
|
|
||||||
|
saved, skipped, failed = download_tracks(
|
||||||
|
api, tracks, directory, on_event=progress
|
||||||
|
)
|
||||||
|
self.app.call_from_thread(
|
||||||
|
self.done, saved, skipped, failed, directory)
|
||||||
|
except SystemExit as exc:
|
||||||
|
self.app.call_from_thread(self.failed, str(exc))
|
||||||
|
|
||||||
|
|
||||||
class MenuScreen(ModalScreen[str | None]):
|
class MenuScreen(ModalScreen[str | None]):
|
||||||
BINDINGS = [Binding("escape", "close", "Закрыть")]
|
BINDINGS = [Binding("escape", "close", "Закрыть")]
|
||||||
|
|
||||||
@@ -441,7 +632,10 @@ class MenuScreen(ModalScreen[str | None]):
|
|||||||
yield Button("Избранное", id="m-favorites")
|
yield Button("Избранное", id="m-favorites")
|
||||||
yield Button("История", id="m-history")
|
yield Button("История", id="m-history")
|
||||||
yield Button("Поиск", id="m-search")
|
yield Button("Поиск", id="m-search")
|
||||||
|
yield Button("Скачать", id="m-download")
|
||||||
yield Button("Текст песни", id="m-lyrics")
|
yield Button("Текст песни", id="m-lyrics")
|
||||||
|
yield Button("Скачать трек", id="m-download-current")
|
||||||
|
yield Button("Скачать список", id="m-download-list")
|
||||||
yield Button("Настройки", id="m-settings")
|
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")
|
||||||
@@ -454,11 +648,96 @@ class MenuScreen(ModalScreen[str | None]):
|
|||||||
self.dismiss(event.button.id)
|
self.dismiss(event.button.id)
|
||||||
|
|
||||||
|
|
||||||
|
class PathScreen(ModalScreen[str | None]):
|
||||||
|
"""Диалог ввода пути (например, папки загрузок)."""
|
||||||
|
|
||||||
|
BINDINGS = [Binding("escape", "cancel", "Отмена")]
|
||||||
|
|
||||||
|
def __init__(self, value: str, title: str = "Путь") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.value = value
|
||||||
|
self.dialog_title = title
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="path-panel"):
|
||||||
|
yield Static(self.dialog_title, id="path-title")
|
||||||
|
yield Input(value=self.value, id="path-input")
|
||||||
|
with Horizontal(id="path-actions"):
|
||||||
|
yield Button("Сохранить", id="path-save", variant="primary")
|
||||||
|
yield Button("Отмена", id="path-cancel")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
field = self.query_one("#path-input", Input)
|
||||||
|
field.focus()
|
||||||
|
field.cursor_position = len(field.value)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def submitted(self, event: Input.Submitted) -> None:
|
||||||
|
self.dismiss(event.value.strip() or None)
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "path-save":
|
||||||
|
value = self.query_one("#path-input", Input).value.strip()
|
||||||
|
self.dismiss(value or None)
|
||||||
|
else:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# главный экран
|
# главный экран
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TuiMprisHandler:
|
||||||
|
"""Прокидывает команды MPRIS из D-Bus в поток приложения Textual."""
|
||||||
|
|
||||||
|
def __init__(self, app: "MusicTui") -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def _call(self, method: str, *args) -> None:
|
||||||
|
callback = getattr(self.app, method, None)
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.app.call_from_thread(callback, *args)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_play(self) -> None:
|
||||||
|
self._call("action_play_current")
|
||||||
|
|
||||||
|
def on_pause(self) -> None:
|
||||||
|
self._call("mpris_pause")
|
||||||
|
|
||||||
|
def on_play_pause(self) -> None:
|
||||||
|
self._call("action_toggle_pause")
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
self._call("mpris_stop")
|
||||||
|
|
||||||
|
def on_next(self) -> None:
|
||||||
|
self._call("action_next_track")
|
||||||
|
|
||||||
|
def on_previous(self) -> None:
|
||||||
|
self._call("action_prev_track")
|
||||||
|
|
||||||
|
def on_seek(self, offset: int) -> None:
|
||||||
|
self._call("mpris_seek", offset)
|
||||||
|
|
||||||
|
def on_set_position(self, track_id: str, position: int) -> None:
|
||||||
|
self._call("mpris_set_position", track_id, position)
|
||||||
|
|
||||||
|
def on_volume(self, value: float) -> None:
|
||||||
|
self._call("mpris_set_volume", value)
|
||||||
|
|
||||||
|
def on_quit(self) -> None:
|
||||||
|
self._call("exit")
|
||||||
|
|
||||||
|
|
||||||
class MusicTui(App):
|
class MusicTui(App):
|
||||||
TITLE = "MTS Music"
|
TITLE = "MTS Music"
|
||||||
|
|
||||||
@@ -485,7 +764,10 @@ class MusicTui(App):
|
|||||||
}
|
}
|
||||||
#menu { width: 5; min-width: 5; margin: 0 1 0 0; }
|
#menu { width: 5; min-width: 5; margin: 0 1 0 0; }
|
||||||
#brand { text-style: bold; padding: 0 1; color: #f2f2f2; }
|
#brand { text-style: bold; padding: 0 1; color: #f2f2f2; }
|
||||||
#topstatus { width: 1fr; text-align: right; content-align: right middle; color: #8a8a8a; }
|
#topstatus {
|
||||||
|
width: 1fr; text-align: right; content-align: right middle;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
#phone {
|
#phone {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
@@ -501,7 +783,10 @@ class MusicTui(App):
|
|||||||
color: #8a8a8a;
|
color: #8a8a8a;
|
||||||
margin-bottom: 1;
|
margin-bottom: 1;
|
||||||
}
|
}
|
||||||
#title { width: 100%; text-align: center; text-style: bold; color: #ffffff; }
|
#title {
|
||||||
|
width: 100%; text-align: center; text-style: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
#artist { width: 100%; text-align: center; color: #b0b0b0; }
|
#artist { width: 100%; text-align: center; color: #b0b0b0; }
|
||||||
#album { width: 100%; text-align: center; color: #6a6a6a; }
|
#album { width: 100%; text-align: center; color: #6a6a6a; }
|
||||||
#time { width: 100%; text-align: center; color: #8a8a8a; margin-top: 1; }
|
#time { width: 100%; text-align: center; color: #8a8a8a; margin-top: 1; }
|
||||||
@@ -519,7 +804,10 @@ class MusicTui(App):
|
|||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
}
|
}
|
||||||
#back { width: 5; min-width: 5; margin-right: 1; }
|
#back { width: 5; min-width: 5; margin-right: 1; }
|
||||||
#screen-title { text-style: bold; width: 1fr; content-align: left middle; color: #f2f2f2; }
|
#screen-title {
|
||||||
|
text-style: bold; width: 1fr; content-align: left middle;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
#screen-status { height: 1; padding: 0 1; color: #8a8a8a; }
|
#screen-status { height: 1; padding: 0 1; color: #8a8a8a; }
|
||||||
#list-table { height: 1fr; background: #000000; }
|
#list-table { height: 1fr; background: #000000; }
|
||||||
#search-row { height: 3; padding: 0 1; }
|
#search-row { height: 3; padding: 0 1; }
|
||||||
@@ -540,13 +828,33 @@ class MusicTui(App):
|
|||||||
#menu-panel Button { width: 100%; margin: 1 0; }
|
#menu-panel Button { width: 100%; margin: 1 0; }
|
||||||
#menu-spacer { height: 1fr; }
|
#menu-spacer { height: 1fr; }
|
||||||
|
|
||||||
|
PathScreen { align: center middle; background: #000000 70%; }
|
||||||
|
#path-panel {
|
||||||
|
width: 64;
|
||||||
|
height: auto;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: thick #ffffff;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
#path-title { text-style: bold; padding: 0 0 1 0; color: #ffffff; }
|
||||||
|
#path-input { width: 100%; }
|
||||||
|
#path-actions { height: 3; align: right middle; }
|
||||||
|
#path-actions Button { width: 14; margin-left: 1; }
|
||||||
|
|
||||||
#settings-scroll { height: 1fr; padding: 1 2; }
|
#settings-scroll { height: 1fr; padding: 1 2; }
|
||||||
.srow { height: 3; align: left middle; }
|
.srow { height: 3; align: left middle; }
|
||||||
.slabel { width: 1fr; content-align: left middle; color: #d0d0d0; }
|
.slabel { width: 1fr; content-align: left middle; color: #d0d0d0; }
|
||||||
.svalue { width: 8; content-align: center middle; color: #ffffff; }
|
.svalue { width: 8; content-align: center middle; color: #ffffff; }
|
||||||
.sstep { width: 5; min-width: 5; }
|
.sstep { width: 5; min-width: 5; }
|
||||||
.stoggle { width: 16; min-width: 12; }
|
.stoggle { width: 16; min-width: 12; }
|
||||||
|
#dir-value { padding: 0 0 1 0; color: #8a8a8a; }
|
||||||
#settings-path { padding: 1 0; color: #6a6a6a; }
|
#settings-path { padding: 1 0; color: #6a6a6a; }
|
||||||
|
|
||||||
|
#download-dir { margin: 0 1; }
|
||||||
|
#download-actions { height: 3; padding: 0 1; margin-top: 1; }
|
||||||
|
#download-actions Button { margin-right: 1; }
|
||||||
|
#download-status { height: 2; padding: 1 1 0 1; color: #d0d0d0; }
|
||||||
|
#download-progress { margin: 1 1 0 1; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -571,9 +879,13 @@ class MusicTui(App):
|
|||||||
self.settings = load_settings()
|
self.settings = load_settings()
|
||||||
self.api: Api | None = None
|
self.api: Api | None = None
|
||||||
self.uid = ""
|
self.uid = ""
|
||||||
self.player = MusicPlayer(player, volume=int(self.settings.get("volume", 80)))
|
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.search_results: list[dict] = []
|
||||||
self.liked_ids: set[str] = set()
|
self.liked_ids: set[str] = set()
|
||||||
self.history: list[dict] = load_history()
|
self.history: list[dict] = load_history()
|
||||||
self.queue: list[dict] = []
|
self.queue: list[dict] = []
|
||||||
@@ -585,6 +897,7 @@ class MusicTui(App):
|
|||||||
self.cover_bytes: dict[str, bytes] = {}
|
self.cover_bytes: dict[str, bytes] = {}
|
||||||
self.cover_rendered: dict[tuple[str, int], Text] = {}
|
self.cover_rendered: dict[tuple[str, int], Text] = {}
|
||||||
self.cover_width = 40
|
self.cover_width = 40
|
||||||
|
self.mpris: MprisService | None = None
|
||||||
|
|
||||||
# -- разметка ---------------------------------------------------------
|
# -- разметка ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -600,7 +913,12 @@ class MusicTui(App):
|
|||||||
yield Static("", id="artist")
|
yield Static("", id="artist")
|
||||||
yield Static("", id="album")
|
yield Static("", id="album")
|
||||||
yield Static("0:00 / 0:00", id="time")
|
yield Static("0:00 / 0:00", id="time")
|
||||||
yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="progress")
|
yield ProgressBar(
|
||||||
|
total=100,
|
||||||
|
show_eta=False,
|
||||||
|
show_percentage=False,
|
||||||
|
id="progress",
|
||||||
|
)
|
||||||
with Horizontal(id="controls"):
|
with Horizontal(id="controls"):
|
||||||
yield Button("⏮", id="prev")
|
yield Button("⏮", id="prev")
|
||||||
yield Button("▶", id="playpause")
|
yield Button("▶", id="playpause")
|
||||||
@@ -615,18 +933,25 @@ class MusicTui(App):
|
|||||||
self.theme = "mono"
|
self.theme = "mono"
|
||||||
self.set_interval(0.4, self.tick)
|
self.set_interval(0.4, self.tick)
|
||||||
self.apply_cover_width()
|
self.apply_cover_width()
|
||||||
|
self.mpris = MprisService(TuiMprisHandler(self), identity="MTS Music")
|
||||||
|
self.mpris.update_volume(self.player.volume / 100)
|
||||||
try:
|
try:
|
||||||
self.api = Api()
|
self.api = Api()
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
self.exit()
|
self.exit()
|
||||||
return
|
return
|
||||||
if not self.api.token:
|
if not self.api.token:
|
||||||
self.notify("Сначала войди: python mtsmusic.py login", severity="error", timeout=8)
|
self.notify(
|
||||||
|
"Сначала войди: python mtsmusic.py login",
|
||||||
|
severity="error",
|
||||||
|
timeout=8)
|
||||||
self.set_timer(3, self.exit)
|
self.set_timer(3, self.exit)
|
||||||
return
|
return
|
||||||
self.load_initial()
|
self.load_initial()
|
||||||
|
|
||||||
async def on_unmount(self) -> None:
|
async def on_unmount(self) -> None:
|
||||||
|
if self.mpris:
|
||||||
|
self.mpris.close()
|
||||||
await self.player.stop()
|
await self.player.stop()
|
||||||
|
|
||||||
# -- геометрия --------------------------------------------------------
|
# -- геометрия --------------------------------------------------------
|
||||||
@@ -638,7 +963,8 @@ class MusicTui(App):
|
|||||||
return value - (value % 2)
|
return value - (value % 2)
|
||||||
|
|
||||||
def apply_cover_width(self) -> None:
|
def apply_cover_width(self) -> None:
|
||||||
self.cover_width = self.cover_width_for(self.size.width, self.size.height)
|
self.cover_width = self.cover_width_for(
|
||||||
|
self.size.width, self.size.height)
|
||||||
self.apply_column_width(self.cover_width)
|
self.apply_column_width(self.cover_width)
|
||||||
|
|
||||||
def apply_column_width(self, width: int) -> None:
|
def apply_column_width(self, width: int) -> None:
|
||||||
@@ -699,15 +1025,22 @@ class MusicTui(App):
|
|||||||
liked = {str(track.get("id")) for track in start}
|
liked = {str(track.get("id")) for track in start}
|
||||||
else:
|
else:
|
||||||
payload = api.get(f"/users/{self.uid}/likes/tracks")
|
payload = api.get(f"/users/{self.uid}/likes/tracks")
|
||||||
refs = ((payload.get("result") or {}).get("library") or {}).get("tracks") or []
|
refs = ((payload.get("result") or {}).get(
|
||||||
|
"library") or {}).get("tracks") or []
|
||||||
liked = {str(ref.get("id")) for ref in refs}
|
liked = {str(ref.get("id")) for ref in refs}
|
||||||
if want == "history":
|
if want == "history":
|
||||||
start, name = list(self.history), "История"
|
start, name = list(self.history), "История"
|
||||||
self.call_from_thread(self.apply_initial, wave, liked, start, name)
|
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(
|
def apply_initial(
|
||||||
self,
|
self,
|
||||||
@@ -743,33 +1076,47 @@ class MusicTui(App):
|
|||||||
track_id = str(track.get("id"))
|
track_id = str(track.get("id"))
|
||||||
liked = track_id in self.liked_ids
|
liked = track_id in self.liked_ids
|
||||||
|
|
||||||
self.query_one("#title", Static).update(Text(track.get("title") or "?"))
|
self.query_one(
|
||||||
|
"#title", Static).update(
|
||||||
|
Text(
|
||||||
|
track.get("title") or "?"))
|
||||||
self.query_one("#artist", Static).update(Text(track_artist(track)))
|
self.query_one("#artist", Static).update(Text(track_artist(track)))
|
||||||
self.query_one("#album", Static).update(Text(track_album(track)))
|
self.query_one("#album", Static).update(Text(track_album(track)))
|
||||||
self.update_like_button(liked)
|
self.update_like_button(liked)
|
||||||
self.update_play_button()
|
self.update_play_button()
|
||||||
self.update_time()
|
self.update_time()
|
||||||
|
self._update_mpris_metadata()
|
||||||
self.sub_title = track.get("title") or ""
|
self.sub_title = track.get("title") or ""
|
||||||
|
|
||||||
def update_like_button(self, liked: bool | None = None) -> None:
|
def update_like_button(self, liked: bool | None = None) -> None:
|
||||||
if liked is None:
|
if liked is None:
|
||||||
liked = bool(self.current and str(self.current.get("id")) in self.liked_ids)
|
liked = bool(
|
||||||
|
self.current and str(
|
||||||
|
self.current.get("id")) in self.liked_ids)
|
||||||
button = self.query_one("#like", Button)
|
button = self.query_one("#like", Button)
|
||||||
heart = "♥" if liked else "♡"
|
heart = "♥" if liked else "♡"
|
||||||
suffix = " Избранное" if self.cover_width >= 34 else ""
|
suffix = " Избранное" if self.cover_width >= 34 else ""
|
||||||
button.label = heart + suffix
|
button.label = heart + suffix
|
||||||
|
|
||||||
def update_play_button(self) -> None:
|
def update_play_button(self) -> None:
|
||||||
self.query_one("#playpause", Button).label = "⏸" if self.player.paused else "▶"
|
self.query_one(
|
||||||
|
"#playpause",
|
||||||
|
Button).label = "⏸" if self.player.paused else "▶"
|
||||||
|
self._update_mpris_status()
|
||||||
|
|
||||||
def update_time(self) -> None:
|
def update_time(self) -> None:
|
||||||
duration = (self.current or {}).get("durationMs") or 0
|
duration = (self.current or {}).get("durationMs") or 0
|
||||||
elapsed = min(self.player.elapsed(), duration / 1000 if duration else 0)
|
elapsed = min(
|
||||||
|
self.player.elapsed(),
|
||||||
|
duration /
|
||||||
|
1000 if duration else 0)
|
||||||
bar = self.query_one("#progress", ProgressBar)
|
bar = self.query_one("#progress", ProgressBar)
|
||||||
bar.update(total=max(duration / 1000, 1), progress=elapsed)
|
bar.update(total=max(duration / 1000, 1), progress=elapsed)
|
||||||
self.query_one("#time", Static).update(
|
self.query_one("#time", Static).update(
|
||||||
f"{fmt_duration(int(elapsed * 1000))} / {fmt_duration(duration)}"
|
f"{fmt_duration(int(elapsed * 1000))} / {fmt_duration(duration)}"
|
||||||
)
|
)
|
||||||
|
if self.mpris:
|
||||||
|
self.mpris.update_position(int(elapsed * 1000) * 1000)
|
||||||
|
|
||||||
# -- обложка ----------------------------------------------------------
|
# -- обложка ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -811,7 +1158,9 @@ class MusicTui(App):
|
|||||||
def fetch_cover(self, token: int, url: str) -> None:
|
def fetch_cover(self, token: int, url: str) -> None:
|
||||||
try:
|
try:
|
||||||
force_ipv4()
|
force_ipv4()
|
||||||
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=20)
|
response = requests.get(
|
||||||
|
url, headers={
|
||||||
|
"User-Agent": USER_AGENT}, timeout=20)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.content
|
data = response.content
|
||||||
except (requests.RequestException, OSError):
|
except (requests.RequestException, OSError):
|
||||||
@@ -828,7 +1177,11 @@ class MusicTui(App):
|
|||||||
|
|
||||||
# -- воспроизведение --------------------------------------------------
|
# -- воспроизведение --------------------------------------------------
|
||||||
|
|
||||||
def play_from_list(self, tracks: list[dict], index: int, name: str) -> None:
|
def play_from_list(
|
||||||
|
self,
|
||||||
|
tracks: list[dict],
|
||||||
|
index: int,
|
||||||
|
name: str) -> None:
|
||||||
if not (0 <= index < len(tracks)):
|
if not (0 <= index < len(tracks)):
|
||||||
return
|
return
|
||||||
self.queue_name = name
|
self.queue_name = name
|
||||||
@@ -868,17 +1221,24 @@ class MusicTui(App):
|
|||||||
if not api:
|
if not api:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
url, item = resolve_stream(api, str(track.get("id")))
|
path, item = fetch_track_file(api, str(track.get("id")))
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self.notify, f"Не удалось открыть трек: {exc}", severity="error"
|
self.notify,
|
||||||
)
|
f"Не удалось открыть трек: {exc}",
|
||||||
|
severity="error")
|
||||||
return
|
return
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self.start_playback, token, track, url, bool(item.get("preview"))
|
self.start_playback, token, track, str(path),
|
||||||
|
bool(item.get("preview")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_playback(self, token: int, track: dict, url: str, preview: bool) -> None:
|
def start_playback(
|
||||||
|
self,
|
||||||
|
token: int,
|
||||||
|
track: dict,
|
||||||
|
url: str,
|
||||||
|
preview: bool) -> None:
|
||||||
if token != self.play_token:
|
if token != self.play_token:
|
||||||
return
|
return
|
||||||
self.run_worker(self._play_url(track, url, preview), group="playback")
|
self.run_worker(self._play_url(track, url, preview), group="playback")
|
||||||
@@ -906,7 +1266,10 @@ class MusicTui(App):
|
|||||||
|
|
||||||
def action_play_current(self) -> None:
|
def action_play_current(self) -> None:
|
||||||
if self.current:
|
if self.current:
|
||||||
self.play_track(self.current, self.queue or self.wave, self.current_index)
|
self.play_track(
|
||||||
|
self.current,
|
||||||
|
self.queue or self.wave,
|
||||||
|
self.current_index)
|
||||||
|
|
||||||
def try_next(self, direction: int) -> None:
|
def try_next(self, direction: int) -> None:
|
||||||
queue = self.queue or self.wave
|
queue = self.queue or self.wave
|
||||||
@@ -939,7 +1302,12 @@ class MusicTui(App):
|
|||||||
if not self.current or not self.player.is_playing():
|
if not self.current or not self.player.is_playing():
|
||||||
return
|
return
|
||||||
duration = (self.current.get("durationMs") or 0) / 1000
|
duration = (self.current.get("durationMs") or 0) / 1000
|
||||||
self.run_worker(self._seek(delta, duration), group="seek", exclusive=True)
|
self.run_worker(
|
||||||
|
self._seek(
|
||||||
|
delta,
|
||||||
|
duration),
|
||||||
|
group="seek",
|
||||||
|
exclusive=True)
|
||||||
|
|
||||||
async def _seek(self, delta: float, duration: float) -> None:
|
async def _seek(self, delta: float, duration: float) -> None:
|
||||||
await self.player.seek(delta, duration)
|
await self.player.seek(delta, duration)
|
||||||
@@ -979,19 +1347,29 @@ class MusicTui(App):
|
|||||||
]
|
]
|
||||||
self.call_from_thread(self.apply_more_wave, tracks, then_play)
|
self.call_from_thread(self.apply_more_wave, tracks, then_play)
|
||||||
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")
|
||||||
|
|
||||||
def apply_more_wave(self, tracks: list[dict], then_play: int | None) -> None:
|
def apply_more_wave(
|
||||||
|
self,
|
||||||
|
tracks: list[dict],
|
||||||
|
then_play: int | None) -> None:
|
||||||
known = {str(track.get("id")) for track in self.wave}
|
known = {str(track.get("id")) for track in self.wave}
|
||||||
added = [track for track in tracks if str(track.get("id")) not in known]
|
added = [
|
||||||
|
track for track in tracks if str(
|
||||||
|
track.get("id")) not in known]
|
||||||
self.wave.extend(added)
|
self.wave.extend(added)
|
||||||
self.set_topstatus(f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}")
|
self.set_topstatus(
|
||||||
|
f"Волна · {len(self.wave)} · ♥ {len(self.liked_ids)}")
|
||||||
if then_play is not None and then_play < len(self.queue):
|
if then_play is not None and then_play < len(self.queue):
|
||||||
self.play_track(self.queue[then_play], self.queue, then_play)
|
self.play_track(self.queue[then_play], self.queue, then_play)
|
||||||
elif then_play is not None and added:
|
elif then_play is not None and added:
|
||||||
self.queue = self.wave
|
self.queue = self.wave
|
||||||
self.current_index = len(self.wave) - len(added)
|
self.current_index = len(self.wave) - len(added)
|
||||||
self.play_track(self.wave[self.current_index], self.wave, self.current_index)
|
self.play_track(self.wave[self.current_index],
|
||||||
|
self.wave, self.current_index)
|
||||||
|
|
||||||
# -- избранное --------------------------------------------------------
|
# -- избранное --------------------------------------------------------
|
||||||
|
|
||||||
@@ -1008,7 +1386,10 @@ class MusicTui(App):
|
|||||||
albums = track.get("albums") or []
|
albums = track.get("albums") or []
|
||||||
album = str(albums[0].get("id")) if albums else ""
|
album = str(albums[0].get("id")) if albums else ""
|
||||||
if not album:
|
if not album:
|
||||||
self.call_from_thread(self.notify, "У трека нет альбома", severity="error")
|
self.call_from_thread(
|
||||||
|
self.notify,
|
||||||
|
"У трека нет альбома",
|
||||||
|
severity="error")
|
||||||
return
|
return
|
||||||
liked = track_id in self.liked_ids
|
liked = track_id in self.liked_ids
|
||||||
try:
|
try:
|
||||||
@@ -1023,7 +1404,10 @@ class MusicTui(App):
|
|||||||
**{"track-id": f"{track_id}:{album}"},
|
**{"track-id": f"{track_id}:{album}"},
|
||||||
)
|
)
|
||||||
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")
|
||||||
return
|
return
|
||||||
self.call_from_thread(self.apply_like, track, not liked)
|
self.call_from_thread(self.apply_like, track, not liked)
|
||||||
|
|
||||||
@@ -1052,8 +1436,14 @@ class MusicTui(App):
|
|||||||
self.push_screen(HistoryScreen(self.history))
|
self.push_screen(HistoryScreen(self.history))
|
||||||
elif action == "m-search":
|
elif action == "m-search":
|
||||||
self.push_screen(SearchScreen())
|
self.push_screen(SearchScreen())
|
||||||
|
elif action == "m-download":
|
||||||
|
self.push_screen(DownloadScreen())
|
||||||
elif action == "m-lyrics":
|
elif action == "m-lyrics":
|
||||||
self.action_show_lyrics()
|
self.action_show_lyrics()
|
||||||
|
elif action == "m-download-current":
|
||||||
|
self.action_download_current()
|
||||||
|
elif action == "m-download-list":
|
||||||
|
self.action_download_list()
|
||||||
elif action == "m-settings":
|
elif action == "m-settings":
|
||||||
self.push_screen(SettingsScreen())
|
self.push_screen(SettingsScreen())
|
||||||
elif action == "m-quit":
|
elif action == "m-quit":
|
||||||
@@ -1065,10 +1455,148 @@ class MusicTui(App):
|
|||||||
self.settings = update_settings(**{key: value})
|
self.settings = update_settings(**{key: value})
|
||||||
self.apply_settings()
|
self.apply_settings()
|
||||||
|
|
||||||
|
# -- скачивание -------------------------------------------------------
|
||||||
|
|
||||||
|
def action_download_current(self) -> None:
|
||||||
|
if not self.current:
|
||||||
|
self.notify("Нет текущего трека", severity="warning")
|
||||||
|
return
|
||||||
|
self.download_tracks([self.current], "трек")
|
||||||
|
|
||||||
|
def action_download_list(self) -> None:
|
||||||
|
tracks = self.queue or ([self.current] if self.current else [])
|
||||||
|
if not tracks:
|
||||||
|
self.notify("Список пуст", severity="warning")
|
||||||
|
return
|
||||||
|
self.download_tracks(list(tracks), self.queue_name)
|
||||||
|
|
||||||
|
def download_tracks(self, tracks: list[dict], label: str) -> None:
|
||||||
|
if not self.api:
|
||||||
|
return
|
||||||
|
self.set_topstatus(f"Скачиваю {label}…")
|
||||||
|
self.download_worker(tracks, label)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=False, group="download")
|
||||||
|
def download_worker(self, tracks: list[dict], label: str) -> None:
|
||||||
|
dest = str(self.settings.get("download_dir") or "")
|
||||||
|
if not dest:
|
||||||
|
self.call_from_thread(
|
||||||
|
self.notify,
|
||||||
|
"Не задана папка загрузок",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_event(event: dict) -> None:
|
||||||
|
if event.get("status") != "start":
|
||||||
|
return
|
||||||
|
title = str(event["track"].get("title") or "")
|
||||||
|
self.call_from_thread(
|
||||||
|
self.set_topstatus,
|
||||||
|
f"Скачиваю {event['index']}/{event['total']}: {title}",
|
||||||
|
)
|
||||||
|
|
||||||
|
saved, _skipped, _failed = download_tracks(
|
||||||
|
self.api, tracks, Path(dest).expanduser(), on_event=on_event)
|
||||||
|
self.call_from_thread(
|
||||||
|
self.download_finished,
|
||||||
|
label,
|
||||||
|
saved,
|
||||||
|
len(tracks),
|
||||||
|
dest,
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_finished(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
saved: int,
|
||||||
|
total: int,
|
||||||
|
dest: str,
|
||||||
|
) -> None:
|
||||||
|
self.set_topstatus(self.queue_name)
|
||||||
|
if saved:
|
||||||
|
self.notify(f"Скачано {saved}/{total} → {dest}", timeout=6)
|
||||||
|
else:
|
||||||
|
self.notify("Не удалось скачать", severity="error")
|
||||||
|
|
||||||
def apply_settings(self) -> None:
|
def apply_settings(self) -> None:
|
||||||
volume = int(self.settings.get("volume", 80))
|
volume = int(self.settings.get("volume", 80))
|
||||||
if self.player.volume != volume:
|
if self.player.volume != volume:
|
||||||
self.run_worker(self.player.set_volume(volume), group="volume", exclusive=True)
|
self.run_worker(
|
||||||
|
self.player.set_volume(volume),
|
||||||
|
group="volume",
|
||||||
|
exclusive=True)
|
||||||
|
if self.mpris:
|
||||||
|
self.mpris.update_volume(volume / 100)
|
||||||
|
|
||||||
|
# -- MPRIS ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _update_mpris_status(self) -> None:
|
||||||
|
if not self.mpris:
|
||||||
|
return
|
||||||
|
if self.player.is_playing() and not self.player.paused:
|
||||||
|
status = "Playing"
|
||||||
|
elif self.player.is_playing():
|
||||||
|
status = "Paused"
|
||||||
|
else:
|
||||||
|
status = "Stopped"
|
||||||
|
self.mpris.update_playback_status(status)
|
||||||
|
|
||||||
|
def _update_mpris_metadata(self) -> None:
|
||||||
|
if not self.mpris:
|
||||||
|
return
|
||||||
|
track = self.current
|
||||||
|
if not track:
|
||||||
|
self.mpris.clear_metadata()
|
||||||
|
self.mpris.update_capabilities(
|
||||||
|
can_next=False, can_previous=False, can_seek=False
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.mpris.update_metadata(
|
||||||
|
track.get("id"),
|
||||||
|
title=str(track.get("title") or ""),
|
||||||
|
artist=track_artist(track),
|
||||||
|
album=track_album(track),
|
||||||
|
duration_ms=int(track.get("durationMs") or 0),
|
||||||
|
art_url=track_cover_url(track) or "",
|
||||||
|
)
|
||||||
|
has_queue = bool(self.queue or self.wave)
|
||||||
|
self.mpris.update_capabilities(
|
||||||
|
can_next=has_queue, can_previous=has_queue, can_seek=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def mpris_pause(self) -> None:
|
||||||
|
if self.player.is_playing() and not self.player.paused:
|
||||||
|
self.player.toggle_pause()
|
||||||
|
self.update_play_button()
|
||||||
|
|
||||||
|
def mpris_stop(self) -> None:
|
||||||
|
self.run_worker(self._mpris_stop(), group="playback", exclusive=True)
|
||||||
|
|
||||||
|
async def _mpris_stop(self) -> None:
|
||||||
|
await self.player.stop()
|
||||||
|
self.update_play_button()
|
||||||
|
self.update_time()
|
||||||
|
|
||||||
|
def mpris_seek(self, offset_us: int) -> None:
|
||||||
|
if not self.current or not self.player.is_playing():
|
||||||
|
return
|
||||||
|
self.seek(offset_us / 1_000_000)
|
||||||
|
|
||||||
|
def mpris_set_position(self, track_path: str, position_us: int) -> None:
|
||||||
|
if not self.current or not self.player.is_playing():
|
||||||
|
return
|
||||||
|
if track_object_path(self.current.get("id")) != track_path:
|
||||||
|
return
|
||||||
|
duration = (self.current.get("durationMs") or 0) / 1000
|
||||||
|
target = max(0.0, position_us / 1_000_000)
|
||||||
|
if duration > 0:
|
||||||
|
target = min(target, duration - 1)
|
||||||
|
self.seek(target - self.player.elapsed())
|
||||||
|
|
||||||
|
def mpris_set_volume(self, value: float) -> None:
|
||||||
|
self.update_setting("volume", int(
|
||||||
|
round(max(0.0, min(1.0, value)) * 100)))
|
||||||
|
|
||||||
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