Files

145 lines
6.0 KiB
Python

from rich.markup import escape
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import music
import youtube
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
LOADING = "..."
DESCRIPTION_PREVIEW_LENGTH = 500
EMPTY_DETAILS = "Add a song with [b]a[/b] and a YouTube link."
def song_details(identifier: str, details: dict | None) -> str:
if not details:
return f"[b]{escape(identifier)}[/b]\n\n[dim]Loading details from YouTube...[/dim]"
rows = [
("Artist", details.get("artist")),
("Length", youtube.format_duration(details["durationSeconds"]) if details.get("durationSeconds") else None),
("Views", f"{details['views']:,}" if details.get("views") is not None else None),
("Published", details.get("published")),
("Video ID", identifier),
]
lines = [f"[b]{escape(details.get('title') or 'Title not found')}[/b]", ""]
lines += [f"[dim]{label:<11}[/dim]{escape(str(value))}" for label, value in rows if value]
description = details.get("description")
if description:
preview = description[:DESCRIPTION_PREVIEW_LENGTH] + ("..." if len(description) > DESCRIPTION_PREVIEW_LENGTH else "")
lines += ["", escape(preview)]
lines += ["", f"[dim]{escape(details['url'])}[/dim]", f"[dim]{escape(details['cover'])}[/dim]"]
return "\n".join(lines)
class MusicPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("d", "delete", "Delete"),
Binding("K", "move(-1)", "Move up"),
Binding("J", "move(1)", "Move down"),
Binding("u", "refresh_details", "Refresh"),
Binding("r", "reload", "Reload"),
]
content_file = "music.json"
song_info: dict[str, dict] = {}
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static(EMPTY_DETAILS, id="song-detail")
def on_mount(self):
table = self.table()
table.add_column("#", key="number")
table.add_column("Title", key="title")
table.add_column("Artist", key="artist")
table.add_column("Length", key="length")
self.reload()
def row_cells(self, identifier: str) -> tuple[str, str, str]:
details = self.song_info.get(identifier)
if not details:
return identifier, LOADING, LOADING
return details.get("title") or identifier, details.get("artist") or "-", youtube.format_duration(details.get("durationSeconds"))
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for number, identifier in enumerate(music.list_songs(), start=1):
table.add_row(str(number), *self.row_cells(identifier), key=identifier)
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
self.update_summary()
self.load_details()
def update_summary(self):
songs = music.list_songs()
known_lengths = [self.song_info[song].get("durationSeconds") for song in songs if song in self.song_info]
total = sum(length for length in known_lengths if length)
length_text = youtube.format_duration(total) if known_lengths else LOADING
self.border_subtitle = f"{len(songs)} songs · {length_text}"
def show_details(self, identifier: str | None):
detail = self.query_one("#song-detail", Static)
detail.update(song_details(identifier, self.song_info.get(identifier)) if identifier else EMPTY_DETAILS)
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@work(thread=True, exclusive=True, group="details")
def load_details(self, refresh_ids: tuple[str, ...] = ()):
for identifier in music.list_songs():
if identifier in self.song_info and identifier not in refresh_ids:
continue
self.song_info[identifier] = youtube.video_details(identifier)
self.app.call_from_thread(self.show_song, identifier)
def show_song(self, identifier: str):
table = self.table()
try:
for column, value in zip(["title", "artist", "length"], self.row_cells(identifier)):
table.update_cell(identifier, column, value, update_width=True)
except Exception:
return
self.update_summary()
if self.selected_key() == identifier:
self.show_details(identifier)
def action_refresh_details(self):
identifier = self.selected_key()
if identifier:
self.notify("Refreshing from YouTube...")
self.load_details((identifier,))
def action_add(self):
fields = [FormField("link", "YouTube link or video ID", placeholder="https://music.youtube.com/watch?v=...", required=True)]
self.app.push_screen(FormDialog("Add a song", fields, "Add"), self.add_song)
def add_song(self, values: dict | None):
if values:
self.run_now(lambda: music.add_song(values["link"]), lambda identifier: f"Added {identifier}", youtube.video_id(values["link"]))
def action_delete(self):
identifier = self.selected_key()
if not identifier:
return
title = (self.song_info.get(identifier) or {}).get("title") or identifier
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: music.remove_song(identifier), f"Removed {title}")
self.app.push_screen(ConfirmDialog(f"Remove {title} from the playlist?"), confirmed)
def action_move(self, step: int):
identifier = self.selected_key()
if identifier:
self.run_now(lambda: music.move_song(identifier, step), "Moved", identifier)