aboutsummaryrefslogtreecommitdiff
path: root/radio_tui/app.py
blob: a1d4695c6f84b7054fd268e763ee78cc80bfb7a6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
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()