style: center TUI cover and switch player to monochrome

This commit is contained in:
jads
2026-10-23 22:04:51 +03:00
parent 26a58068cb
commit 0eb6909724
2 changed files with 99 additions and 58 deletions

View File

@@ -42,11 +42,12 @@ PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" .venv/bin/playwright install chromiu
.venv/bin/python mtsmusic.py tui .venv/bin/python mtsmusic.py tui
``` ```
Интерфейс в терминале сделан как экран телефона: по центру обложка (цветными Интерфейс в терминале сделан как экран телефона и полностью чёрно-белый:
блоками, нужен truecolor-терминал), ниже — большая кнопка play/pause, по бокам по центру обложка в оттенках серого, строго под ней по той же ширине —
— предыдущий и следующий треки. Под ними — «Текст» и «В избранное». Кнопка большая кнопка play/pause и по бокам предыдущий/следующий треки, ниже
«☰» в левом верхнем углу открывает боковое меню: «Моя волна», избранное, «Текст» и «Избранное». Кнопка «☰» в левом верхнем углу открывает боковое
история прослушивания, поиск, текст песни и выход. меню: «Моя волна», избранное, история прослушивания, поиск, текст песни и
выход.
Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны Мышь работает: кнопки, меню, строки списков и полоса прогресса кликабельны
(клик по прогрессу перематывает трек). (клик по прогрессу перематывает трек).

146
tui.py
View File

