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)