diff options
| author | veradec <glitchmill.dyj3d@simplelogin.co> | 2026-10-25 22:06:42 +0530 |
|---|---|---|
| committer | veradec <glitchmill.dyj3d@simplelogin.co> | 2026-10-25 22:06:42 +0530 |
| commit | b518cd156166b16d8c7acca8e63efc65ebe13383 (patch) | |
| tree | 1ebc1fa6367f47e738162a048c246de12cb96862 /radio_tui/app.py | |
Diffstat (limited to 'radio_tui/app.py')
| -rw-r--r-- | radio_tui/app.py | 370 |
1 files changed, 370 insertions, 0 deletions
diff --git a/radio_tui/app.py b/radio_tui/app.py new file mode 100644 index 0000000..a1d4695 --- /dev/null +++ b/radio_tui/app.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.widgets import ( + DataTable, + Footer, + Header, + Input, + Label, + Static, + Switch, +) + +from radio_tui.player import MpvPlayer +from radio_tui.stations import Station, load_stations + + +class NowPlayingPanel(Static): + DEFAULT_CSS = """ + NowPlayingPanel { + height: 8; + border: round $accent; + padding: 1 2; + background: $surface; + } + + NowPlayingPanel .title { + text-style: bold; + color: $accent; + } + + NowPlayingPanel .offline { + color: $error; + } + """ + + station = reactive("") + track = reactive("") + volume = reactive(50) + muted = reactive(False) + offline = reactive(False) + + def render(self) -> str: + station = self.station or "Nothing playing" + track = self.track or "Select a station and press Enter" + volume = f"{self.volume}%" + if self.muted: + volume += " (muted)" + status = "offline" if self.offline else "live" + lines = [ + "[b accent]Now Playing[/]", + f"[bold]{station}[/]", + f"[dim]{track}[/]" if not self.offline else f"[error]{track}[/]", + f"Volume: {volume} · {status}", + ] + return "\n".join(lines) + + +class RadioApp(App): + TITLE = "Telugu Radio" + SUB_TITLE = "OnlineRadioFM" + CSS = """ + Screen { + layout: vertical; + } + + #main { + height: 1fr; + } + + #sidebar { + width: 34; + border-right: solid $primary; + padding: 0 1; + } + + #sidebar Label { + margin: 1 0 0 0; + text-style: bold; + } + + #search { + margin: 1 0; + } + + #playable-only { + margin: 0 0 1 0; + } + + #station-table { + height: 1fr; + } + + #details { + padding: 1 2; + height: 1fr; + } + + #details-title { + text-style: bold; + margin-bottom: 1; + } + + #details-body { + color: $text-muted; + } + + .stat-block { + margin-top: 1; + padding: 1; + border: round $primary-lighten-2; + background: $panel; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("enter", "play_selected", "Play"), + Binding("space", "play_selected", "Play", show=False), + Binding("n", "next_station", "Next"), + Binding("p", "prev_station", "Prev"), + Binding("/", "focus_search", "Search"), + Binding("+", "volume_up", "Vol +"), + Binding("=", "volume_up", "Vol +", show=False), + Binding("-", "volume_down", "Vol -"), + Binding("m", "toggle_mute", "Mute"), + Binding("s", "stop", "Stop"), + Binding("f", "toggle_favorite", "Favorite"), + Binding("y", "copy_stream", "Copy URL"), + ] + + favorites_path = Path.home() / ".radio_tui_favorites.json" + selected_index = reactive(0) + filter_text = reactive("") + playable_only = reactive(True) + + def __init__(self) -> None: + super().__init__() + self.all_stations = load_stations() + self.filtered_stations: list[Station] = [] + self.player = MpvPlayer() + self.favorites: set[str] = self._load_favorites() + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(id="main"): + with Vertical(id="sidebar"): + yield Label("Search") + yield Input(placeholder="Filter stations...", id="search") + with Horizontal(id="playable-only"): + yield Switch(value=True, id="playable-switch") + yield Label("Playable only") + yield DataTable(id="station-table", zebra_stripes=True, cursor_type="row") + with Vertical(id="details"): + yield NowPlayingPanel(id="now-playing") + yield Static("Select a station", id="details-title") + yield Static("", id="details-body") + yield Static("", id="details-stats", classes="stat-block") + yield Footer() + + def on_mount(self) -> None: + if not self.player.available: + self.notify( + "mpv and socat are required for playback.", + severity="error", + timeout=8, + ) + table = self.query_one("#station-table", DataTable) + table.add_columns("Station", "Live") + self.refresh_table() + table.focus() + self.set_interval(1, self.refresh_now_playing) + + def _load_favorites(self) -> set[str]: + if not self.favorites_path.exists(): + return set() + try: + data = json.loads(self.favorites_path.read_text(encoding="utf-8")) + return set(data) + except (json.JSONDecodeError, TypeError): + return set() + + def _save_favorites(self) -> None: + self.favorites_path.write_text( + json.dumps(sorted(self.favorites), indent=2), + encoding="utf-8", + ) + + def _apply_filter(self) -> None: + query = self.filter_text.casefold().strip() + stations = self.all_stations + if self.playable_only: + stations = [station for station in stations if station.playable] + if query: + stations = [ + station + for station in stations + if query in station.name.casefold() + ] + self.filtered_stations = stations + + def refresh_table(self) -> None: + self._apply_filter() + table = self.query_one("#station-table", DataTable) + table.clear() + for station in self.filtered_stations: + fav = "★ " if station.name in self.favorites else "" + live = "●" if station.playable else "○" + table.add_row(f"{fav}{station.name}", live) + if self.filtered_stations: + self.selected_index = min(self.selected_index, len(self.filtered_stations) - 1) + table.move_cursor(row=self.selected_index) + self.update_details() + else: + self.query_one("#details-title", Static).update("No stations match") + self.query_one("#details-body", Static).update("") + self.query_one("#details-stats", Static).update("") + + def selected_station(self) -> Station | None: + if not self.filtered_stations: + return None + index = max(0, min(self.selected_index, len(self.filtered_stations) - 1)) + return self.filtered_stations[index] + + def update_details(self) -> None: + station = self.selected_station() + if not station: + return + favorite = "Yes" if station.name in self.favorites else "No" + stream = station.stream_url or "Not available online" + body = ( + f"Page: {station.page_url}\n" + f"Stream: {stream}\n" + f"Favorite: {favorite}" + ) + playable = sum(1 for item in self.all_stations if item.playable) + stats = ( + f"{len(self.all_stations)} stations total · " + f"{playable} playable · " + f"{len(self.favorites)} favorites" + ) + self.query_one("#details-title", Static).update(station.name) + self.query_one("#details-body", Static).update(body) + self.query_one("#details-stats", Static).update(stats) + + def refresh_now_playing(self) -> None: + panel = self.query_one("#now-playing", NowPlayingPanel) + state = self.player.snapshot() + panel.station = state.station_name + panel.track = state.media_title + panel.volume = state.volume + panel.muted = state.muted + panel.offline = state.offline + + @on(Input.Changed, "#search") + def on_search_changed(self, event: Input.Changed) -> None: + self.filter_text = event.value + self.selected_index = 0 + self.refresh_table() + + @on(Switch.Changed, "#playable-switch") + def on_playable_changed(self, event: Switch.Changed) -> None: + self.playable_only = event.value + self.selected_index = 0 + self.refresh_table() + + @on(DataTable.RowHighlighted) + def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + if event.cursor_row is not None: + self.selected_index = event.cursor_row + self.update_details() + + @on(DataTable.RowSelected) + def on_row_selected(self, event: DataTable.RowSelected) -> None: + if event.cursor_row is not None: + self.selected_index = event.cursor_row + self.update_details() + self.action_play_selected() + + def action_focus_search(self) -> None: + self.query_one("#search", Input).focus() + + def action_play_selected(self) -> None: + station = self.selected_station() + if not station: + return + if not station.playable or not station.stream_url: + self.notify(f"No stream for {station.name}", severity="warning") + return + self.player.play(station.name, station.stream_url) + self.notify(f"Playing {station.name}") + + def action_stop(self) -> None: + self.player.stop() + self.notify("Stopped") + + def action_next_station(self) -> None: + if not self.filtered_stations: + return + self.selected_index = (self.selected_index + 1) % len(self.filtered_stations) + table = self.query_one("#station-table", DataTable) + table.move_cursor(row=self.selected_index) + self.action_play_selected() + + def action_prev_station(self) -> None: + if not self.filtered_stations: + return + self.selected_index = (self.selected_index - 1) % len(self.filtered_stations) + table = self.query_one("#station-table", DataTable) + table.move_cursor(row=self.selected_index) + self.action_play_selected() + + def action_volume_up(self) -> None: + self.player.adjust_volume(5) + self.refresh_now_playing() + + def action_volume_down(self) -> None: + self.player.adjust_volume(-5) + self.refresh_now_playing() + + def action_toggle_mute(self) -> None: + self.player.toggle_mute() + self.refresh_now_playing() + + def action_toggle_favorite(self) -> None: + station = self.selected_station() + if not station: + return + if station.name in self.favorites: + self.favorites.remove(station.name) + self.notify(f"Removed {station.name} from favorites") + else: + self.favorites.add(station.name) + self.notify(f"Added {station.name} to favorites") + self._save_favorites() + self.refresh_table() + + def action_copy_stream(self) -> None: + import shutil + import subprocess + + station = self.selected_station() + if not station or not station.stream_url: + self.notify("No stream URL to copy", severity="warning") + return + url = station.stream_url + for copy_cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"]): + if shutil.which(copy_cmd[0]): + subprocess.run(copy_cmd, input=url.encode(), check=False) + self.notify("Stream URL copied") + return + self.notify(url, title="Stream URL", timeout=10) + + def on_unmount(self) -> None: + self.player.stop() + + +def main() -> None: + RadioApp().run() + + +if __name__ == "__main__": + main() |
