style: center TUI cover and switch player to monochrome
This commit is contained in:
11
README.md
11
README.md
@@ -42,11 +42,12 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
|
||||
.venv/bin/python mtsmusic.py tui
|
||||
```
|
||||
|
||||
Интерфейс в терминале сделан как экран телефона: по центру обложка (цветными
|
||||
блоками, нужен truecolor-терминал), ниже — большая кнопка play/pause, по бокам
|
||||
— предыдущий и следующий треки. Под ними — «Текст» и «В избранное». Кнопка
|
||||
«☰» в левом верхнем углу открывает боковое меню: «Моя волна», избранное,
|
||||
история прослушивания, поиск, текст песни и выход.
|
||||
Интерфейс в терминале сделан как экран телефона и полностью чёрно-белый:
|
||||
по центру обложка в оттенках серого, строго под ней по той же ширине —
|
||||
большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
|
||||
«Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
|
||||
меню: «Моя волна», избранное, история прослушивания, поиск, текст песни и
|
||||
выход.
|
||||
|
||||
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
|
||||
(клик по прогрессу перематывает трек).
|
||||
|
||||
146
tui.py
146
tui.py
@@ -14,6 +14,7 @@ from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.events import Click, Resize
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.theme import Theme
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
DataTable,
|
||||
@@ -47,9 +48,25 @@ COVER_MAX = 46
|
||||
COVER_MIN = 20
|
||||
COVER_CACHE = 20
|
||||
|
||||
MONO_THEME = Theme(
|
||||
name="mono",
|
||||
primary="#e6e6e6",
|
||||
secondary="#9a9a9a",
|
||||
warning="#cfcfcf",
|
||||
error="#ffffff",
|
||||
success="#bdbdbd",
|
||||
accent="#ffffff",
|
||||
foreground="#f2f2f2",
|
||||
background="#000000",
|
||||
surface="#0d0d0d",
|
||||
panel="#0a0a0a",
|
||||
dark=True,
|
||||
)
|
||||
|
||||
|
||||
def cover_renderable(data: bytes, width: int) -> Text:
|
||||
image = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
"""Обложка в оттенках серого (полностью чёрно-белый интерфейс)."""
|
||||
image = Image.open(io.BytesIO(data)).convert("L")
|
||||
rows = max(1, width // 2)
|
||||
image = image.resize((width, rows * 2), Image.Resampling.LANCZOS)
|
||||
|
||||
@@ -60,8 +77,8 @@ def cover_renderable(data: bytes, width: int) -> Text:
|
||||
bottom = image.getpixel((x, y * 2 + 1))
|
||||
canvas.append(
|
||||
"▀",
|
||||
style=f"#{top[0]:02x}{top[1]:02x}{top[2]:02x} "
|
||||
f"on #{bottom[0]:02x}{bottom[1]:02x}{bottom[2]:02x}",
|
||||
style=f"#{top:02x}{top:02x}{top:02x} "
|
||||
f"on #{bottom:02x}{bottom:02x}{bottom:02x}",
|
||||
)
|
||||
if y != rows - 1:
|
||||
canvas.append("\n")
|
||||
@@ -375,68 +392,80 @@ class MusicTui(App):
|
||||
TITLE = "MTS Music"
|
||||
|
||||
CSS = """
|
||||
Screen { background: $background; }
|
||||
/* Полностью чёрно-белая тема: только оттенки серого. */
|
||||
Screen { background: #000000; color: #f2f2f2; }
|
||||
|
||||
Button {
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
border: tall #4a4a4a;
|
||||
text-style: bold;
|
||||
}
|
||||
Button:hover { background: #1c1c1c; border: tall #9a9a9a; }
|
||||
Button:focus { border: tall #ffffff; }
|
||||
Button.-active { background: #2a2a2a; }
|
||||
|
||||
#topbar {
|
||||
height: 3;
|
||||
dock: top;
|
||||
padding: 0 1;
|
||||
align: left middle;
|
||||
background: $panel;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
#menu { width: 5; min-width: 5; margin: 0 1 0 0; }
|
||||
#brand { text-style: bold; padding: 0 1; }
|
||||
#topstatus { width: 1fr; text-align: right; content-align: right middle; color: $text-muted; }
|
||||
#brand { text-style: bold; padding: 0 1; color: #f2f2f2; }
|
||||
#topstatus { width: 1fr; text-align: right; content-align: right middle; color: #8a8a8a; }
|
||||
|
||||
#phone {
|
||||
height: 1fr;
|
||||
align: center middle;
|
||||
padding: 1 1 0 1;
|
||||
padding: 1 0 0 0;
|
||||
}
|
||||
/* Колонка ровно по ширине обложки: всё под обложкой выровнено по ней. */
|
||||
#column { width: 40; height: auto; align: center top; }
|
||||
#cover {
|
||||
width: 40;
|
||||
width: 100%;
|
||||
height: 20;
|
||||
content-align: center middle;
|
||||
color: $text-muted;
|
||||
color: #8a8a8a;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#title { width: 100%; text-align: center; text-style: bold; }
|
||||
#artist { width: 100%; text-align: center; color: $text-muted; }
|
||||
#album { width: 100%; text-align: center; color: $text-disabled; }
|
||||
#time { width: 100%; text-align: center; color: $text-muted; margin-top: 1; }
|
||||
#progress { width: 44; }
|
||||
#title { width: 100%; text-align: center; text-style: bold; color: #ffffff; }
|
||||
#artist { width: 100%; text-align: center; color: #b0b0b0; }
|
||||
#album { width: 100%; text-align: center; color: #6a6a6a; }
|
||||
#time { width: 100%; text-align: center; color: #8a8a8a; margin-top: 1; }
|
||||
#progress { width: 100%; }
|
||||
#controls { height: 3; align: center middle; margin-top: 1; }
|
||||
#prev, #next { width: 7; min-width: 7; }
|
||||
#playpause { width: 13; min-width: 13; margin: 0 2; }
|
||||
#actions { height: 3; align: center middle; }
|
||||
#actions Button { width: 20; min-width: 20; margin: 0 1; }
|
||||
#prev, #playpause, #next { height: 3; min-width: 4; }
|
||||
#actions { height: 3; align: center middle; margin-top: 1; }
|
||||
#actions Button { height: 3; min-width: 4; }
|
||||
|
||||
#screen-top {
|
||||
height: 3;
|
||||
dock: top;
|
||||
padding: 0 1;
|
||||
align: left middle;
|
||||
background: $panel;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
#back { width: 5; min-width: 5; margin-right: 1; }
|
||||
#screen-title { text-style: bold; width: 1fr; content-align: left middle; }
|
||||
#screen-status { height: 1; padding: 0 1; color: $text-muted; }
|
||||
#list-table { height: 1fr; }
|
||||
#screen-title { text-style: bold; width: 1fr; content-align: left middle; color: #f2f2f2; }
|
||||
#screen-status { height: 1; padding: 0 1; color: #8a8a8a; }
|
||||
#list-table { height: 1fr; background: #000000; }
|
||||
#search-row { height: 3; padding: 0 1; }
|
||||
#search-input { width: 1fr; }
|
||||
#search-go { width: 12; margin-left: 1; }
|
||||
#lyrics-scroll { height: 1fr; padding: 0 2; }
|
||||
#lyrics-body { width: 100%; }
|
||||
#lyrics-body { width: 100%; color: #e0e0e0; }
|
||||
|
||||
MenuScreen { align: left middle; background: $background 60%; }
|
||||
MenuScreen { align: left middle; background: #000000 70%; }
|
||||
#menu-panel {
|
||||
width: 42;
|
||||
height: 100%;
|
||||
background: $surface;
|
||||
border-right: thick $accent;
|
||||
background: #0d0d0d;
|
||||
border-right: thick #ffffff;
|
||||
padding: 1 2;
|
||||
}
|
||||
#menu-title { text-style: bold; padding: 0 0 1 0; }
|
||||
#menu-title { text-style: bold; padding: 0 0 1 0; color: #ffffff; }
|
||||
#menu-panel Button { width: 100%; margin: 1 0; }
|
||||
#menu-spacer { height: 1fr; }
|
||||
"""
|
||||
@@ -483,23 +512,26 @@ class MusicTui(App):
|
||||
yield Button("☰", id="menu")
|
||||
yield Static("MTS Music", id="brand")
|
||||
yield Static("", id="topstatus")
|
||||
with Vertical(id="phone"):
|
||||
yield Static("♪", id="cover")
|
||||
yield Static("", id="title")
|
||||
yield Static("", id="artist")
|
||||
yield Static("", id="album")
|
||||
yield Static("0:00 / 0:00", id="time")
|
||||
yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="progress")
|
||||
with Horizontal(id="controls"):
|
||||
yield Button("⏮", id="prev")
|
||||
yield Button("▶", id="playpause", variant="primary")
|
||||
yield Button("⏭", id="next")
|
||||
with Horizontal(id="actions"):
|
||||
yield Button("Текст", id="lyrics")
|
||||
yield Button("♡ В избранное", id="like", variant="success")
|
||||
with Horizontal(id="phone"):
|
||||
with Vertical(id="column"):
|
||||
yield Static("♪", id="cover")
|
||||
yield Static("", id="title")
|
||||
yield Static("", id="artist")
|
||||
yield Static("", id="album")
|
||||
yield Static("0:00 / 0:00", id="time")
|
||||
yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="progress")
|
||||
with Horizontal(id="controls"):
|
||||
yield Button("⏮", id="prev")
|
||||
yield Button("▶", id="playpause")
|
||||
yield Button("⏭", id="next")
|
||||
with Horizontal(id="actions"):
|
||||
yield Button("Текст", id="lyrics")
|
||||
yield Button("♡ В избранное", id="like")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.register_theme(MONO_THEME)
|
||||
self.theme = "mono"
|
||||
self.set_interval(0.4, self.tick)
|
||||
self.apply_cover_width()
|
||||
try:
|
||||
@@ -526,24 +558,31 @@ class MusicTui(App):
|
||||
|
||||
def apply_cover_width(self) -> None:
|
||||
self.cover_width = self.cover_width_for(self.size.width, self.size.height)
|
||||
self.apply_column_width(self.cover_width)
|
||||
|
||||
def apply_column_width(self, width: int) -> None:
|
||||
"""Ширина колонки равна обложке; кнопки растянуты ровно по ней."""
|
||||
try:
|
||||
cover = self.query_one("#cover", Static)
|
||||
self.query_one("#column").styles.width = width
|
||||
self.query_one("#cover").styles.height = max(4, width // 2)
|
||||
side = max(4, width // 4)
|
||||
play = max(6, width - side * 2)
|
||||
self.query_one("#prev").styles.width = side
|
||||
self.query_one("#next").styles.width = side
|
||||
self.query_one("#playpause").styles.width = play
|
||||
half = max(5, width // 2)
|
||||
self.query_one("#lyrics").styles.width = half
|
||||
self.query_one("#like").styles.width = width - half
|
||||
self.update_like_button()
|
||||
except Exception:
|
||||
return
|
||||
cover.styles.width = self.cover_width
|
||||
cover.styles.height = max(4, self.cover_width // 2)
|
||||
|
||||
def on_resize(self, event: Resize) -> None:
|
||||
width = self.cover_width_for(event.size.width, event.size.height)
|
||||
if width == self.cover_width:
|
||||
return
|
||||
self.cover_width = width
|
||||
try:
|
||||
cover = self.query_one("#cover", Static)
|
||||
cover.styles.width = width
|
||||
cover.styles.height = max(4, width // 2)
|
||||
except Exception:
|
||||
return
|
||||
self.apply_column_width(width)
|
||||
url = track_cover_url(self.current) if self.current else None
|
||||
if url and url in self.cover_bytes:
|
||||
self.show_cover(url)
|
||||
@@ -610,8 +649,9 @@ class MusicTui(App):
|
||||
if liked is None:
|
||||
liked = bool(self.current and str(self.current.get("id")) in self.liked_ids)
|
||||
button = self.query_one("#like", Button)
|
||||
button.label = "♥ В избранном" if liked else "♡ В избранное"
|
||||
button.variant = "warning" if liked else "success"
|
||||
heart = "♥" if liked else "♡"
|
||||
suffix = " Избранное" if self.cover_width >= 34 else ""
|
||||
button.label = heart + suffix
|
||||
|
||||
def update_play_button(self) -> None:
|
||||
self.query_one("#playpause", Button).label = "⏸" if self.player.paused else "▶"
|
||||
|
||||
Reference in New Issue
Block a user