145 lines
6.0 KiB
Python
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)
|