aboutsummaryrefslogtreecommitdiff
path: root/radio_tui/app.py
diff options
context:
space:
mode:
authorveradec <glitchmill.dyj3d@simplelogin.co>2026-10-25 22:06:42 +0530
committerveradec <glitchmill.dyj3d@simplelogin.co>2026-10-25 22:06:42 +0530
commitb518cd156166b16d8c7acca8e63efc65ebe13383 (patch)
tree1ebc1fa6367f47e738162a048c246de12cb96862 /radio_tui/app.py
init: init radio_tuiHEADmaster
Diffstat (limited to 'radio_tui/app.py')
-rw-r--r--radio_tui/app.py370
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()