@@ -14,6 +14,7 @@ from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.events import Click, Resize from textual.events import Click, Resize
from textual.screen import ModalScreen, Screen from textual.screen import ModalScreen, Screen
from textual.theme import Theme
from textual.widgets import ( from textual.widgets import (
Button, Button,
DataTable, DataTable,
@@ -47,9 +48,25 @@ COVER_MAX = 46
COVER_MIN = 20 COVER_MIN = 20
COVER_CACHE = 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: 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) rows = max(1, width // 2)
image = image.resize((width, rows * 2), Image.Resampling.LANCZOS) 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)) bottom = image.getpixel((x, y * 2 + 1))
canvas.append( canvas.append(
"▀", "▀",
style=f"#{top[0]:02x}{top[1]:02x}{top[2]:02x} " style=f"#{top:02x}{top:02x}{top:02x} "
f"on #{bottom[0]:02x}{bottom[1]:02x}{bottom[2]:02x}", f"on #{bottom:02x}{bottom:02x}{bottom:02x}",
) )
if y != rows - 1: if y != rows - 1:
canvas.append("\n") canvas.append("\n")
@@ -375,68 +392,80 @@ class MusicTui(App):
TITLE = "MTS Music" TITLE = "MTS Music"
CSS = """ 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 { #topbar {
height: 3; height: 3;
dock: top; dock: top;
padding: 0 1; padding: 0 1;
align: left middle; align: left middle;
background: $panel; background: #0a0a0a;
} }
#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; } #brand { text-style: bold; padding: 0 1; color: #f2f2f2; }
#topstatus { width: 1fr; text-align: right; content-align: right middle; color: $text-muted; } #topstatus { width: 1fr; text-align: right; content-align: right middle; color: #8a8a8a; }
#phone { #phone {
height: 1fr; height: 1fr;
align: center middle; align: center middle;
padding: 1 1 0 1; padding: 1 0 0 0;
} }
/* Колонка ровно по ширине обложки: всё под обложкой выровнено по ней. */
#column { width: 40; height: auto; align: center top; }
#cover { #cover {
width: 40; width: 100%;
height: 20; height: 20;
content-align: center middle; content-align: center middle;
color: $text-muted; color: #8a8a8a;
margin-bottom: 1; margin-bottom: 1;
} }
#title { width: 100%; text-align: center; text-style: bold; } #title { width: 100%; text-align: center; text-style: bold; color: #ffffff; }
#artist { width: 100%; text-align: center; color: $text-muted; } #artist { width: 100%; text-align: center; color: #b0b0b0; }
#album { width: 100%; text-align: center; color: $text-disabled; } #album { width: 100%; text-align: center; color: #6a6a6a; }
#time { width: 100%; text-align: center; color: $text-muted; margin-top: 1; } #time { width: 100%; text-align: center; color: #8a8a8a; margin-top: 1; }
#progress { width: 44; } #progress { width: 100%; }
#controls { height: 3; align: center middle; margin-top: 1; } #controls { height: 3; align: center middle; margin-top: 1; }
#prev, #next { width: 7; min-width: 7; } #prev, #playpause, #next { height: 3; min-width: 4; }
#playpause { width: 13; min-width: 13; margin: 0 2; } #actions { height: 3; align: center middle; margin-top: 1; }
#actions { height: 3; align: center middle; } #actions Button { height: 3; min-width: 4; }
#actions Button { width: 20; min-width: 20; margin: 0 1; }
#screen-top { #screen-top {
height: 3; height: 3;
dock: top; dock: top;
padding: 0 1; padding: 0 1;
align: left middle; align: left middle;
background: $panel; 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; } #screen-title { text-style: bold; width: 1fr; content-align: left middle; color: #f2f2f2; }
#screen-status { height: 1; padding: 0 1; color: $text-muted; } #screen-status { height: 1; padding: 0 1; color: #8a8a8a; }
#list-table { height: 1fr; } #list-table { height: 1fr; background: #000000; }
#search-row { height: 3; padding: 0 1; } #search-row { height: 3; padding: 0 1; }
#search-input { width: 1fr; } #search-input { width: 1fr; }
#search-go { width: 12; margin-left: 1; } #search-go { width: 12; margin-left: 1; }
#lyrics-scroll { height: 1fr; padding: 0 2; } #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 { #menu-panel {
width: 42; width: 42;
height: 100%; height: 100%;
background: $surface; background: #0d0d0d;
border-right: thick $accent; border-right: thick #ffffff;
padding: 1 2; 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-panel Button { width: 100%; margin: 1 0; }
#menu-spacer { height: 1fr; } #menu-spacer { height: 1fr; }
""" """
@@ -483,23 +512,26 @@ class MusicTui(App):
yield Button("☰", id="menu") yield Button("☰", id="menu")
yield Static("MTS Music", id="brand") yield Static("MTS Music", id="brand")
yield Static("", id="topstatus") yield Static("", id="topstatus")
with Vertical(id="phone"): with Horizontal(id="phone"):
yield Static("♪", id="cover") with Vertical(id="column"):
yield Static("", id="title") yield Static("♪", id="cover")
yield Static("", id="artist") yield Static("", id="title")
yield Static("", id="album") yield Static("", id="artist")
yield Static("0:00 / 0:00", id="time") yield Static("", id="album")
yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="progress") yield Static("0:00 / 0:00", id="time")
with Horizontal(id="controls"): yield ProgressBar(total=100, show_eta=False, show_percentage=False, id="progress")
yield Button("⏮", id="prev") with Horizontal(id="controls"):
yield Button("▶", id="playpause", variant="primary") yield Button("⏮", id="prev")
yield Button("⏭", id="next") yield Button("▶", id="playpause")
with Horizontal(id="actions"): yield Button("⏭", id="next")
yield Button("Текст", id="lyrics") with Horizontal(id="actions"):
yield Button("♡ В избранное", id="like", variant="success") yield Button("Текст", id="lyrics")
yield Button("♡ В избранное", id="like")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
self.register_theme(MONO_THEME)
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()
try: try:
@@ -526,24 +558,31 @@ class MusicTui(App):
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)
def apply_column_width(self, width: int) -> None:
"""Ширина колонки равна обложке; кнопки растянуты ровно по ней."""
try: 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: except Exception:
return return
cover.styles.width = self.cover_width
cover.styles.height = max(4, self.cover_width // 2)
def on_resize(self, event: Resize) -> None: def on_resize(self, event: Resize) -> None:
width = self.cover_width_for(event.size.width, event.size.height) width = self.cover_width_for(event.size.width, event.size.height)
if width == self.cover_width: if width == self.cover_width:
return return
self.cover_width = width self.cover_width = width
try: self.apply_column_width(width)
cover = self.query_one("#cover", Static)
cover.styles.width = width
cover.styles.height = max(4, width // 2)
except Exception:
return
url = track_cover_url(self.current) if self.current else None url = track_cover_url(self.current) if self.current else None
if url and url in self.cover_bytes: if url and url in self.cover_bytes:
self.show_cover(url) self.show_cover(url)
@@ -610,8 +649,9 @@ class MusicTui(App):
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)
button.label = "♥ В избранном" if liked else "♡ В избранное" heart = "♥" if liked else "♡"
button.variant = "warning" if liked else "success" suffix = " Избранное" if self.cover_width >= 34 else ""
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 "▶"