From 2b914c2ffefcca63ff76a7d7f39d6f66df6dad5d Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 19:54:40 +0530 Subject: [PATCH 01/31] feat: add interactive station selection menu via l/list command --- README.md | 1 + radioactive/__main__.py | 1 + radioactive/utilities.py | 29 ++++++++++++++++++++++++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0f1d435..67cdb8d 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ Enter a command to perform an action: ? t/T/track: Current song name (track info) r/R/record: Record a station f/F/fav: Add station to favorite list +l/L/list: Open favorite station selection menu s/S/search: Search for a new station n/N/next: Play next station from search results or favorite list timer/sleep: Set a sleep timer (duration in minutes) diff --git a/radioactive/__main__.py b/radioactive/__main__.py index 875b99c..bdca4db 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -111,6 +111,7 @@ def final_step(options, last_station, alias, handler, history, station_list=None record_file_format=options["record_file_format"], loglevel=options["loglevel"], handler=handler, + last_station=last_station, station_list=station_list, ) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 461c656..bf58f1f 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -204,6 +204,7 @@ def handle_listen_keypress( record_file_format, loglevel, handler=None, + last_station=None, station_list=None, ) -> None: """ @@ -309,9 +310,29 @@ def stop_playback(): player.stop() sys.exit(0) - elif user_input in ["w", "W", "list"]: - alias.generate_map() - handle_favorite_table(alias) + # elif user_input in ["w", "W"]: + # alias.generate_map() + # handle_favorite_table(alias) + + elif user_input in ["l", "L", "list"]: + if handler and last_station: + try: + new_station_name, new_target_url = handle_station_selection_menu( + handler, last_station, alias + ) + if new_target_url: + player.stop() + player.url = new_target_url + player.play() + handle_current_play_panel(new_station_name) + # Update loop variables + station_name = new_station_name + station_url = new_target_url + target_url = new_target_url + except Exception as e: + log.error(f"Error selecting station: {e}") + else: + log.warning("Station selection menu unavailable") elif TRACK_FEATURE and user_input in ["t", "T", "track"]: handle_fetch_song_title(target_url) @@ -477,6 +498,8 @@ def stop_playback(): log.info("r/record: Record a station") log.info("rf/recordfile: Specify a filename for the recording") log.info("f/fav: Add station to favorite list") + log.info("l/list: Open favorite station selection menu") + # log.info("w: Show favorite station table") if SEARCH_FEATURE: log.info("s/search: Search for a new station") if CYCLE_FEATURE: From 0af07ddffdba1051389497c8926106fa96e6f0e8 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 20:00:41 +0530 Subject: [PATCH 02/31] feat: add station cycling support and integrate automatic station info display across play handlers --- radioactive/utilities.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index bf58f1f..00d747f 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -156,7 +156,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: log.debug("Asking for user input") try: - log.info("Type 'r' to play a random station") + log.info("Type 'r' for a random station, 'n' to cycle through the list") user_input = input("Type the result ID to play: ") except EOFError: print() @@ -165,6 +165,11 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: sys.exit(0) try: + if user_input in ["n", "N", "next"]: + # user want's to play the first one and move on? + user_input = 1 + log.debug("Next station requested, picking first one") + if user_input in ["r", "R", "random"]: # pick a random integer withing range user_input = randint(1, len(response) - 1) @@ -180,6 +185,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: # saving global info set_global_station_info(target_response) + handle_show_station_info() return handle_station_uuid_play(handler, target_response["stationuuid"]) else: @@ -432,6 +438,7 @@ def stop_playback(): if source_type == "search": # It's a full station object set_global_station_info(target_station) + handle_show_station_info() new_station_name, new_target_url = handle_station_uuid_play( handler, target_station["stationuuid"] ) @@ -451,6 +458,7 @@ def stop_playback(): # Direct URL temp_info["url"] = uuid_or_url set_global_station_info(temp_info) + handle_show_station_info() new_station_name = target_station["name"] new_target_url = uuid_or_url # Allow direct play without UUID handler @@ -458,6 +466,7 @@ def stop_playback(): # UUID temp_info["stationuuid"] = uuid_or_url set_global_station_info(temp_info) + handle_show_station_info() new_station_name, new_target_url = ( handle_station_uuid_play(handler, uuid_or_url) ) From bd68f52fbc704b5aac755a53b35af84b7048203f Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 20:20:13 +0530 Subject: [PATCH 03/31] feat: add volume control support for mpv, vlc, and ffplay players --- README.md | 2 ++ radioactive/__main__.py | 4 ++-- radioactive/ffplay.py | 10 ++++++++++ radioactive/mpv.py | 13 +++++++++++-- radioactive/utilities.py | 21 +++++++++++++++++++++ radioactive/vlc.py | 16 ++++++++++++++-- 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 67cdb8d..1006cd4 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ l/L/list: Open favorite station selection menu s/S/search: Search for a new station n/N/next: Play next station from search results or favorite list timer/sleep: Set a sleep timer (duration in minutes) +v <0-100>: Set volume +v+/v-: Increase/Decrease volume rf/RF/recordfile: Specify a filename for the recording. h/H/help/?: Show this help message q/Q/quit: Quit radioactive diff --git a/radioactive/__main__.py b/radioactive/__main__.py index bdca4db..a79cbc7 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -54,14 +54,14 @@ def final_step(options, last_station, alias, handler, history, station_list=None if options["audio_player"] == "vlc": from radioactive.vlc import VLC - vlc = VLC() + vlc = VLC(options["volume"]) vlc.start(options["target_url"]) player = vlc elif options["audio_player"] == "mpv": from radioactive.mpv import MPV - mpv = MPV() + mpv = MPV(options["volume"]) mpv.start(options["target_url"]) player = mpv diff --git a/radioactive/ffplay.py b/radioactive/ffplay.py index ff17c88..1720b03 100644 --- a/radioactive/ffplay.py +++ b/radioactive/ffplay.py @@ -209,3 +209,13 @@ def toggle(self) -> None: else: log.debug("Starting the ffplay process") self.start_process() + + def set_volume(self, volume: int) -> None: + """Update the volume level and restart playback if active.""" + self.volume = volume + log.info(f"Volume set to {self.volume}") + if self.is_playing: + log.debug("Restarting ffplay with new volume") + # We don't want to kill the parent process if stop() fails + self.stop() + self.start_process() diff --git a/radioactive/mpv.py b/radioactive/mpv.py index 3cebe76..353f3e9 100644 --- a/radioactive/mpv.py +++ b/radioactive/mpv.py @@ -6,9 +6,10 @@ class MPV: - def __init__(self): + def __init__(self, volume: int = 80): self.program_name = "mpv" self.exe_path = which(self.program_name) + self.volume = volume log.debug(f"{self.program_name}: {self.exe_path}") if self.exe_path is None: @@ -20,7 +21,7 @@ def __init__(self): self.url = None def _construct_mpv_commands(self, url): - return [self.exe_path, url] + return [self.exe_path, f"--volume={self.volume}", url] def start(self, url): self.url = url @@ -56,3 +57,11 @@ def toggle(self): def play(self): if not self.is_running and self.url: self.start(self.url) + + def set_volume(self, volume: int): + self.volume = volume + log.info(f"Volume set to {self.volume}") + if self.is_running: + log.debug("Restarting mpv with new volume") + self.stop() + self.start(self.url) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 00d747f..0d3c2fd 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -497,6 +497,25 @@ def stop_playback(): "Cycle/Next unavailable (no search results or favorites to cycle through)" ) + elif user_input == "v+": + new_vol = min(player.volume + 10, 100) + player.set_volume(new_vol) + + elif user_input == "v-": + new_vol = max(player.volume - 10, 0) + player.set_volume(new_vol) + + elif user_input.startswith("v "): + try: + vol_str = user_input.split(" ")[1].strip() + new_vol = int(vol_str) + if 0 <= new_vol <= 100: + player.set_volume(new_vol) + else: + log.error("Volume must be between 0 and 100") + except Exception: + log.error("Invalid volume format. Use 'v 50'") + elif user_input in ["h", "H", "?", "help"]: log.info("p: Play/Pause current station") if TRACK_FEATURE: @@ -509,6 +528,8 @@ def stop_playback(): log.info("f/fav: Add station to favorite list") log.info("l/list: Open favorite station selection menu") # log.info("w: Show favorite station table") + log.info("v <0-100>: Set volume") + log.info("v+/v-: Increase/Decrease volume") if SEARCH_FEATURE: log.info("s/search: Search for a new station") if CYCLE_FEATURE: diff --git a/radioactive/vlc.py b/radioactive/vlc.py index a564d44..f5dfd10 100644 --- a/radioactive/vlc.py +++ b/radioactive/vlc.py @@ -6,9 +6,12 @@ class VLC: - def __init__(self): + def __init__(self, volume: int = 80): self.program_name = "vlc" self.exe_path = which(self.program_name) + self.volume = volume + # Mapping 0-100 to 0-256 + self.mapped_volume = int(self.volume * 2.56) # Check common locations on Windows if self.exe_path is None and sys.platform.startswith("win"): @@ -44,7 +47,7 @@ def __init__(self): self.url = None def _construct_vlc_commands(self, url): - return [self.exe_path, url] + return [self.exe_path, "--volume", str(self.mapped_volume), url] def start(self, url): self.url = url @@ -80,3 +83,12 @@ def toggle(self): def play(self): if not self.is_running and self.url: self.start(self.url) + + def set_volume(self, volume: int): + self.volume = volume + self.mapped_volume = int(self.volume * 2.56) + log.info(f"Volume set to {self.volume}") + if self.is_running: + log.debug("Restarting VLC with new volume") + self.stop() + self.start(self.url) From 39b3c1b2bf222911b8296e2a338aa638df1bb271 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 20:30:48 +0530 Subject: [PATCH 04/31] feat: replace console-based help output with interactive pick menu --- radioactive/utilities.py | 69 ++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 0d3c2fd..43798ae 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -124,6 +124,47 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st return handle_station_uuid_play(handler, station_uuid) +def handle_runtime_help_menu(): + """ + Show a popup-style help menu using 'pick'. + """ + title = "Available Runtime Commands (Press Enter to return):" + options = [] + options.append("p: Play/Pause current station") + + if TRACK_FEATURE: + options.append("t/track: Current track info") + if INFO_FEATURE: + options.append("i/info: Station information") + if RECORDING_FEATURE: + options.append("r/record: Record a station") + options.append("rf/recordfile: Specify a filename for the recording") + + options.append("f/fav: Add station to favorite list") + options.append("l/list: Open favorite station selection menu") + options.append("v <0-100>: Set volume") + options.append("v+/v-: Increase/Decrease volume") + + if SEARCH_FEATURE: + options.append("s/search: Search for a new station") + if CYCLE_FEATURE: + options.append( + "n/next: Play result from next station searching or favorite list" + ) + if TIMER_FEATURE: + options.append("timer/sleep: Set a sleep timer") + + options.append("q/quit: Quit radioactive") + + try: + pick(options, title, indicator="") + except Exception as e: + log.debug(f"Error showing help menu: {e}") + # fallback to simple message if pick fails + log.info("Press Enter to return") + input() + + def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: """ Handle user selection from search results. @@ -517,26 +558,8 @@ def stop_playback(): log.error("Invalid volume format. Use 'v 50'") elif user_input in ["h", "H", "?", "help"]: - log.info("p: Play/Pause current station") - if TRACK_FEATURE: - log.info("t/track: Current track info") - if INFO_FEATURE: - log.info("i/info: Station information") - if RECORDING_FEATURE: - log.info("r/record: Record a station") - log.info("rf/recordfile: Specify a filename for the recording") - log.info("f/fav: Add station to favorite list") - log.info("l/list: Open favorite station selection menu") - # log.info("w: Show favorite station table") - log.info("v <0-100>: Set volume") - log.info("v+/v-: Increase/Decrease volume") - if SEARCH_FEATURE: - log.info("s/search: Search for a new station") - if CYCLE_FEATURE: - log.info( - "n/next: Play result from next station searching or favorite list" - ) - if TIMER_FEATURE: - log.info("timer/sleep: Set a sleep timer") - log.info("h/help/?: Show this help message") - log.info("q/quit: Quit radioactive") + handle_runtime_help_menu() + + elif user_input in ["q", "Q", "quit"]: + player.stop() + sys.exit(0) From 26849b2706d49d1e506710418e57a2af44bfed7d Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 20:35:49 +0530 Subject: [PATCH 05/31] refactor: replace pick with rich-based panel and table for runtime help menu --- radioactive/utilities.py | 87 +++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 43798ae..5962d67 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -126,43 +126,64 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st def handle_runtime_help_menu(): """ - Show a popup-style help menu using 'pick'. + Show a colorful popup-style help menu using 'rich'. + Uses the alternate screen buffer to avoid cluttering the console history. """ - title = "Available Runtime Commands (Press Enter to return):" - options = [] - options.append("p: Play/Pause current station") - - if TRACK_FEATURE: - options.append("t/track: Current track info") - if INFO_FEATURE: - options.append("i/info: Station information") - if RECORDING_FEATURE: - options.append("r/record: Record a station") - options.append("rf/recordfile: Specify a filename for the recording") - - options.append("f/fav: Add station to favorite list") - options.append("l/list: Open favorite station selection menu") - options.append("v <0-100>: Set volume") - options.append("v+/v-: Increase/Decrease volume") - - if SEARCH_FEATURE: - options.append("s/search: Search for a new station") - if CYCLE_FEATURE: - options.append( - "n/next: Play result from next station searching or favorite list" + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + + console = Console() + with console.screen(): + table = Table(box=None, expand=False, border_style="dim") + table.add_column("Command", style="bold cyan", justify="left") + table.add_column("Description", style="green", justify="left") + + # Helper to simplify adding rows + def add(cmd, desc): + table.add_row(cmd, desc) + + add("p", "Play/Pause current station") + if TRACK_FEATURE: + add("t / track", "Current track info") + if INFO_FEATURE: + add("i / info", "Station information") + if RECORDING_FEATURE: + add("r / record", "Record a station") + add("rf / recordfile", "Specify a filename for the recording") + + add("f / fav", "Add station to favorite list") + add("l / list", "Open favorite station selection menu") + add("v <0-100>", "Set volume level") + add("v+ / v-", "Increase / Decrease volume level") + + if SEARCH_FEATURE: + add("s / search", "Search for a new station") + if CYCLE_FEATURE: + add("n / next", "Play next result from search or favorites") + if TIMER_FEATURE: + add("timer / sleep", "Set a sleep timer") + + add("q / quit", "Quit radioactive") + + # Center the table within a panel + help_panel = Panel( + table, + title="[bold magenta]:radio: Available Runtime Commands[/bold magenta]", + subtitle="[blink]Press Enter to return[/blink]", + border_style="magenta", + expand=False, + padding=(1, 4), ) - if TIMER_FEATURE: - options.append("timer/sleep: Set a sleep timer") - options.append("q/quit: Quit radioactive") + # Print the panel centered on the alternate screen + console.print(help_panel, justify="center") - try: - pick(options, title, indicator="") - except Exception as e: - log.debug(f"Error showing help menu: {e}") - # fallback to simple message if pick fails - log.info("Press Enter to return") - input() + # Use console.input() to wait for Enter and avoid prompt capture + try: + console.input() + except (EOFError, KeyboardInterrupt): + pass def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: From a902adc24ec6af280136e152b748bdcc6a536dac Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 21:31:17 +0530 Subject: [PATCH 06/31] feat: implement Vim-style command prompt with tab completion --- radioactive/utilities.py | 95 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 5962d67..3ba7ccc 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -5,8 +5,10 @@ import os import sys +import termios import threading import time +import tty from random import randint from typing import Any, Dict, List, Optional, Tuple, Union @@ -124,6 +126,96 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st return handle_station_uuid_play(handler, station_uuid) +def handle_vim_style_prompt(): + """ + Shows a Vim-style command prompt (:) at the bottom with descriptive Tab completion. + """ + from rich.console import Console + from rich.live import Live + from rich.text import Text + + # Mapping of shortcut/command to descriptive full text + command_map = { + "p": "play/pause", + "t": "track info", + "i": "station info", + "r": "record", + "rf": "record file", + "f": "add favorite", + "l": "list favorites", + "v+": "volume +", + "v-": "volume -", + "v": "set volume", + "s": "search", + "n": "next station", + "timer": "timer", + "sleep": "sleep", + "q": "quit", + "help": "help", + "?": "help", + } + + # Combined list for matching + completions = list(command_map.keys()) + + buffer = "" + + # Helper to capture single key on Linux + def get_key(): + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(sys.stdin.fileno()) + ch = sys.stdin.read(1) + if ch == "\x1b": # Escape sequence + seq = sys.stdin.read(2) + ch += seq + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return ch + + def get_display(text, matches=[]): + # The prompt part + prompt_text = Text("command : ", style="magenta") + prompt_text.append(text, style="bold cyan") + + if matches and text: + # Show a descriptive hint for the first match + first_match = matches[0] + description = command_map.get(first_match, first_match) + + hint_text = Text(f" ({description})", style="dim green") + prompt_text.append(hint_text) + + return prompt_text + + with Live(get_display(""), transient=True, refresh_per_second=10) as live: + while True: + char = get_key() + + if char in ["\r", "\n"]: # Enter + return buffer.strip() + + elif char in ["\x7f", "\x08"]: # Backspace + buffer = buffer[:-1] + + elif char == "\t": # Tab + # Cycle through matches or just pick first + matches = [m for m in completions if m.startswith(buffer)] + if matches: + buffer = matches[0] + + elif char in ["\x03", "\x1b"]: # Ctrl+C or ESC + return "q" if char == "\x1b" else "" + + elif len(char) == 1: # printable + buffer += char + + # Find matches to show hints + matches = [m for m in completions if buffer and m.startswith(buffer)] + live.update(get_display(buffer, matches)) + + def handle_runtime_help_menu(): """ Show a colorful popup-style help menu using 'rich'. @@ -282,7 +374,8 @@ def handle_listen_keypress( log.info("Press '?' to see available commands\n") while True: try: - user_input = input("Enter a command to perform an action: ") + user_input = handle_vim_style_prompt() + # print for logging/debugging consistency? No, user wants it clean. except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") From 3c73caad10d12f679bbb440cd4cddc0aa60656fc Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 21:47:52 +0530 Subject: [PATCH 07/31] feat: implement fuzzy station search and auto-completion in the Vim-style command prompt --- README.md | 41 +++++++++------- radioactive/__main__.py | 1 + radioactive/utilities.py | 103 +++++++++++++++++++++++++++++++-------- 3 files changed, 107 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 1006cd4..aae2f3c 100644 --- a/README.md +++ b/README.md @@ -207,24 +207,29 @@ This will countdown until 18:30, then record the station for 30 minutes, and exi ### Runtime Commands -Input a command during the radio playback to perform an action. Available commands are: - -``` -Enter a command to perform an action: ? - -t/T/track: Current song name (track info) -r/R/record: Record a station -f/F/fav: Add station to favorite list -l/L/list: Open favorite station selection menu -s/S/search: Search for a new station -n/N/next: Play next station from search results or favorite list -timer/sleep: Set a sleep timer (duration in minutes) -v <0-100>: Set volume -v+/v-: Increase/Decrease volume -rf/RF/recordfile: Specify a filename for the recording. -h/H/help/?: Show this help message -q/Q/quit: Quit radioactive -``` +Radioactive features a modern, **Vim-style command bar** at the bottom of the screen. Instead of the old prompt, you now see a subtle `:` where you can type commands and search for stations. + +#### Available Commands: +| Shortcut | Full Command | Description | +| :--- | :--- | :--- | +| `p` | `play/pause` | Toggle current station playback | +| `t` | `track` | Show current track info | +| `i` | `info` | Show station details | +| `r` | `record` | Start/Stop recording | +| `rf` | `recordfile` | Record with a specific filename | +| `f` | `fav` | Add current station to favorites | +| `l` | `list` | Open favorite selection menu | +| `s` | `search` | Search for a new station online | +| `n` | `next` | Play next station (from search/favs) | +| `timer` | `sleep` | Set a sleep timer | +| `v` | `volume` | Set volume (e.g., `v 50`) | +| `v+` / `v-` | | Increase / Decrease volume | +| `q` | `quit` | Exit Radioactive | + +#### Power Features: +* **Tab Completion**: Type a few letters and press `Tab` or `Right Arrow` to auto-complete commands and station names. +* **Instant Suggestions**: As you type, the bar shows descriptive hints (e.g., typing `p` shows `(play/pause)`). +* **Universal Fuzzy Search**: If your input doesn't match a command, Radioactive instantly searches your **Favorites** and **History**. Just type the name of a station and press `Enter` to play it immediately! ### Sort Parameters diff --git a/radioactive/__main__.py b/radioactive/__main__.py index a79cbc7..8521ca0 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -113,6 +113,7 @@ def final_step(options, last_station, alias, handler, history, station_list=None handler=handler, last_station=last_station, station_list=station_list, + history=history, ) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 3ba7ccc..8a24937 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -126,9 +126,10 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st return handle_station_uuid_play(handler, station_uuid) -def handle_vim_style_prompt(): +def handle_vim_style_prompt(alias=None, history=None): """ - Shows a Vim-style command prompt (:) at the bottom with descriptive Tab completion. + Shows a Vim-style command prompt (:) at the bottom with descriptive Tab completion + and fuzzy search for favorites/history stations. """ from rich.console import Console from rich.live import Live @@ -158,6 +159,16 @@ def handle_vim_style_prompt(): # Combined list for matching completions = list(command_map.keys()) + # Build a list of station names for fuzzy search + station_names = [] + if alias and hasattr(alias, "alias_map"): + station_names += [s.get("name", "").strip() for s in alias.alias_map] + if history and hasattr(history, "get_list"): + station_names += [s.get("name", "").strip() for s in history.get_list()] + + # Clean and deduplicate station names + station_names = sorted(list(set([n for n in station_names if n]))) + buffer = "" # Helper to capture single key on Linux @@ -179,13 +190,26 @@ def get_display(text, matches=[]): prompt_text = Text("command : ", style="magenta") prompt_text.append(text, style="bold cyan") - if matches and text: - # Show a descriptive hint for the first match - first_match = matches[0] - description = command_map.get(first_match, first_match) - - hint_text = Text(f" ({description})", style="dim green") - prompt_text.append(hint_text) + if buffer: + # Check commands first + cmd_matches = [m for m in completions if m.startswith(buffer)] + if cmd_matches: + first_match = cmd_matches[0] + description = command_map.get(first_match, first_match) + hint_text = Text(f" ({description})", style="dim green") + prompt_text.append(hint_text) + else: + # No command match, trigger fuzzy search for stations + station_matches = [ + n for n in station_names if buffer.lower() in n.lower() + ] + # simple sort by position of query in the name + station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) + + if station_matches: + first_match = station_matches[0] + hint_text = Text(f" (~ {first_match})", style="italic dim yellow") + prompt_text.append(hint_text) return prompt_text @@ -193,27 +217,43 @@ def get_display(text, matches=[]): while True: char = get_key() + # Find current matches for logic below + cmd_matches = [m for m in completions if buffer and m.startswith(buffer)] + station_matches = [] + if not cmd_matches and buffer: + station_matches = [ + n for n in station_names if buffer.lower() in n.lower() + ] + station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) + if char in ["\r", "\n"]: # Enter + # If there's a fuzzy station match, use it. Otherwise use the buffer. + if not cmd_matches and station_matches: + return station_matches[0] return buffer.strip() elif char in ["\x7f", "\x08"]: # Backspace buffer = buffer[:-1] - elif char == "\t": # Tab - # Cycle through matches or just pick first - matches = [m for m in completions if m.startswith(buffer)] - if matches: - buffer = matches[0] + elif char == "\t" or char == "\x1b[C": # Tab or Right Arrow + # Auto-complete to the first match + if cmd_matches: + buffer = cmd_matches[0] + elif station_matches: + buffer = station_matches[0] elif char in ["\x03", "\x1b"]: # Ctrl+C or ESC - return "q" if char == "\x1b" else "" + # ESC can produce \x1b followed by nothing if caught fast, + # but arrows also start with \x1b. get_key handles sequences. + if char == "\x1b": + return "q" + return "" elif len(char) == 1: # printable buffer += char - # Find matches to show hints - matches = [m for m in completions if buffer and m.startswith(buffer)] - live.update(get_display(buffer, matches)) + # Update display with new buffer state + live.update(get_display(buffer, cmd_matches or station_matches)) def handle_runtime_help_menu(): @@ -257,6 +297,7 @@ def add(cmd, desc): add("timer / sleep", "Set a sleep timer") add("q / quit", "Quit radioactive") + add("Any Text", "Fuzzy search & play from favorites/history") # Center the table within a panel help_panel = Panel( @@ -366,6 +407,7 @@ def handle_listen_keypress( handler=None, last_station=None, station_list=None, + history=None, ) -> None: """ Listen for user input during playback to perform actions. @@ -374,7 +416,7 @@ def handle_listen_keypress( log.info("Press '?' to see available commands\n") while True: try: - user_input = handle_vim_style_prompt() + user_input = handle_vim_style_prompt(alias, history) # print for logging/debugging consistency? No, user wants it clean. except EOFError: print() @@ -671,9 +713,30 @@ def stop_playback(): except Exception: log.error("Invalid volume format. Use 'v 50'") - elif user_input in ["h", "H", "?", "help"]: + elif user_input == "?": handle_runtime_help_menu() elif user_input in ["q", "Q", "quit"]: player.stop() sys.exit(0) + + elif user_input.strip() != "": + # Fuzzy match station from aliases or history if not a direct command + # Try to see if it's a station name the user typed + log.info(f"Checking for station: {user_input}") + try: + name, url = handle_direct_play(alias, user_input) + if url: + player.stop() + player.url = url + player.play() + handle_current_play_panel(name) + station_url = url + station_name = name + target_url = url + except SystemExit: + # Direct play sys.exit(1) on failure, we want to stay in loop + pass + except Exception as e: + log.debug(f"Error in fuzzy station search: {e}") + log.warning(f"Unknown command or station: {user_input}") From ed05a0ff4bdfd0ea3fee5f974e119023029746b7 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 22:56:54 +0530 Subject: [PATCH 08/31] chore: bump package version to 4.0.0 --- radioactive/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radioactive/app.py b/radioactive/app.py index 8074c4e..b606a3e 100644 --- a/radioactive/app.py +++ b/radioactive/app.py @@ -18,7 +18,7 @@ def __init__(self): try: self.__VERSION__ = metadata.version("radio-active") except metadata.PackageNotFoundError: - self.__VERSION__ = "3.0.4" # change this on every update # + self.__VERSION__ = "4.0.0" # change this on every update # self.pypi_api = "https://pypi.org/pypi/radio-active/json" self.remote_version = "" From b497067c4e1c4c34b7955e62ec03aa2b6caa8236 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 4 Apr 2026 23:04:38 +0530 Subject: [PATCH 09/31] docs: add release notes for version 4.0.0 to CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1becfd..f30de35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 4.0.0 + +1. Vim-style command prompt with advanced tab completion 🚀 +2. Real-time fuzzy station search across favorites and history +3. Integrated volume control for MPV, VLC, and FFplay players +4. Interactive station selection menu via `l/list` command +5. Improved help menu using a Rich-based interactive interface + ## 3.0.4 1. Release notes are now shown to the end users for future versions From ac2fc4ee286f445d989d3a5c9fa5ebba52e454c8 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 19:40:25 +0530 Subject: [PATCH 10/31] refactor: implement persistent Rich Live UI display with centralized state management and input handling --- radioactive/__main__.py | 10 +- radioactive/actions.py | 9 +- radioactive/handler.py | 1 - radioactive/ui.py | 195 +++++++++++++++++++++++++++++++-------- radioactive/utilities.py | 106 ++++++++++++++++----- 5 files changed, 254 insertions(+), 67 deletions(-) diff --git a/radioactive/__main__.py b/radioactive/__main__.py index 8521ca0..b4b4b64 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -22,6 +22,7 @@ handle_direct_play, handle_favorite_table, handle_history_table, + handle_input, handle_listen_keypress, handle_play_last_station, handle_play_random_station, @@ -34,6 +35,7 @@ handle_update_screen, handle_user_choice_from_search_result, handle_welcome_screen, + stop_live_display, ) # globally needed as signal handler needs it @@ -209,11 +211,13 @@ def main(): if os.path.exists(full_path): log.warning(f"File '{full_path}' already exists.") while True: - user_choice = input("File already exists. Overwrite? (y/n): ").lower() + user_choice = handle_input( + "File already exists. Overwrite? (y/n): " + ).lower() if user_choice == "y": break elif user_choice == "n": - new_name = input("Enter new filename (without extension): ") + new_name = handle_input("Enter new filename (without extension): ") options["record_file"] = new_name # re-calculate final_filename = new_name @@ -483,6 +487,8 @@ def main(): def signal_handler(sig, frame): log.debug("You pressed Ctrl+C!") log.debug("Stopping the radio") + # always needed + stop_live_display() if ffplay and ffplay.is_playing: ffplay.stop() # kill the player diff --git a/radioactive/actions.py b/radioactive/actions.py index f63e357..b9bd228 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -22,6 +22,7 @@ if RECORDING_FEATURE: from radioactive.recorder import record_audio_auto_codec, record_audio_from_url from radioactive.last_station import Last_station +from radioactive.ui import handle_input def handle_fetch_song_title(url: str) -> None: @@ -149,8 +150,8 @@ def handle_record( def handle_add_station(alias) -> None: """Add a new station to favorites via user input.""" try: - left = input("Enter station name:") - right = input("Enter station stream-url or radio-browser uuid:") + left = handle_input("Enter station name:") + right = handle_input("Enter station stream-url or radio-browser uuid:") except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") @@ -170,7 +171,7 @@ def handle_add_to_favorite(alias, station_name: str, station_uuid_url: str) -> N response = alias.add_entry(station_name, station_uuid_url) if not response: try: - user_input = input("Enter a different name: ") + user_input = handle_input("Enter a different name: ") except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") @@ -210,7 +211,7 @@ def handle_save_to_history(history, station_name: str, station_url: str) -> None station_data["uuid_or_url"] = station_url # try to get richer info - from radioactive.ui import get_global_station_info + from radioactive.ui import get_global_station_info, handle_input global_info = get_global_station_info() if global_info and global_info.get("name") == station_name: diff --git a/radioactive/handler.py b/radioactive/handler.py index 1e4ca9d..e849564 100644 --- a/radioactive/handler.py +++ b/radioactive/handler.py @@ -182,7 +182,6 @@ def validate_uuid_station(self) -> List[Dict[str, Any]]: """ if self.response and len(self.response) >= 1: # We take the first one if multiple (unlikely for UUID but possible in theory) - log.debug(json.dumps(self.response[0], indent=3)) self.target_station = self.response[0] # register a valid click to increase its popularity diff --git a/radioactive/ui.py b/radioactive/ui.py index f17d99f..50bc768 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -2,8 +2,11 @@ UI components for radio-active using Rich. """ +from typing import Any, Optional + from rich import print -from rich.console import Console +from rich.console import Console, Group +from rich.live import Live from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -13,25 +16,71 @@ # This is shared state, ideally should be managed better, but keeping for compatibility global_current_station_info = {} +# Persistent Live display elements +_console = Console() +_live_display = None -def handle_welcome_screen() -> None: - """Print the welcome screen panel.""" - welcome = Panel( +# UI State components +_app_banner = None +_station_banner = None +_prompt_renderable = None + + +def _get_banner_panel(curr_station_name: str) -> Panel: + """Internal helper to create the banner panel.""" + panel_station_name = Text(curr_station_name, justify="center") + return Panel( + panel_station_name, title=":radio: Current Station", width=80, expand=False + ) + + +def _get_welcome_panel() -> Panel: + """Internal helper to create the app banner.""" + return Panel( """ :radio: Play any radios around the globe right from this Terminal [yellow]:zap:[/yellow]! :smile: Author: Dipankar Pal - :question: Type '--help' for more details on available commands - :bug: Visit: https://github.com/deep5050/radio-active to submit issues - :star: Show some love by starring the project on GitHub [red]:heart:[/red] - :dollar: You can donate me at https://deep5050.github.io/payme/ + :question: Type '?' for help, any string to search favorites/history :x: Press Ctrl+C to quit """, title="[b]RADIOACTIVE[/b]", - width=85, - expand=True, + width=80, + expand=False, safe_box=True, ) - print(welcome) + + +def _get_composite_renderable(): + """Combine all active UI components into a single Group.""" + components = [] + if _app_banner: + components.append(_app_banner) + if _station_banner: + components.append(_station_banner) + if _prompt_renderable: + components.append(_prompt_renderable) + + return Group(*components) + + +def refresh_live_display(): + """Update the persistent live display with the current composite renderable.""" + if _live_display: + _live_display.update(_get_composite_renderable(), refresh=True) + + +def handle_welcome_screen() -> None: + """ + Prepare the app banner for the playback UI. + + Do not start Rich Live here: interactive flows (e.g. ``pick`` for favorites) + use curses and break Live's cursor accounting. Live starts in + handle_current_play_panel when the main TUI is shown. + """ + global _app_banner + _app_banner = _get_welcome_panel() + if _live_display is not None: + refresh_live_display() def handle_update_screen(app) -> None: @@ -60,9 +109,10 @@ def handle_update_screen(app) -> None: update_panel = Panel( update_msg, - width=85, + width=80, + expand=False, ) - print(update_panel) + _console.print(update_panel) else: log.debug("Update not available") @@ -77,9 +127,9 @@ def handle_favorite_table(alias) -> None: table = Table( show_header=True, header_style="bold magenta", - min_width=85, + width=80, safe_box=False, - expand=True, + expand=False, ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") @@ -87,7 +137,7 @@ def handle_favorite_table(alias) -> None: if len(alias.alias_map) > 0: for entry in alias.alias_map: table.add_row(entry["name"], entry["uuid_or_url"]) - print(table) + _console.print(table) log.info(f"Your favorite stations are saved in {alias.alias_path}") else: log.info("You have no favorite station list") @@ -103,9 +153,9 @@ def handle_history_table(history) -> None: table = Table( show_header=True, header_style="bold magenta", - min_width=85, + width=80, safe_box=False, - expand=True, + expand=False, ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") @@ -113,7 +163,7 @@ def handle_history_table(history) -> None: if len(history.history_list) > 0: for entry in history.history_list: table.add_row(entry["name"], entry["uuid_or_url"]) - print(table) + _console.print(table) log.info(f"Your history is saved in {history.history_path}") else: log.info("You have no history") @@ -121,35 +171,104 @@ def handle_history_table(history) -> None: def handle_show_station_info() -> None: """Show important information regarding the current station.""" - # pylint: disable=global-statement - custom_info = {} + custom_info = [] try: - custom_info["name"] = global_current_station_info.get("name") - custom_info["uuid"] = global_current_station_info.get("stationuuid") - custom_info["url"] = global_current_station_info.get("url") - custom_info["website"] = global_current_station_info.get("homepage") - custom_info["country"] = global_current_station_info.get("country") - custom_info["language"] = global_current_station_info.get("language") - custom_info["tags"] = global_current_station_info.get("tags") - custom_info["codec"] = global_current_station_info.get("codec") - custom_info["bitrate"] = global_current_station_info.get("bitrate") - print(custom_info) + # Create a clean table for station info instead of a dict dump + table = Table(box=None, padding=(0, 2)) + table.add_column("Property", style="cyan") + table.add_column("Value", style="white") + + table.add_row("Name", global_current_station_info.get("name")) + table.add_row("URL", global_current_station_info.get("url")) + table.add_row("Website", global_current_station_info.get("homepage")) + table.add_row("Country", global_current_station_info.get("country")) + table.add_row("Language", global_current_station_info.get("language")) + table.add_row("Codec", global_current_station_info.get("codec")) + table.add_row("Bitrate", str(global_current_station_info.get("bitrate", "N/A"))) + + info_panel = Panel(table, title="[b]Station Info[/b]", width=80, expand=False) + _console.print(info_panel) except Exception as e: log.error(f"No station information available: {e}") def handle_current_play_panel(curr_station_name: str = "") -> None: """ - Print the currently playing station panel. + Update the current station banner and manage the Live display lifecycle. + """ + global _live_display, _station_banner + _station_banner = _get_banner_panel(curr_station_name) - Args: - curr_station_name (str): Name of the station. + if _live_display is None: + _live_display = Live( + _get_composite_renderable(), + console=_console, + auto_refresh=False, + transient=False, + ) + _live_display.start() + else: + refresh_live_display() + + +def stop_live_display() -> None: + """Stop the global live display.""" + global _live_display, _prompt_renderable + if _live_display: + try: + _live_display.stop() + except Exception: + pass + _live_display = None + _prompt_renderable = None + + +def set_live_display(live: Optional[Live]) -> None: + """Set the active live display instance.""" + global _live_display + _live_display = live + + +def get_live_display() -> Optional[Live]: + """Return the active live display instance.""" + return _live_display + + +def get_current_banner() -> Optional[Group]: """ - panel_station_name = Text(curr_station_name, justify="center") + Return app + station banners as one Group for standalone display (e.g. fallback Live). + None if neither banner is set. + """ + parts = [c for c in [_app_banner, _station_banner] if c is not None] + if not parts: + return None + return Group(*parts) + + +def update_prompt_renderable(renderable: Optional[Any] = None): + """Update the prompt portion of the persistent UI.""" + global _prompt_renderable + _prompt_renderable = renderable + refresh_live_display() + - station_panel = Panel(panel_station_name, title="[blink]:radio:[/blink]", width=85) - console = Console() - console.print(station_panel) +def handle_input(prompt: str) -> str: + """ + Standard input wrapper. Uses the active live display's console if available + to prevent UI corruption. + """ + try: + if _live_display: + # use the live console to print above the banner + res = _live_display.console.input(prompt) + else: + res = input(prompt) + return res + except EOFError: + return "" + except Exception as e: + log.debug(f"Input error: {e}") + return "" def set_global_station_info(info: dict) -> None: diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 8a24937..4fcb4d2 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -51,17 +51,21 @@ handle_station_uuid_play, ) from radioactive.ffplay import kill_background_ffplays - -# Re-export functions for backward compatibility and aggregation from radioactive.ui import ( + get_current_banner, get_global_station_info, + get_live_display, handle_current_play_panel, handle_favorite_table, handle_history_table, + handle_input, handle_show_station_info, handle_update_screen, handle_welcome_screen, set_global_station_info, + set_live_display, + stop_live_display, + update_prompt_renderable, ) RED_COLOR = "\033[91m" @@ -109,6 +113,10 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st ) sys.exit(0) + # pick() uses curses; Rich Live must not be active or the terminal state breaks + # and panels redraw on top of each other. + stop_live_display() + _, index = pick(options, title, indicator="-->") # check if there is direct URL or just UUID @@ -128,10 +136,9 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st def handle_vim_style_prompt(alias=None, history=None): """ - Shows a Vim-style command prompt (:) at the bottom with descriptive Tab completion + Shows a Vim-style command prompt at the bottom with descriptive Tab completion and fuzzy search for favorites/history stations. """ - from rich.console import Console from rich.live import Live from rich.text import Text @@ -185,13 +192,16 @@ def get_key(): termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch - def get_display(text, matches=[]): - # The prompt part - prompt_text = Text("command : ", style="magenta") - prompt_text.append(text, style="bold cyan") + def build_prompt_renderable(): + """ + Only the command line. App and station banners are drawn by the global Live + composite (_get_composite_renderable); nesting them here duplicated the UI. + """ + prompt_text = Text("command:", style="bold magenta") + prompt_text.append(" ", style="default") + prompt_text.append(buffer, style="bold cyan") if buffer: - # Check commands first cmd_matches = [m for m in completions if m.startswith(buffer)] if cmd_matches: first_match = cmd_matches[0] @@ -199,11 +209,9 @@ def get_display(text, matches=[]): hint_text = Text(f" ({description})", style="dim green") prompt_text.append(hint_text) else: - # No command match, trigger fuzzy search for stations station_matches = [ n for n in station_names if buffer.lower() in n.lower() ] - # simple sort by position of query in the name station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) if station_matches: @@ -213,7 +221,20 @@ def get_display(text, matches=[]): return prompt_text - with Live(get_display(""), transient=True, refresh_per_second=10) as live: + def full_fallback_renderable(): + """When no global Live exists, stack banners + prompt in one transient Live.""" + from rich.console import Group + + prompt_r = build_prompt_renderable() + banner = get_current_banner() + if banner is not None: + return Group(banner, prompt_r) + return prompt_r + + live = get_live_display() + if live: + # Show prompt before blocking on first key; banners come from global composite only. + update_prompt_renderable(build_prompt_renderable()) while True: char = get_key() @@ -227,7 +248,8 @@ def get_display(text, matches=[]): station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) if char in ["\r", "\n"]: # Enter - # If there's a fuzzy station match, use it. Otherwise use the buffer. + # reset display to just banner (remove prompt) + update_prompt_renderable(None) if not cmd_matches and station_matches: return station_matches[0] return buffer.strip() @@ -243,8 +265,7 @@ def get_display(text, matches=[]): buffer = station_matches[0] elif char in ["\x03", "\x1b"]: # Ctrl+C or ESC - # ESC can produce \x1b followed by nothing if caught fast, - # but arrows also start with \x1b. get_key handles sequences. + update_prompt_renderable(None) if char == "\x1b": return "q" return "" @@ -252,8 +273,43 @@ def get_display(text, matches=[]): elif len(char) == 1: # printable buffer += char - # Update display with new buffer state - live.update(get_display(buffer, cmd_matches or station_matches)) + update_prompt_renderable(build_prompt_renderable()) + else: + # Fallback to local transient live display if no persistent one exists + with Live( + full_fallback_renderable(), transient=True, auto_refresh=False + ) as live: + while True: + char = get_key() + # Find current matches + cmd_matches = [ + m for m in completions if buffer and m.startswith(buffer) + ] + station_matches = [] + if not cmd_matches and buffer: + station_matches = [ + n for n in station_names if buffer.lower() in n.lower() + ] + station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) + + if char in ["\r", "\n"]: # Enter + if not cmd_matches and station_matches: + return station_matches[0] + return buffer.strip() + elif char in ["\x7f", "\x08"]: # Backspace + buffer = buffer[:-1] + elif char == "\t" or char == "\x1b[C": # Tab or Right Arrow + if cmd_matches: + buffer = cmd_matches[0] + elif station_matches: + buffer = station_matches[0] + elif char in ["\x03", "\x1b"]: # Ctrl+C or ESC + if char == "\x1b": + return "q" + return "" + elif len(char) == 1: # printable + buffer += char + live.update(full_fallback_renderable()) def handle_runtime_help_menu(): @@ -310,6 +366,7 @@ def add(cmd, desc): ) # Print the panel centered on the alternate screen + stop_live_display() console.print(help_panel, justify="center") # Use console.input() to wait for Enter and avoid prompt capture @@ -318,6 +375,11 @@ def add(cmd, desc): except (EOFError, KeyboardInterrupt): pass + # Restore the live banner if a station was playing + global_info = get_global_station_info() + if global_info: + handle_current_play_panel(global_info.get("name", "Unknown Station")) + def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: """ @@ -332,7 +394,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: log.debug("Exactly one result found") try: - user_input = input("Want to play this station? Y/N: ") + user_input = handle_input("Want to play this station? Y/N: ") except EOFError: print() sys.exit(0) @@ -352,7 +414,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: try: log.info("Type 'r' for a random station, 'n' to cycle through the list") - user_input = input("Type the result ID to play: ") + user_input = handle_input("Type the result ID to play: ") except EOFError: print() log.info("Exiting") @@ -436,7 +498,7 @@ def handle_listen_keypress( ) elif user_input in ["rf", "RF", "recordfile"]: try: - user_input = input("Enter output filename: ") + user_input = handle_input("Enter output filename: ") except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") @@ -478,7 +540,7 @@ def handle_listen_keypress( elif TIMER_FEATURE and user_input in ["timer", "sleep"]: try: - duration_str = input("Enter sleep timer duration in minutes: ") + duration_str = handle_input("Enter sleep timer duration in minutes: ") duration = float(duration_str) if duration <= 0: log.error("Duration must be positive") @@ -546,7 +608,7 @@ def stop_playback(): elif SEARCH_FEATURE and user_input in ["s", "S", "search"]: if handler: try: - query = input("Enter station name to search: ") + query = handle_input("Enter station name to search: ") except EOFError: continue From 2f3a1e5a40dddfb014097d1593ff81144445c690 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 19:40:37 +0530 Subject: [PATCH 11/31] Revert "refactor: implement persistent Rich Live UI display with centralized state management and input handling" This reverts commit ac2fc4ee286f445d989d3a5c9fa5ebba52e454c8. --- radioactive/__main__.py | 10 +- radioactive/actions.py | 9 +- radioactive/handler.py | 1 + radioactive/ui.py | 195 ++++++++------------------------------- radioactive/utilities.py | 106 +++++---------------- 5 files changed, 67 insertions(+), 254 deletions(-) diff --git a/radioactive/__main__.py b/radioactive/__main__.py index b4b4b64..8521ca0 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -22,7 +22,6 @@ handle_direct_play, handle_favorite_table, handle_history_table, - handle_input, handle_listen_keypress, handle_play_last_station, handle_play_random_station, @@ -35,7 +34,6 @@ handle_update_screen, handle_user_choice_from_search_result, handle_welcome_screen, - stop_live_display, ) # globally needed as signal handler needs it @@ -211,13 +209,11 @@ def main(): if os.path.exists(full_path): log.warning(f"File '{full_path}' already exists.") while True: - user_choice = handle_input( - "File already exists. Overwrite? (y/n): " - ).lower() + user_choice = input("File already exists. Overwrite? (y/n): ").lower() if user_choice == "y": break elif user_choice == "n": - new_name = handle_input("Enter new filename (without extension): ") + new_name = input("Enter new filename (without extension): ") options["record_file"] = new_name # re-calculate final_filename = new_name @@ -487,8 +483,6 @@ def main(): def signal_handler(sig, frame): log.debug("You pressed Ctrl+C!") log.debug("Stopping the radio") - # always needed - stop_live_display() if ffplay and ffplay.is_playing: ffplay.stop() # kill the player diff --git a/radioactive/actions.py b/radioactive/actions.py index b9bd228..f63e357 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -22,7 +22,6 @@ if RECORDING_FEATURE: from radioactive.recorder import record_audio_auto_codec, record_audio_from_url from radioactive.last_station import Last_station -from radioactive.ui import handle_input def handle_fetch_song_title(url: str) -> None: @@ -150,8 +149,8 @@ def handle_record( def handle_add_station(alias) -> None: """Add a new station to favorites via user input.""" try: - left = handle_input("Enter station name:") - right = handle_input("Enter station stream-url or radio-browser uuid:") + left = input("Enter station name:") + right = input("Enter station stream-url or radio-browser uuid:") except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") @@ -171,7 +170,7 @@ def handle_add_to_favorite(alias, station_name: str, station_uuid_url: str) -> N response = alias.add_entry(station_name, station_uuid_url) if not response: try: - user_input = handle_input("Enter a different name: ") + user_input = input("Enter a different name: ") except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") @@ -211,7 +210,7 @@ def handle_save_to_history(history, station_name: str, station_url: str) -> None station_data["uuid_or_url"] = station_url # try to get richer info - from radioactive.ui import get_global_station_info, handle_input + from radioactive.ui import get_global_station_info global_info = get_global_station_info() if global_info and global_info.get("name") == station_name: diff --git a/radioactive/handler.py b/radioactive/handler.py index e849564..1e4ca9d 100644 --- a/radioactive/handler.py +++ b/radioactive/handler.py @@ -182,6 +182,7 @@ def validate_uuid_station(self) -> List[Dict[str, Any]]: """ if self.response and len(self.response) >= 1: # We take the first one if multiple (unlikely for UUID but possible in theory) + log.debug(json.dumps(self.response[0], indent=3)) self.target_station = self.response[0] # register a valid click to increase its popularity diff --git a/radioactive/ui.py b/radioactive/ui.py index 50bc768..f17d99f 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -2,11 +2,8 @@ UI components for radio-active using Rich. """ -from typing import Any, Optional - from rich import print -from rich.console import Console, Group -from rich.live import Live +from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -16,71 +13,25 @@ # This is shared state, ideally should be managed better, but keeping for compatibility global_current_station_info = {} -# Persistent Live display elements -_console = Console() -_live_display = None - -# UI State components -_app_banner = None -_station_banner = None -_prompt_renderable = None - - -def _get_banner_panel(curr_station_name: str) -> Panel: - """Internal helper to create the banner panel.""" - panel_station_name = Text(curr_station_name, justify="center") - return Panel( - panel_station_name, title=":radio: Current Station", width=80, expand=False - ) - -def _get_welcome_panel() -> Panel: - """Internal helper to create the app banner.""" - return Panel( +def handle_welcome_screen() -> None: + """Print the welcome screen panel.""" + welcome = Panel( """ :radio: Play any radios around the globe right from this Terminal [yellow]:zap:[/yellow]! :smile: Author: Dipankar Pal - :question: Type '?' for help, any string to search favorites/history + :question: Type '--help' for more details on available commands + :bug: Visit: https://github.com/deep5050/radio-active to submit issues + :star: Show some love by starring the project on GitHub [red]:heart:[/red] + :dollar: You can donate me at https://deep5050.github.io/payme/ :x: Press Ctrl+C to quit """, title="[b]RADIOACTIVE[/b]", - width=80, - expand=False, + width=85, + expand=True, safe_box=True, ) - - -def _get_composite_renderable(): - """Combine all active UI components into a single Group.""" - components = [] - if _app_banner: - components.append(_app_banner) - if _station_banner: - components.append(_station_banner) - if _prompt_renderable: - components.append(_prompt_renderable) - - return Group(*components) - - -def refresh_live_display(): - """Update the persistent live display with the current composite renderable.""" - if _live_display: - _live_display.update(_get_composite_renderable(), refresh=True) - - -def handle_welcome_screen() -> None: - """ - Prepare the app banner for the playback UI. - - Do not start Rich Live here: interactive flows (e.g. ``pick`` for favorites) - use curses and break Live's cursor accounting. Live starts in - handle_current_play_panel when the main TUI is shown. - """ - global _app_banner - _app_banner = _get_welcome_panel() - if _live_display is not None: - refresh_live_display() + print(welcome) def handle_update_screen(app) -> None: @@ -109,10 +60,9 @@ def handle_update_screen(app) -> None: update_panel = Panel( update_msg, - width=80, - expand=False, + width=85, ) - _console.print(update_panel) + print(update_panel) else: log.debug("Update not available") @@ -127,9 +77,9 @@ def handle_favorite_table(alias) -> None: table = Table( show_header=True, header_style="bold magenta", - width=80, + min_width=85, safe_box=False, - expand=False, + expand=True, ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") @@ -137,7 +87,7 @@ def handle_favorite_table(alias) -> None: if len(alias.alias_map) > 0: for entry in alias.alias_map: table.add_row(entry["name"], entry["uuid_or_url"]) - _console.print(table) + print(table) log.info(f"Your favorite stations are saved in {alias.alias_path}") else: log.info("You have no favorite station list") @@ -153,9 +103,9 @@ def handle_history_table(history) -> None: table = Table( show_header=True, header_style="bold magenta", - width=80, + min_width=85, safe_box=False, - expand=False, + expand=True, ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") @@ -163,7 +113,7 @@ def handle_history_table(history) -> None: if len(history.history_list) > 0: for entry in history.history_list: table.add_row(entry["name"], entry["uuid_or_url"]) - _console.print(table) + print(table) log.info(f"Your history is saved in {history.history_path}") else: log.info("You have no history") @@ -171,104 +121,35 @@ def handle_history_table(history) -> None: def handle_show_station_info() -> None: """Show important information regarding the current station.""" - custom_info = [] + # pylint: disable=global-statement + custom_info = {} try: - # Create a clean table for station info instead of a dict dump - table = Table(box=None, padding=(0, 2)) - table.add_column("Property", style="cyan") - table.add_column("Value", style="white") - - table.add_row("Name", global_current_station_info.get("name")) - table.add_row("URL", global_current_station_info.get("url")) - table.add_row("Website", global_current_station_info.get("homepage")) - table.add_row("Country", global_current_station_info.get("country")) - table.add_row("Language", global_current_station_info.get("language")) - table.add_row("Codec", global_current_station_info.get("codec")) - table.add_row("Bitrate", str(global_current_station_info.get("bitrate", "N/A"))) - - info_panel = Panel(table, title="[b]Station Info[/b]", width=80, expand=False) - _console.print(info_panel) + custom_info["name"] = global_current_station_info.get("name") + custom_info["uuid"] = global_current_station_info.get("stationuuid") + custom_info["url"] = global_current_station_info.get("url") + custom_info["website"] = global_current_station_info.get("homepage") + custom_info["country"] = global_current_station_info.get("country") + custom_info["language"] = global_current_station_info.get("language") + custom_info["tags"] = global_current_station_info.get("tags") + custom_info["codec"] = global_current_station_info.get("codec") + custom_info["bitrate"] = global_current_station_info.get("bitrate") + print(custom_info) except Exception as e: log.error(f"No station information available: {e}") def handle_current_play_panel(curr_station_name: str = "") -> None: """ - Update the current station banner and manage the Live display lifecycle. - """ - global _live_display, _station_banner - _station_banner = _get_banner_panel(curr_station_name) + Print the currently playing station panel. - if _live_display is None: - _live_display = Live( - _get_composite_renderable(), - console=_console, - auto_refresh=False, - transient=False, - ) - _live_display.start() - else: - refresh_live_display() - - -def stop_live_display() -> None: - """Stop the global live display.""" - global _live_display, _prompt_renderable - if _live_display: - try: - _live_display.stop() - except Exception: - pass - _live_display = None - _prompt_renderable = None - - -def set_live_display(live: Optional[Live]) -> None: - """Set the active live display instance.""" - global _live_display - _live_display = live - - -def get_live_display() -> Optional[Live]: - """Return the active live display instance.""" - return _live_display - - -def get_current_banner() -> Optional[Group]: - """ - Return app + station banners as one Group for standalone display (e.g. fallback Live). - None if neither banner is set. + Args: + curr_station_name (str): Name of the station. """ - parts = [c for c in [_app_banner, _station_banner] if c is not None] - if not parts: - return None - return Group(*parts) - - -def update_prompt_renderable(renderable: Optional[Any] = None): - """Update the prompt portion of the persistent UI.""" - global _prompt_renderable - _prompt_renderable = renderable - refresh_live_display() - + panel_station_name = Text(curr_station_name, justify="center") -def handle_input(prompt: str) -> str: - """ - Standard input wrapper. Uses the active live display's console if available - to prevent UI corruption. - """ - try: - if _live_display: - # use the live console to print above the banner - res = _live_display.console.input(prompt) - else: - res = input(prompt) - return res - except EOFError: - return "" - except Exception as e: - log.debug(f"Input error: {e}") - return "" + station_panel = Panel(panel_station_name, title="[blink]:radio:[/blink]", width=85) + console = Console() + console.print(station_panel) def set_global_station_info(info: dict) -> None: diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 4fcb4d2..8a24937 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -51,21 +51,17 @@ handle_station_uuid_play, ) from radioactive.ffplay import kill_background_ffplays + +# Re-export functions for backward compatibility and aggregation from radioactive.ui import ( - get_current_banner, get_global_station_info, - get_live_display, handle_current_play_panel, handle_favorite_table, handle_history_table, - handle_input, handle_show_station_info, handle_update_screen, handle_welcome_screen, set_global_station_info, - set_live_display, - stop_live_display, - update_prompt_renderable, ) RED_COLOR = "\033[91m" @@ -113,10 +109,6 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st ) sys.exit(0) - # pick() uses curses; Rich Live must not be active or the terminal state breaks - # and panels redraw on top of each other. - stop_live_display() - _, index = pick(options, title, indicator="-->") # check if there is direct URL or just UUID @@ -136,9 +128,10 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st def handle_vim_style_prompt(alias=None, history=None): """ - Shows a Vim-style command prompt at the bottom with descriptive Tab completion + Shows a Vim-style command prompt (:) at the bottom with descriptive Tab completion and fuzzy search for favorites/history stations. """ + from rich.console import Console from rich.live import Live from rich.text import Text @@ -192,16 +185,13 @@ def get_key(): termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch - def build_prompt_renderable(): - """ - Only the command line. App and station banners are drawn by the global Live - composite (_get_composite_renderable); nesting them here duplicated the UI. - """ - prompt_text = Text("command:", style="bold magenta") - prompt_text.append(" ", style="default") - prompt_text.append(buffer, style="bold cyan") + def get_display(text, matches=[]): + # The prompt part + prompt_text = Text("command : ", style="magenta") + prompt_text.append(text, style="bold cyan") if buffer: + # Check commands first cmd_matches = [m for m in completions if m.startswith(buffer)] if cmd_matches: first_match = cmd_matches[0] @@ -209,9 +199,11 @@ def build_prompt_renderable(): hint_text = Text(f" ({description})", style="dim green") prompt_text.append(hint_text) else: + # No command match, trigger fuzzy search for stations station_matches = [ n for n in station_names if buffer.lower() in n.lower() ] + # simple sort by position of query in the name station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) if station_matches: @@ -221,20 +213,7 @@ def build_prompt_renderable(): return prompt_text - def full_fallback_renderable(): - """When no global Live exists, stack banners + prompt in one transient Live.""" - from rich.console import Group - - prompt_r = build_prompt_renderable() - banner = get_current_banner() - if banner is not None: - return Group(banner, prompt_r) - return prompt_r - - live = get_live_display() - if live: - # Show prompt before blocking on first key; banners come from global composite only. - update_prompt_renderable(build_prompt_renderable()) + with Live(get_display(""), transient=True, refresh_per_second=10) as live: while True: char = get_key() @@ -248,8 +227,7 @@ def full_fallback_renderable(): station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) if char in ["\r", "\n"]: # Enter - # reset display to just banner (remove prompt) - update_prompt_renderable(None) + # If there's a fuzzy station match, use it. Otherwise use the buffer. if not cmd_matches and station_matches: return station_matches[0] return buffer.strip() @@ -265,7 +243,8 @@ def full_fallback_renderable(): buffer = station_matches[0] elif char in ["\x03", "\x1b"]: # Ctrl+C or ESC - update_prompt_renderable(None) + # ESC can produce \x1b followed by nothing if caught fast, + # but arrows also start with \x1b. get_key handles sequences. if char == "\x1b": return "q" return "" @@ -273,43 +252,8 @@ def full_fallback_renderable(): elif len(char) == 1: # printable buffer += char - update_prompt_renderable(build_prompt_renderable()) - else: - # Fallback to local transient live display if no persistent one exists - with Live( - full_fallback_renderable(), transient=True, auto_refresh=False - ) as live: - while True: - char = get_key() - # Find current matches - cmd_matches = [ - m for m in completions if buffer and m.startswith(buffer) - ] - station_matches = [] - if not cmd_matches and buffer: - station_matches = [ - n for n in station_names if buffer.lower() in n.lower() - ] - station_matches.sort(key=lambda n: n.lower().find(buffer.lower())) - - if char in ["\r", "\n"]: # Enter - if not cmd_matches and station_matches: - return station_matches[0] - return buffer.strip() - elif char in ["\x7f", "\x08"]: # Backspace - buffer = buffer[:-1] - elif char == "\t" or char == "\x1b[C": # Tab or Right Arrow - if cmd_matches: - buffer = cmd_matches[0] - elif station_matches: - buffer = station_matches[0] - elif char in ["\x03", "\x1b"]: # Ctrl+C or ESC - if char == "\x1b": - return "q" - return "" - elif len(char) == 1: # printable - buffer += char - live.update(full_fallback_renderable()) + # Update display with new buffer state + live.update(get_display(buffer, cmd_matches or station_matches)) def handle_runtime_help_menu(): @@ -366,7 +310,6 @@ def add(cmd, desc): ) # Print the panel centered on the alternate screen - stop_live_display() console.print(help_panel, justify="center") # Use console.input() to wait for Enter and avoid prompt capture @@ -375,11 +318,6 @@ def add(cmd, desc): except (EOFError, KeyboardInterrupt): pass - # Restore the live banner if a station was playing - global_info = get_global_station_info() - if global_info: - handle_current_play_panel(global_info.get("name", "Unknown Station")) - def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: """ @@ -394,7 +332,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: log.debug("Exactly one result found") try: - user_input = handle_input("Want to play this station? Y/N: ") + user_input = input("Want to play this station? Y/N: ") except EOFError: print() sys.exit(0) @@ -414,7 +352,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: try: log.info("Type 'r' for a random station, 'n' to cycle through the list") - user_input = handle_input("Type the result ID to play: ") + user_input = input("Type the result ID to play: ") except EOFError: print() log.info("Exiting") @@ -498,7 +436,7 @@ def handle_listen_keypress( ) elif user_input in ["rf", "RF", "recordfile"]: try: - user_input = handle_input("Enter output filename: ") + user_input = input("Enter output filename: ") except EOFError: print() log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") @@ -540,7 +478,7 @@ def handle_listen_keypress( elif TIMER_FEATURE and user_input in ["timer", "sleep"]: try: - duration_str = handle_input("Enter sleep timer duration in minutes: ") + duration_str = input("Enter sleep timer duration in minutes: ") duration = float(duration_str) if duration <= 0: log.error("Duration must be positive") @@ -608,7 +546,7 @@ def stop_playback(): elif SEARCH_FEATURE and user_input in ["s", "S", "search"]: if handler: try: - query = handle_input("Enter station name to search: ") + query = input("Enter station name to search: ") except EOFError: continue From 47a065e47bea76102cf600d1dfc7c643403dbb76 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 20:01:42 +0530 Subject: [PATCH 12/31] feat: implement rich-based modal for station information and remove automatic display on station load --- radioactive/ui.py | 60 +++++++++++++++++++++++++++++++--------- radioactive/utilities.py | 4 --- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/radioactive/ui.py b/radioactive/ui.py index f17d99f..6a7a81b 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -120,20 +120,54 @@ def handle_history_table(history) -> None: def handle_show_station_info() -> None: - """Show important information regarding the current station.""" - # pylint: disable=global-statement - custom_info = {} + """Show important information regarding the current station in an alternate screen (Modal).""" try: - custom_info["name"] = global_current_station_info.get("name") - custom_info["uuid"] = global_current_station_info.get("stationuuid") - custom_info["url"] = global_current_station_info.get("url") - custom_info["website"] = global_current_station_info.get("homepage") - custom_info["country"] = global_current_station_info.get("country") - custom_info["language"] = global_current_station_info.get("language") - custom_info["tags"] = global_current_station_info.get("tags") - custom_info["codec"] = global_current_station_info.get("codec") - custom_info["bitrate"] = global_current_station_info.get("bitrate") - print(custom_info) + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + + console = Console() + with console.screen(): + table = Table(box=None, padding=(0, 2), show_header=False) + table.add_column("Property", style="cyan", justify="left") + table.add_column("Value", style="white") + + # Map internal keys to display labels + fields = [ + ("Name", "name"), + ("UUID", "stationuuid"), + ("Stream URL", "url"), + ("Website", "homepage"), + ("Country", "country"), + ("Language", "language"), + ("Tags", "tags"), + ("Codec", "codec"), + ("Bitrate", "bitrate"), + ] + + for label, key in fields: + val = str(global_current_station_info.get(key, "N/A")) + if val.strip() == "" or val == "None": + val = "N/A" + table.add_row(f"{label}:", val) + + info_panel = Panel( + table, + title="[bold magenta]:radio: Station Information[/bold magenta]", + subtitle="[blink]Press Enter to return[/blink]", + border_style="magenta", + padding=(1, 4), + expand=False, + ) + + console.print("\n" * 3) + console.print(info_panel, justify="center") + + try: + console.input() + except (EOFError, KeyboardInterrupt): + pass + except Exception as e: log.error(f"No station information available: {e}") diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 8a24937..42b3b6c 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -380,7 +380,6 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: # saving global info set_global_station_info(target_response) - handle_show_station_info() return handle_station_uuid_play(handler, target_response["stationuuid"]) else: @@ -635,7 +634,6 @@ def stop_playback(): if source_type == "search": # It's a full station object set_global_station_info(target_station) - handle_show_station_info() new_station_name, new_target_url = handle_station_uuid_play( handler, target_station["stationuuid"] ) @@ -655,7 +653,6 @@ def stop_playback(): # Direct URL temp_info["url"] = uuid_or_url set_global_station_info(temp_info) - handle_show_station_info() new_station_name = target_station["name"] new_target_url = uuid_or_url # Allow direct play without UUID handler @@ -663,7 +660,6 @@ def stop_playback(): # UUID temp_info["stationuuid"] = uuid_or_url set_global_station_info(temp_info) - handle_show_station_info() new_station_name, new_target_url = ( handle_station_uuid_play(handler, uuid_or_url) ) From 0a635cebca0c9b77ed3f49cf18f2fc84f96591aa Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 21:01:30 +0530 Subject: [PATCH 13/31] feat: add zen mode for minimalist station display and update UI layout and styling --- radioactive/ui.py | 95 +++++++++++++++++++++++++++++++++------- radioactive/utilities.py | 9 +++- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/radioactive/ui.py b/radioactive/ui.py index 6a7a81b..81d0fe4 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -18,17 +18,15 @@ def handle_welcome_screen() -> None: """Print the welcome screen panel.""" welcome = Panel( """ - :radio: Play any radios around the globe right from this Terminal [yellow]:zap:[/yellow]! + :radio: Play any radios around the globe right from this Terminal :smile: Author: Dipankar Pal :question: Type '--help' for more details on available commands - :bug: Visit: https://github.com/deep5050/radio-active to submit issues - :star: Show some love by starring the project on GitHub [red]:heart:[/red] - :dollar: You can donate me at https://deep5050.github.io/payme/ - :x: Press Ctrl+C to quit + :bug: Visit: https://github.com/deep5050/radio-active + :question: Press ? for help """, title="[b]RADIOACTIVE[/b]", - width=85, - expand=True, + width=100, + expand=False, safe_box=True, ) print(welcome) @@ -60,7 +58,8 @@ def handle_update_screen(app) -> None: update_panel = Panel( update_msg, - width=85, + width=100, + expand=False, ) print(update_panel) else: @@ -77,9 +76,9 @@ def handle_favorite_table(alias) -> None: table = Table( show_header=True, header_style="bold magenta", - min_width=85, + width=100, safe_box=False, - expand=True, + expand=False, ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") @@ -103,9 +102,9 @@ def handle_history_table(history) -> None: table = Table( show_header=True, header_style="bold magenta", - min_width=85, + width=100, safe_box=False, - expand=True, + expand=False, ) table.add_column("Station", justify="left") table.add_column("URL / UUID", justify="left") @@ -172,16 +171,82 @@ def handle_show_station_info() -> None: log.error(f"No station information available: {e}") +def handle_zen_mode() -> None: + """Show a minimalist 'Zen' display of the current station.""" + try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + # Beautiful Zen emojis + emojis = ["✨", "🧘", "🌊", "🍃", "🌙", "☁️", "🎵", "🎧"] + import random + + icon = random.choice(emojis) + + console = Console() + with console.screen(): + # defensive retrieval of the name + name = global_current_station_info.get("name") + if not name or str(name).strip().upper() in ["N/A", "NONE", "UNKNOWN"]: + # fallback, check if we have it anywhere else? + display_name = "Unknown Station" + else: + display_name = str(name).strip() + + # Truncate to 30 chars + if len(display_name) > 30: + display_name = display_name[:27] + "..." + + # Create a stylized station name + zen_text = Text() + zen_text.append(f"\n{icon} ", style="bold yellow") + zen_text.append(display_name.upper(), style="bold white") + zen_text.append(f" {icon}\n", style="bold yellow") + + zen_panel = Panel( + zen_text, + title="[bold magenta]RADIOACTIVE[/bold magenta]", + subtitle="[dim]Press Enter to return[/dim]", + border_style="bold blue", + padding=(2, 4), + width=100, + expand=False, + ) + + # Center vertically with some newlines + console.print("\n" * 8) + console.print(zen_panel, justify="center") + + try: + console.input() + except (EOFError, KeyboardInterrupt): + pass + + except Exception as e: + log.error(f"Error in zen mode: {e}") + + def handle_current_play_panel(curr_station_name: str = "") -> None: """ - Print the currently playing station panel. + Print the currently playing station panel and sync station name state. Args: curr_station_name (str): Name of the station. """ - panel_station_name = Text(curr_station_name, justify="center") + # Ensure the global state is always updated with the active station name + if curr_station_name and curr_station_name.strip() != "": + # Update the name to ensure sync even if previous station name existed + global_current_station_info["name"] = curr_station_name + + # Truncate to 30 chars + display_name = curr_station_name + if len(display_name) > 30: + display_name = display_name[:27] + "..." + + panel_station_name = Text(display_name, justify="center") - station_panel = Panel(panel_station_name, title="[blink]:radio:[/blink]", width=85) + station_panel = Panel(panel_station_name, title="[blink]:radio:[/blink]", width=72) console = Console() console.print(station_panel) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 42b3b6c..b19cf7b 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -61,6 +61,7 @@ handle_show_station_info, handle_update_screen, handle_welcome_screen, + handle_zen_mode, set_global_station_info, ) @@ -187,7 +188,7 @@ def get_key(): def get_display(text, matches=[]): # The prompt part - prompt_text = Text("command : ", style="magenta") + prompt_text = Text("command : ", style="green") prompt_text.append(text, style="bold cyan") if buffer: @@ -280,6 +281,7 @@ def add(cmd, desc): add("t / track", "Current track info") if INFO_FEATURE: add("i / info", "Station information") + add("z / zenmode", "Minimalist station display") if RECORDING_FEATURE: add("r / record", "Record a station") add("rf / recordfile", "Specify a filename for the recording") @@ -412,7 +414,7 @@ def handle_listen_keypress( Listen for user input during playback to perform actions. Now with handler and station_list for runtime commands. """ - log.info("Press '?' to see available commands\n") + # log.info("Press '?' to see available commands\n") while True: try: user_input = handle_vim_style_prompt(alias, history) @@ -475,6 +477,9 @@ def handle_listen_keypress( if INFO_FEATURE and user_input in ["i", "I", "info"]: handle_show_station_info() + elif user_input in ["z", "Z", "zenmode"]: + handle_zen_mode() + elif TIMER_FEATURE and user_input in ["timer", "sleep"]: try: duration_str = input("Enter sleep timer duration in minutes: ") From 050385769f084c3fbbaee7f8008ebc102aa2b316 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 21:56:45 +0530 Subject: [PATCH 14/31] feat: add recording status popup and background FFmpeg process management --- radioactive/actions.py | 11 ++- radioactive/recorder.py | 40 +++++----- radioactive/ui.py | 66 ++++++++++++++++ radioactive/utilities.py | 166 ++++++++++++++++++++++++++------------- 4 files changed, 201 insertions(+), 82 deletions(-) diff --git a/radioactive/actions.py b/radioactive/actions.py index f63e357..d9f2e3a 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -74,7 +74,7 @@ def handle_record( log.error("Recording feature is not compiled/enabled in this build.") return - log.info("Press 'q' to stop recording") + # log.info("Press 'q' to stop recording") force_mp3 = False if record_file_format != "mp3" and record_file_format != "auto": @@ -139,11 +139,10 @@ def handle_record( tmp_filename = f"{record_file}.{record_file_format}" outfile_path = os.path.join(record_file_path, tmp_filename) - log.info(f"Recording will be saved as: \n{outfile_path}") - - log.info(f"Recording will be saved as: \n{outfile_path}") - - record_audio_from_url(target_url, outfile_path, force_mp3, loglevel, duration) + process = record_audio_from_url( + target_url, outfile_path, force_mp3, loglevel, duration + ) + return process, outfile_path def handle_add_station(alias) -> None: diff --git a/radioactive/recorder.py b/radioactive/recorder.py index 3c11f91..1106586 100644 --- a/radioactive/recorder.py +++ b/radioactive/recorder.py @@ -32,52 +32,52 @@ def record_audio_auto_codec(input_stream_url): def record_audio_from_url(input_url, output_file, force_mp3, loglevel, duration=None): + """ + Record audio from a URL using FFmpeg. + Returns the subprocess.Popen object to allow UI tracking. + """ try: - # Construct the FFmpeg command ffmpeg_command = [ "ffmpeg", "-i", - input_url, # input URL - "-vn", # disable video recording - "-stats", # show stats + input_url, + "-vn", + "-stats", ] - # codec for audio stream ffmpeg_command.append("-c:a") if force_mp3: ffmpeg_command.append("libmp3lame") - log.debug("Record: force libmp3lame") else: - # file will be saved as as provided. this is more error prone - # file extension must match the actual stream codec ffmpeg_command.append("copy") ffmpeg_command.append("-loglevel") if loglevel == "debug": ffmpeg_command.append("info") else: - ffmpeg_command.append("error"), + ffmpeg_command.append("error") ffmpeg_command.append("-hide_banner") if duration: - # duration is in minutes seconds = int(duration) * 60 ffmpeg_command.append("-t") ffmpeg_command.append(str(seconds)) - # output file ffmpeg_command.append(output_file) - # Run FFmpeg command on foreground to catch 'q' without - # any complex thread for now - subprocess.run(ffmpeg_command, check=True) + # Run FFmpeg command in background to allow UI tracking + # Use DEVNULL to prevent hangs and terminal corruption + process = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return process - log.debug("Record: {}".format(str(ffmpeg_command))) - log.info("Audio recorded successfully.") - - except subprocess.CalledProcessError as e: - log.debug("Error: {}".format(e)) - log.error(f"Error while recording audio: {e}") + except Exception as ex: + log.error(f"Error while starting recording: {ex}") + return None except Exception as ex: log.debug("Error: {}".format(ex)) log.error(f"An error occurred: {ex}") diff --git a/radioactive/ui.py b/radioactive/ui.py index 81d0fe4..e369c34 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -227,6 +227,72 @@ def handle_zen_mode() -> None: log.error(f"Error in zen mode: {e}") +def handle_recording_popup(process, outfile_path) -> None: + """Show a static recording info panel in an alternate screen (Popup).""" + if not process: + return + + try: + import os + + from rich.align import Align + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + + console = Console() + filename = os.path.basename(outfile_path) + directory = os.path.dirname(outfile_path) + + with console.screen(): + table = Table(box=None, padding=(0, 2), show_header=False) + table.add_column("Prop", style="cyan", justify="right") + table.add_column("Val", style="white") + + table.add_row("File Name:", filename) + table.add_row("Directory:", directory) + table.add_row( + "Status:", "[blink][bold red]● Recording ... [/bold red][/blink]" + ) + + info_panel = Panel( + table, + title="[bold white]RADIOACTIVE[/bold white]", + subtitle="Press Enter to STOP recording", + border_style="white", + padding=(1, 4), + width=100, + expand=False, + ) + + # Center the panel visually + console.print("\n" * 8) + console.print(Align.center(info_panel)) + + while process.poll() is None: + try: + # Wait for Enter to stop + input() + try: + # send 'q' to ffmpeg to save and quit nicely + process.stdin.write(b"q") + process.stdin.flush() + except: + process.terminate() + process.wait() + break + except (EOFError, KeyboardInterrupt): + process.terminate() + process.wait() + break + + # finalize UI after stop or process ends + # log.info(f"Recording saved at: {outfile_path}") + + except Exception as e: + log.error(f"Error in recording popup: {e}") + + def handle_current_play_panel(curr_station_name: str = "") -> None: """ Print the currently playing station panel and sync station name state. diff --git a/radioactive/utilities.py b/radioactive/utilities.py index b19cf7b..b8bc228 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -58,6 +58,7 @@ handle_current_play_panel, handle_favorite_table, handle_history_table, + handle_recording_popup, handle_show_station_info, handle_update_screen, handle_welcome_screen, @@ -172,19 +173,66 @@ def handle_vim_style_prompt(alias=None, history=None): buffer = "" - # Helper to capture single key on Linux - def get_key(): - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - try: - tty.setraw(sys.stdin.fileno()) - ch = sys.stdin.read(1) - if ch == "\x1b": # Escape sequence - seq = sys.stdin.read(2) - ch += seq - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - return ch + +def get_key(): + """Helper to capture single key on Linux.""" + import sys + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(sys.stdin.fileno()) + ch = sys.stdin.read(1) + if ch == "\x1b": # Escape sequence + seq = sys.stdin.read(2) + ch += seq + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return ch + + +def handle_vim_style_prompt(alias, history) -> str: + """Captured VIM style command prompt with fuzzy search and completions.""" + from rich.live import Live + from rich.text import Text + + # Mapping of shortcut/command to descriptive full text + command_map = { + "p": "play/pause", + "t": "track info", + "i": "station info", + "r": "record", + "rf": "record file", + "f": "add favorite", + "l": "list favorites", + "v+": "volume +", + "v-": "volume -", + "v": "set volume", + "s": "search", + "n": "next station", + "timer": "timer", + "sleep": "sleep", + "q": "quit", + "help": "help", + "?": "help", + } + + # Combined list for matching + completions = list(command_map.keys()) + + # Build a list of station names for fuzzy search + station_names = [] + if alias and hasattr(alias, "alias_map"): + station_names += [s.get("name", "").strip() for s in alias.alias_map] + if history and hasattr(history, "get_list"): + station_names += [s.get("name", "").strip() for s in history.get_list()] + + # Clean and deduplicate station names + station_names = sorted(list(set([n for n in station_names if n]))) + + buffer = "" def get_display(text, matches=[]): # The prompt part @@ -425,60 +473,66 @@ def handle_listen_keypress( kill_background_ffplays() sys.exit(0) - if RECORDING_FEATURE: - if user_input in ["r", "R", "record"]: - handle_record( + if RECORDING_FEATURE and user_input in ["r", "R", "record"]: + process, outfile_path = handle_record( + target_url, + station_name, + record_file_path, + record_file, + record_file_format, + loglevel, + ) + handle_recording_popup(process, outfile_path) + continue + + elif RECORDING_FEATURE and user_input in ["rf", "RF", "recordfile"]: + try: + user_input = input("Enter output filename: ") + except EOFError: + print() + log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") + kill_background_ffplays() + sys.exit(0) + + # try to get extension from filename + try: + file_name_parts = user_input.split(".") + if len(file_name_parts) > 1 and file_name_parts[-1] == "mp3": + log.debug("codec: force mp3") + # overwrite original codec with "mp3" + record_file_format = "mp3" + file_name = user_input.rsplit(".", 1)[ + 0 + ] # Handle filename with dots + else: + if len(file_name_parts) > 1 and file_name_parts[-1] != "mp3": + log.warning("You can only specify mp3 as file extension.\n") + log.warning( + "Do not provide any extension to autodetect the codec.\n" + ) + file_name = user_input + except Exception: + file_name = user_input + + if user_input.strip() != "": + process, outfile_path = handle_record( target_url, station_name, record_file_path, - record_file, + file_name, record_file_format, loglevel, ) - elif user_input in ["rf", "RF", "recordfile"]: - try: - user_input = input("Enter output filename: ") - except EOFError: - print() - log.debug("Ctrl+D (EOF) detected. Exiting gracefully.") - kill_background_ffplays() - sys.exit(0) - - # try to get extension from filename - try: - file_name_parts = user_input.split(".") - if len(file_name_parts) > 1 and file_name_parts[-1] == "mp3": - log.debug("codec: force mp3") - # overwrite original codec with "mp3" - record_file_format = "mp3" - file_name = user_input.rsplit(".", 1)[ - 0 - ] # Handle filename with dots - else: - if len(file_name_parts) > 1 and file_name_parts[-1] != "mp3": - log.warning("You can only specify mp3 as file extension.\n") - log.warning( - "Do not provide any extension to autodetect the codec.\n" - ) - file_name = user_input - except Exception: - file_name = user_input - - if user_input.strip() != "": - handle_record( - target_url, - station_name, - record_file_path, - file_name, - record_file_format, - loglevel, - ) + handle_recording_popup(process, outfile_path) + continue if INFO_FEATURE and user_input in ["i", "I", "info"]: handle_show_station_info() + continue elif user_input in ["z", "Z", "zenmode"]: handle_zen_mode() + continue elif TIMER_FEATURE and user_input in ["timer", "sleep"]: try: @@ -627,7 +681,7 @@ def stop_playback(): while attempts < max_attempts: target_station = target_list[next_index] - log.info(f"Switching to: {target_station.get('name')}") + log.debug(f"Switching to: {target_station.get('name')}") # Determine how to play based on available info # We need to simulate the "Selection" logic From 385149230f09e41c770a89961d1c04845db21a33 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 22:13:24 +0530 Subject: [PATCH 15/31] style: update UI panel themes to use white borders and remove blinking subtitles --- radioactive/ui.py | 12 ++++++------ radioactive/utilities.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/radioactive/ui.py b/radioactive/ui.py index e369c34..5cbc3e9 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -152,9 +152,9 @@ def handle_show_station_info() -> None: info_panel = Panel( table, - title="[bold magenta]:radio: Station Information[/bold magenta]", - subtitle="[blink]Press Enter to return[/blink]", - border_style="magenta", + title="[bold white]:radio: Station Information[/bold white]", + subtitle="Press Enter to return", + border_style="white", padding=(1, 4), expand=False, ) @@ -206,9 +206,9 @@ def handle_zen_mode() -> None: zen_panel = Panel( zen_text, - title="[bold magenta]RADIOACTIVE[/bold magenta]", - subtitle="[dim]Press Enter to return[/dim]", - border_style="bold blue", + title="[bold white]RADIOACTIVE[/bold white]", + subtitle="Press Enter to return", + border_style="bold white", padding=(2, 4), width=100, expand=False, diff --git a/radioactive/utilities.py b/radioactive/utilities.py index b8bc228..20c22b1 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -352,9 +352,9 @@ def add(cmd, desc): # Center the table within a panel help_panel = Panel( table, - title="[bold magenta]:radio: Available Runtime Commands[/bold magenta]", - subtitle="[blink]Press Enter to return[/blink]", - border_style="magenta", + title="[bold white]:radio: Available Runtime Commands[/bold white]", + subtitle="Press Enter to return", + border_style="white", expand=False, padding=(1, 4), ) From d56116de2ef8ad9faf404ad454495f04b636568b Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sun, 5 Apr 2026 22:26:24 +0530 Subject: [PATCH 16/31] feat: enhance zen mode UI with station tags, codec information, and improved text alignment --- radioactive/ui.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/radioactive/ui.py b/radioactive/ui.py index 5cbc3e9..c5c3009 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -182,6 +182,8 @@ def handle_zen_mode() -> None: emojis = ["✨", "🧘", "🌊", "🍃", "🌙", "☁️", "🎵", "🎧"] import random + from rich.align import Align + icon = random.choice(emojis) console = Console() @@ -194,16 +196,33 @@ def handle_zen_mode() -> None: else: display_name = str(name).strip() - # Truncate to 30 chars - if len(display_name) > 30: - display_name = display_name[:27] + "..." - # Create a stylized station name - zen_text = Text() - zen_text.append(f"\n{icon} ", style="bold yellow") + zen_text = Text(justify="center") + + # Beautiful Wave Decoration + wave = " ▂ ▃ ▅ ▆ █ █ ▆ ▅ ▃ ▂ " + # zen_text.append(f"\n{wave}\n\n", style="bold white") + + zen_text.append(f"{icon} ", style="bold yellow") zen_text.append(display_name.upper(), style="bold white") zen_text.append(f" {icon}\n", style="bold yellow") + # Add more data if available + tags = global_current_station_info.get("tags") + if tags and str(tags).strip() != "": + clean_tags = str(tags).replace(",", " • ").strip() + if len(clean_tags) > 70: + clean_tags = clean_tags[:67] + "..." + zen_text.append(f"\n{clean_tags}\n", style="dim white") + + codec = global_current_station_info.get("codec") + bitrate = global_current_station_info.get("bitrate") + if codec or bitrate: + info_line = f"\n{codec or ''} • {bitrate or ''} kbps".strip(" • ") + zen_text.append(info_line, style="italic dim white") + + # zen_text.append(f"\n\n{wave}\n", style="bold white") + zen_panel = Panel( zen_text, title="[bold white]RADIOACTIVE[/bold white]", @@ -216,10 +235,10 @@ def handle_zen_mode() -> None: # Center vertically with some newlines console.print("\n" * 8) - console.print(zen_panel, justify="center") + console.print(Align.center(zen_panel)) try: - console.input() + input() except (EOFError, KeyboardInterrupt): pass From 117e7f816d10dfaab227c87f29a2a9c426448305 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 6 Apr 2026 09:26:06 +0530 Subject: [PATCH 17/31] fix: refresh alias map after updates and implement station change validation with history logging --- radioactive/alias.py | 2 ++ radioactive/utilities.py | 58 ++++++++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/radioactive/alias.py b/radioactive/alias.py index e94d042..a46b3ab 100644 --- a/radioactive/alias.py +++ b/radioactive/alias.py @@ -86,6 +86,8 @@ def add_entry(self, left, right): with open(self.alias_path, "a+") as f: f.write("{}=={}\n".format(left.strip(), right.strip())) log.info("Current station added to your favorite list") + # Refresh map after writing + self.generate_map() return True def flush(self): diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 20c22b1..10acb05 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -582,10 +582,20 @@ def stop_playback(): handler, last_station, alias ) if new_target_url: + if new_target_url == target_url: + log.info("Station is already playing!") + continue player.stop() player.url = new_target_url player.play() handle_current_play_panel(new_station_name) + # Save the new station as last played and add to history + handle_save_last_station( + last_station, new_station_name, new_target_url + ) + handle_save_to_history( + history, new_station_name, new_target_url + ) # Update loop variables station_name = new_station_name station_url = new_target_url @@ -616,18 +626,31 @@ def stop_playback(): station_list = temp_station_list # Find valid station choice try: - station_name, target_url = ( + new_station_name, new_target_url = ( handle_user_choice_from_search_result( handler, station_list ) ) - # Stop current, switch - player.stop() - player.url = target_url - player.play() - handle_current_play_panel(station_name) - # Update loop variables - station_url = target_url + if new_target_url: + if new_target_url == target_url: + log.info("Station is already playing!") + continue + # Stop current, switch + player.stop() + player.url = new_target_url + player.play() + handle_current_play_panel(new_station_name) + # Save the new station as last played and add to history + handle_save_last_station( + last_station, new_station_name, new_target_url + ) + handle_save_to_history( + history, new_station_name, new_target_url + ) + # Update loop variables + station_name = new_station_name + station_url = new_target_url + target_url = new_target_url except SystemExit: # handle_user_choice might try to exit on cancel pass @@ -725,10 +748,23 @@ def stop_playback(): # Check if we have a URL to play if new_target_url: + if new_target_url == target_url: + # log.debug("Station already playing, but cycling next...") + # Actually, for 'next', if there's only 1 station, we should just stay + if len(target_list) == 1: + log.info("Station is already playing!") + break player.stop() player.url = new_target_url player.play() handle_current_play_panel(new_station_name) + # Save the new station as last played and add to history + handle_save_last_station( + last_station, new_station_name, new_target_url + ) + handle_save_to_history( + history, new_station_name, new_target_url + ) station_url = new_target_url station_name = new_station_name target_url = new_target_url @@ -782,10 +818,16 @@ def stop_playback(): try: name, url = handle_direct_play(alias, user_input) if url: + if url == target_url: + log.info("Station is already playing!") + continue player.stop() player.url = url player.play() handle_current_play_panel(name) + # Save the new station as last played and add to history + handle_save_last_station(last_station, name, url) + handle_save_to_history(history, name, url) station_url = url station_name = name target_url = url From 0c8a8257948dd276c9af46bc598b223a274d0d69 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 6 Apr 2026 21:22:37 +0530 Subject: [PATCH 18/31] feat: add Dockerfile and script to automate pipx installation testing across Python versions --- docker/Dockerfile.pipx_test | 27 ++++++++++++++++ scripts/test_pipx_versions.sh | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docker/Dockerfile.pipx_test create mode 100755 scripts/test_pipx_versions.sh diff --git a/docker/Dockerfile.pipx_test b/docker/Dockerfile.pipx_test new file mode 100644 index 0000000..6c5a7cb --- /dev/null +++ b/docker/Dockerfile.pipx_test @@ -0,0 +1,27 @@ +# Dockerfile.pipx_test +# Used to test the installation of radio-active using pipx across multiple python versions +ARG PYTHON_VERSION=3.9 +FROM python:${PYTHON_VERSION}-slim + +# Upgrade pip and install common dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + gcc \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --upgrade pip setuptools wheel +RUN pip install pipx +RUN pipx ensurepath + +# Install radio-active from PyPI using pipx +# Add /root/.local/bin to PATH where pipx installs binaries +ENV PATH="/root/.local/bin:${PATH}" +RUN pipx install radio-active + +# test if radioactive/radio commands are working and show version +RUN radioactive --version +RUN radio --version + +# Smoke test: check help output +RUN radioactive --help diff --git a/scripts/test_pipx_versions.sh b/scripts/test_pipx_versions.sh new file mode 100755 index 0000000..d71584d --- /dev/null +++ b/scripts/test_pipx_versions.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# Configuration: List of Python versions to test +PYTHON_VERSIONS=("3.8" "3.9" "3.10" "3.11" "3.12") +TAG_PREFIX="radio-active-pipx-test" + +# Get project root from script location +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Starting pipx installation tests on several Python versions..." +echo "Project Root: $PROJECT_ROOT" + +# Cleanup function to be called on script exit or interrupt +cleanup() { + if [[ -n "$tag" ]]; then + echo "Cleaning up image $tag..." + docker rmi "$tag" 2>/dev/null || true + fi + exit +} + +# Trap signals for cleanup +trap cleanup SIGINT SIGTERM EXIT + +failed_versions=() + +for version in "${PYTHON_VERSIONS[@]}"; do + echo "--------------------------------------------------------" + echo "Testing on Python $version..." + echo "--------------------------------------------------------" + + tag="${TAG_PREFIX}:${version}" + + # Build docker image while passing PYTHON_VERSION as build arg + # Running build from project root so it can copy the source + if docker build -t "$tag" \ + -f "$PROJECT_ROOT/docker/Dockerfile.pipx_test" \ + --build-arg PYTHON_VERSION="$version" \ + "$PROJECT_ROOT"; then + echo "✅ Test PASSED for Python $version" + else + echo "❌ Test FAILED for Python $version" + failed_versions+=("$version") + fi + + # Cleanup: Remove the image after testing to save space + echo "Cleaning up image $tag..." + docker rmi "$tag" 2>/dev/null || echo "Warning: Failed to remove image $tag" + tag="" +done + +echo "--------------------------------------------------------" +if [ ${#failed_versions[@]} -eq 0 ]; then + echo "Summarizing results: ALL TESTS PASSED! 🎉" + exit 0 +else + echo "Summarizing results: SOME TESTS FAILED! ❌" + echo "Failed versions: ${failed_versions[*]}" + exit 1 +fi From c2cb835fb07e3264326521a60a51b7386635b957 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 11 Apr 2026 22:01:06 +0530 Subject: [PATCH 19/31] feat: add background process support with PID management and instance collision handling --- CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f30de35..6f8c199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ ## 4.0.0 -1. Vim-style command prompt with advanced tab completion 🚀 -2. Real-time fuzzy station search across favorites and history -3. Integrated volume control for MPV, VLC, and FFplay players -4. Interactive station selection menu via `l/list` command -5. Improved help menu using a Rich-based interactive interface +1. Major UI changes +2. Command prompt with advanced tab completion 🚀 +3. Real-time fuzzy station search across favorites and history +4. Integrated volume control for MPV, VLC, and FFplay players +5. Interactive station selection menu via `l/list` command +6. Improved help menu using a Rich-based interactive interface +7. Zen mode +8. New recording window +9. Stability improvements ## 3.0.4 From 1876624b3e9bda5461ed382df30e468645f8f440 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Sat, 11 Apr 2026 22:04:30 +0530 Subject: [PATCH 20/31] feat: add background mode support with PID tracking and process management --- radioactive/__main__.py | 89 ++++++++++++++++++++++++++++++++++++---- radioactive/paths.py | 8 ++++ radioactive/utilities.py | 44 ++++++++++++++++++++ 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/radioactive/__main__.py b/radioactive/__main__.py index 8521ca0..174507d 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -1,9 +1,11 @@ #!/usr/bin/env python +import atexit import os import signal import sys from time import sleep +import psutil from zenlog import log from radioactive.alias import Alias @@ -14,6 +16,7 @@ from radioactive.history import History from radioactive.last_station import Last_station from radioactive.parser import parse_options +from radioactive.paths import get_pid_path from radioactive.utilities import ( check_sort_by_parameter, handle_add_station, @@ -126,20 +129,16 @@ def main(): VERSION = app.get_version() + if options["version"]: + log.info("RADIO-ACTIVE : version {}".format(VERSION)) + sys.exit(0) + handler = Handler() alias = Alias() alias.generate_map() last_station = Last_station() history = History() - # --------------- app logic starts here ------------------- # - - if options["version"]: - log.info("RADIO-ACTIVE : version {}".format(VERSION)) - sys.exit(0) - - handle_welcome_screen() - if options["show_help_table"]: show_help() sys.exit(0) @@ -149,8 +148,82 @@ def main(): if options["kill_ffplays"]: kill_background_ffplays() + pid_file = get_pid_path() + if os.path.exists(pid_file): + with open(pid_file, "r") as f: + try: + pid = int(f.read().strip()) + if psutil.pid_exists(pid): + os.kill(pid, signal.SIGTERM) + log.info( + f"Terminated background radioactive process (PID: {pid})" + ) + except: + pass + try: + os.remove(pid_file) + except: + pass sys.exit(0) + # --------------- PID check ------------------- # + pid_file = get_pid_path() + if os.path.exists(pid_file): + with open(pid_file, "r") as f: + try: + content = f.read().strip() + if content: + old_pid = int(content) + if psutil.pid_exists(old_pid): + proc = psutil.Process(old_pid) + # Check if it's likely our app + if ( + "python" in proc.name().lower() + or "radioactive" in proc.name().lower() + ): + log.warning( + f"Another instance of radioactive is already running (PID: {old_pid})" + ) + try: + choice = input( + "Open the existing one or open a new instance? (e/n): " + ).lower() + if choice == "e": + log.info( + "Continuing with existing instance. Exiting." + ) + sys.exit(0) + elif choice == "n": + log.info("Starting a new instance.") + else: + log.info("Invalid choice. Exiting.") + sys.exit(1) + except EOFError: + sys.exit(0) + except (ValueError, psutil.NoSuchProcess, Exception) as e: + log.debug(f"Error checking PID: {e}") + pass + + # Save current PID + with open(pid_file, "w") as f: + f.write(str(os.getpid())) + + def cleanup(): + if os.path.exists(pid_file): + try: + with open(pid_file, "r") as f: + content = f.read().strip() + if content: + pid = int(content) + if pid == os.getpid(): + os.remove(pid_file) + except: + pass + + atexit.register(cleanup) + + handle_welcome_screen() + # ------------------ SCHEDULED RECORDING MODE ------------------ # from radioactive.feature_flags import RECORDING_FEATURE diff --git a/radioactive/paths.py b/radioactive/paths.py index 71aa1dd..e503380 100644 --- a/radioactive/paths.py +++ b/radioactive/paths.py @@ -139,3 +139,11 @@ def get_recordings_path(): # Not exiting here, hoping the caller handles it or it works next time return recordings_path + + +def get_pid_path(): + """ + Get the path to the PID file: ~/radioactive/radioactive.pid + """ + base_dir = get_base_dir() + return os.path.join(base_dir, "radioactive.pid") diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 10acb05..71d04ec 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -153,6 +153,7 @@ def handle_vim_style_prompt(alias=None, history=None): "n": "next station", "timer": "timer", "sleep": "sleep", + "b": "background", "q": "quit", "help": "help", "?": "help", @@ -214,6 +215,7 @@ def handle_vim_style_prompt(alias, history) -> str: "n": "next station", "timer": "timer", "sleep": "sleep", + "b": "background", "q": "quit", "help": "help", "?": "help", @@ -347,6 +349,7 @@ def add(cmd, desc): add("timer / sleep", "Set a sleep timer") add("q / quit", "Quit radioactive") + add("b / background", "Run radioactive in the background") add("Any Text", "Fuzzy search & play from favorites/history") # Center the table within a panel @@ -571,6 +574,47 @@ def stop_playback(): player.stop() sys.exit(0) + elif user_input in ["b", "B", "background"]: + log.info("Moving to background...") + try: + pid = os.fork() + if pid > 0: + # parent + log.info( + f"Radio-active is now running in the background. (PID: {pid})" + ) + # No need to stop the player, the child inherits it? + # Actually ffplay is a separate process. + # We want the parent to exit and child to keep running. + # We should probably write the child PID to the PID file if we are using one. + from radioactive.paths import get_pid_path + + with open(get_pid_path(), "w") as f: + f.write(str(pid)) + + sys.exit(0) + else: + # child + os.setsid() + # Redirect standard file descriptors + sys.stdin.close() + sys.stdout = open(os.devnull, "w") + sys.stderr = open(os.devnull, "w") + # child should not listen to keypresses anymore + import signal + + try: + signal.pause() + except: + while True: + time.sleep(100) + sys.exit(0) + except AttributeError: + log.error("Background mode is only supported on Unix-like systems") + except Exception as e: + log.error(f"Error while backgrounding: {e}") + continue + # elif user_input in ["w", "W"]: # alias.generate_map() # handle_favorite_table(alias) From 42b69648bafe68d1cc321aed4a5765552722f213 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 13 Apr 2026 00:40:22 +0530 Subject: [PATCH 21/31] feat: add HISTORY_FEATURE flag to configuration and feature manager --- features.conf | 1 + radioactive/feature_flags.py | 1 + 2 files changed, 2 insertions(+) diff --git a/features.conf b/features.conf index 125ed4e..092b37c 100644 --- a/features.conf +++ b/features.conf @@ -9,3 +9,4 @@ SEARCH_FEATURE=true CYCLE_FEATURE=true INFO_FEATURE=true TIMER_FEATURE=true +HISTORY_FEATURE=true diff --git a/radioactive/feature_flags.py b/radioactive/feature_flags.py index 08b274b..366b6ee 100644 --- a/radioactive/feature_flags.py +++ b/radioactive/feature_flags.py @@ -7,3 +7,4 @@ CYCLE_FEATURE = True INFO_FEATURE = True TIMER_FEATURE = True +HISTORY_FEATURE = True From 83e50bc226881c6e6b0981923b682509a2fa0c9d Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 13 Apr 2026 00:51:31 +0530 Subject: [PATCH 22/31] feat: add auto-track info fetcher and improve case-insensitive station matching --- radioactive/__main__.py | 2 +- radioactive/actions.py | 67 ++++++++++++++++++++----- radioactive/alias.py | 2 +- radioactive/history.py | 23 ++++----- radioactive/utilities.py | 104 +++++++++++++++++++++------------------ 5 files changed, 122 insertions(+), 76 deletions(-) diff --git a/radioactive/__main__.py b/radioactive/__main__.py index 174507d..a145bc7 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -518,7 +518,7 @@ def cleanup(): # ------------------------- direct play ------------------------# if options["direct_play"] is not None: options["curr_station_name"], options["target_url"] = handle_direct_play( - alias, options["direct_play"] + alias, history, options["direct_play"] ) final_step(options, last_station, alias, handler, history) diff --git a/radioactive/actions.py b/radioactive/actions.py index d9f2e3a..792115f 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -24,10 +24,8 @@ from radioactive.last_station import Last_station -def handle_fetch_song_title(url: str) -> None: - """Fetch currently playing track information""" - log.info("Fetching the current track info") - log.debug(f"Attempting to retrieve track info from: {url}") +def get_current_track_name(url: str) -> str: + """Fetch currently playing track information and return it""" # Run ffprobe command and capture the metadata cmd = [ "ffprobe", @@ -50,7 +48,17 @@ def handle_fetch_song_title(url: str) -> None: # Extract the station name (icy-name) if available track_name = data.get("format", {}).get("tags", {}).get("StreamTitle", "") except Exception: - log.error("Error while fetching the track name") + log.debug("Error while fetching the track name") + + return track_name + + +def handle_fetch_song_title(url: str) -> None: + """Fetch currently playing track information""" + log.info("Fetching the current track info") + log.debug(f"Attempting to retrieve track info from: {url}") + + track_name = get_current_track_name(url) if track_name != "": log.info(f"🎶: {track_name}") @@ -187,8 +195,8 @@ def handle_save_last_station(last_station, station_name: str, station_url: str) # last_station = Last_station() # Provided as arg now last_played_station = {} - last_played_station["name"] = station_name - last_played_station["uuid_or_url"] = station_url + last_played_station["name"] = station_name.strip() + last_played_station["uuid_or_url"] = station_url.strip() log.debug(f"Saving the current station: {last_played_station}") last_station.save_info(last_played_station) @@ -217,8 +225,8 @@ def handle_save_to_history(history, station_name: str, station_url: str) -> None station_data.update(global_info) # re-ensure required keys - station_data["name"] = station_name - station_data["uuid_or_url"] = station_url + station_data["name"] = station_name.strip() + station_data["uuid_or_url"] = station_url.strip() log.debug(f"Adding to history: {station_name}") history.append(station_data) @@ -276,7 +284,9 @@ def handle_station_uuid_play(handler, station_uuid: str) -> Tuple[str, str]: return station_name, station_url -def handle_direct_play(alias, station_name_or_url: str = "") -> Tuple[str, str]: +def handle_direct_play( + alias, history=None, station_name_or_url: str = "" +) -> Tuple[str, str]: """Play a station directly with UUID or direct stream URL.""" if "://" in station_name_or_url.strip(): log.debug("Direct play: URL provided") @@ -291,12 +301,45 @@ def handle_direct_play(alias, station_name_or_url: str = "") -> Tuple[str, str]: # search for the station in fav list and return name and url response = alias.search(station_name_or_url) + if not response and history: + log.debug("Not found in favorites, checking history") + # history object should have a search method or we iterate + # looking at history.py might be good + if hasattr(history, "search"): + response = history.search(station_name_or_url) + else: + # fallback iteration + for entry in history.get_list(): + name = entry.get("name", "").strip() + val = entry.get("uuid_or_url", "").strip() + # also check stationuuid if it exists (older history) + sid = entry.get("stationuuid", "").strip() + + token = station_name_or_url.strip().lower() + log.debug( + f"Comparing history entry: '{name.lower()}' with token: '{token}'" + ) + + if ( + name.lower() == token + or val == station_name_or_url.strip() + or sid == station_name_or_url.strip() + ): + log.debug(f"History match found: {name}") + response = entry + break + if not response: - log.error("No station found on your favorite list with the name") + log.debug(f"Search failed for: {station_name_or_url}") + log.error( + "No station found on your favorite list or history with that name" + ) sys.exit(1) else: log.debug(f"Direct play: {response}") - return response["name"], response["uuid_or_url"] + return response["name"], response.get("uuid_or_url") or response.get( + "stationuuid" + ) def handle_play_last_station(last_station) -> Tuple[str, str]: diff --git a/radioactive/alias.py b/radioactive/alias.py index a46b3ab..557fe38 100644 --- a/radioactive/alias.py +++ b/radioactive/alias.py @@ -62,7 +62,7 @@ def search(self, entry): if len(self.alias_map) > 0: log.debug("looking under alias file") for alias in self.alias_map: - if alias["name"].strip() == entry.strip(): + if alias["name"].strip().lower() == entry.strip().lower(): log.debug( "Alias found: {} == {}".format( alias["name"], alias["uuid_or_url"] diff --git a/radioactive/history.py b/radioactive/history.py index 4089ec0..e3faba0 100644 --- a/radioactive/history.py +++ b/radioactive/history.py @@ -32,22 +32,19 @@ def append(self, station): # and bring it to top new_list = [] for s in self.history_list: + prev_name = s.get("name", "").strip().lower() + curr_name = station.get("name", "").strip().lower() + + prev_uuid = (s.get("stationuuid") or s.get("uuid_or_url") or "").strip() + curr_uuid = ( + station.get("stationuuid") or station.get("uuid_or_url") or "" + ).strip() + # check name - if s.get("name") == station.get("name"): + if prev_name == curr_name: continue # check uuid if available - if ( - s.get("stationuuid") - and station.get("stationuuid") - and s.get("stationuuid") == station.get("stationuuid") - ): - continue - # check url if available (for direct play) - if ( - s.get("url") - and station.get("url") - and s.get("url") == station.get("url") - ): + if prev_uuid and curr_uuid and prev_uuid == curr_uuid: continue new_list.append(s) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 71d04ec..83198e9 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -36,6 +36,7 @@ from radioactive.actions import ( check_sort_by_parameter, + get_current_track_name, handle_add_station, handle_add_to_favorite, handle_direct_play, @@ -128,53 +129,6 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st return handle_station_uuid_play(handler, station_uuid) -def handle_vim_style_prompt(alias=None, history=None): - """ - Shows a Vim-style command prompt (:) at the bottom with descriptive Tab completion - and fuzzy search for favorites/history stations. - """ - from rich.console import Console - from rich.live import Live - from rich.text import Text - - # Mapping of shortcut/command to descriptive full text - command_map = { - "p": "play/pause", - "t": "track info", - "i": "station info", - "r": "record", - "rf": "record file", - "f": "add favorite", - "l": "list favorites", - "v+": "volume +", - "v-": "volume -", - "v": "set volume", - "s": "search", - "n": "next station", - "timer": "timer", - "sleep": "sleep", - "b": "background", - "q": "quit", - "help": "help", - "?": "help", - } - - # Combined list for matching - completions = list(command_map.keys()) - - # Build a list of station names for fuzzy search - station_names = [] - if alias and hasattr(alias, "alias_map"): - station_names += [s.get("name", "").strip() for s in alias.alias_map] - if history and hasattr(history, "get_list"): - station_names += [s.get("name", "").strip() for s in history.get_list()] - - # Clean and deduplicate station names - station_names = sorted(list(set([n for n in station_names if n]))) - - buffer = "" - - def get_key(): """Helper to capture single key on Linux.""" import sys @@ -213,6 +167,7 @@ def handle_vim_style_prompt(alias, history) -> str: "v": "set volume", "s": "search", "n": "next station", + "a": "auto track info", "timer": "timer", "sleep": "sleep", "b": "background", @@ -345,6 +300,8 @@ def add(cmd, desc): add("s / search", "Search for a new station") if CYCLE_FEATURE: add("n / next", "Play next result from search or favorites") + if TRACK_FEATURE: + add("a / auto", "Fetch track info every 10s") if TIMER_FEATURE: add("timer / sleep", "Set a sleep timer") @@ -372,6 +329,49 @@ def add(cmd, desc): pass +class AutoFetcher: + def __init__(self): + self.enabled = False + self.thread = None + self.last_song = "" + self.target_url = "" + + def start(self, url): + if self.enabled: + return + self.enabled = True + self.target_url = url + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + log.info("Auto track info fetcher started (10s interval)") + + def stop(self): + self.enabled = False + if self.thread: + # self.thread.join() + self.thread = None + log.info("Auto track info fetcher stopped") + + def toggle(self, url): + if self.enabled: + self.stop() + else: + self.start(url) + + def update_url(self, url): + self.target_url = url + self.last_song = "" + + def _run(self): + while self.enabled: + if self.target_url: + current_song = get_current_track_name(self.target_url) + if current_song and current_song != self.last_song: + log.info(f"🎶: {current_song}") + self.last_song = current_song + time.sleep(10) + + def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: """ Handle user selection from search results. @@ -466,6 +466,7 @@ def handle_listen_keypress( Now with handler and station_list for runtime commands. """ # log.info("Press '?' to see available commands\n") + auto_fetcher = AutoFetcher() while True: try: user_input = handle_vim_style_prompt(alias, history) @@ -644,6 +645,7 @@ def stop_playback(): station_name = new_station_name station_url = new_target_url target_url = new_target_url + auto_fetcher.update_url(target_url) except Exception as e: log.error(f"Error selecting station: {e}") else: @@ -652,6 +654,9 @@ def stop_playback(): elif TRACK_FEATURE and user_input in ["t", "T", "track"]: handle_fetch_song_title(target_url) + elif TRACK_FEATURE and user_input in ["a", "A", "auto"]: + auto_fetcher.toggle(target_url) + elif user_input in ["p", "P"]: player.toggle() @@ -695,6 +700,7 @@ def stop_playback(): station_name = new_station_name station_url = new_target_url target_url = new_target_url + auto_fetcher.update_url(target_url) except SystemExit: # handle_user_choice might try to exit on cancel pass @@ -858,9 +864,9 @@ def stop_playback(): elif user_input.strip() != "": # Fuzzy match station from aliases or history if not a direct command # Try to see if it's a station name the user typed - log.info(f"Checking for station: {user_input}") + # log.info(f"Checking for station: {user_input}") try: - name, url = handle_direct_play(alias, user_input) + name, url = handle_direct_play(alias, history, user_input) if url: if url == target_url: log.info("Station is already playing!") From 7f643da8c953b43577da2617887ef175b13d6b22 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 13 Apr 2026 01:06:19 +0530 Subject: [PATCH 23/31] refactor: centralize rich console instance and update song display output format --- radioactive/ui.py | 1 + radioactive/utilities.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/radioactive/ui.py b/radioactive/ui.py index c5c3009..d23e8f0 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -12,6 +12,7 @@ # Global variable to store current station info for display # This is shared state, ideally should be managed better, but keeping for compatibility global_current_station_info = {} +console = Console() def handle_welcome_screen() -> None: diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 83198e9..2b1ea55 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -13,6 +13,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union from pick import pick +from rich.live import Live +from rich.text import Text from zenlog import log try: @@ -55,6 +57,7 @@ # Re-export functions for backward compatibility and aggregation from radioactive.ui import ( + console, get_global_station_info, handle_current_play_panel, handle_favorite_table, @@ -150,8 +153,6 @@ def get_key(): def handle_vim_style_prompt(alias, history) -> str: """Captured VIM style command prompt with fuzzy search and completions.""" - from rich.live import Live - from rich.text import Text # Mapping of shortcut/command to descriptive full text command_map = { @@ -219,7 +220,9 @@ def get_display(text, matches=[]): return prompt_text - with Live(get_display(""), transient=True, refresh_per_second=10) as live: + with Live( + get_display(""), console=console, transient=True, refresh_per_second=10 + ) as live: while True: char = get_key() @@ -367,7 +370,9 @@ def _run(self): if self.target_url: current_song = get_current_track_name(self.target_url) if current_song and current_song != self.last_song: - log.info(f"🎶: {current_song}") + console.print( + f"\n [green]i[/green] |🎶: [cyan]{current_song}[cyan]" + ) self.last_song = current_song time.sleep(10) From 11e04c72141591428d544a0147836e520ffefbd9 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 13 Apr 2026 01:06:36 +0530 Subject: [PATCH 24/31] Revert "refactor: centralize rich console instance and update song display output format" This reverts commit 7f643da8c953b43577da2617887ef175b13d6b22. --- radioactive/ui.py | 1 - radioactive/utilities.py | 13 ++++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/radioactive/ui.py b/radioactive/ui.py index d23e8f0..c5c3009 100644 --- a/radioactive/ui.py +++ b/radioactive/ui.py @@ -12,7 +12,6 @@ # Global variable to store current station info for display # This is shared state, ideally should be managed better, but keeping for compatibility global_current_station_info = {} -console = Console() def handle_welcome_screen() -> None: diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 2b1ea55..83198e9 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -13,8 +13,6 @@ from typing import Any, Dict, List, Optional, Tuple, Union from pick import pick -from rich.live import Live -from rich.text import Text from zenlog import log try: @@ -57,7 +55,6 @@ # Re-export functions for backward compatibility and aggregation from radioactive.ui import ( - console, get_global_station_info, handle_current_play_panel, handle_favorite_table, @@ -153,6 +150,8 @@ def get_key(): def handle_vim_style_prompt(alias, history) -> str: """Captured VIM style command prompt with fuzzy search and completions.""" + from rich.live import Live + from rich.text import Text # Mapping of shortcut/command to descriptive full text command_map = { @@ -220,9 +219,7 @@ def get_display(text, matches=[]): return prompt_text - with Live( - get_display(""), console=console, transient=True, refresh_per_second=10 - ) as live: + with Live(get_display(""), transient=True, refresh_per_second=10) as live: while True: char = get_key() @@ -370,9 +367,7 @@ def _run(self): if self.target_url: current_song = get_current_track_name(self.target_url) if current_song and current_song != self.last_song: - console.print( - f"\n [green]i[/green] |🎶: [cyan]{current_song}[cyan]" - ) + log.info(f"🎶: {current_song}") self.last_song = current_song time.sleep(10) From 7770610c0baab911dbc8772d67e933e7ac7a8bf6 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 13 Apr 2026 01:29:27 +0530 Subject: [PATCH 25/31] feat: add desktop notifications for track changes using notify-send --- radioactive/actions.py | 19 +++++++++++++++++++ radioactive/utilities.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/radioactive/actions.py b/radioactive/actions.py index 792115f..ae2674a 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -24,6 +24,25 @@ from radioactive.last_station import Last_station +def handle_notification(title: str, message: str) -> None: + """Send a desktop notification on Linux.""" + from shutil import which + + if which("notify-send"): + try: + subprocess.Popen( + ["notify-send", "-a radioactive", "-u normal", title, message], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + log.debug(f"Error sending notification: {e}") + else: + log.error( + "notify-send is not installed. please install it to use notifications" + ) + + def get_current_track_name(url: str) -> str: """Fetch currently playing track information and return it""" # Run ffprobe command and capture the metadata diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 83198e9..9ecde16 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -42,6 +42,7 @@ handle_direct_play, handle_fetch_song_title, handle_get_station_name_from_metadata, + handle_notification, handle_play_last_station, handle_play_random_station, handle_record, @@ -367,7 +368,7 @@ def _run(self): if self.target_url: current_song = get_current_track_name(self.target_url) if current_song and current_song != self.last_song: - log.info(f"🎶: {current_song}") + handle_notification("New song: ", current_song) self.last_song = current_song time.sleep(10) From 8476180fa024566f44dad28612ecdb2461eb5237 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Mon, 13 Apr 2026 18:46:33 +0530 Subject: [PATCH 26/31] refactor: replace sys.exit calls with return statements to support idle mode and prevent unexpected application termination --- radioactive/__main__.py | 66 ++++++++++--------- radioactive/actions.py | 6 +- radioactive/ffplay.py | 1 + radioactive/utilities.py | 136 ++++++++++++++++++++++++++------------- 4 files changed, 131 insertions(+), 78 deletions(-) diff --git a/radioactive/__main__.py b/radioactive/__main__.py index a145bc7..81fa8dc 100755 --- a/radioactive/__main__.py +++ b/radioactive/__main__.py @@ -49,12 +49,12 @@ def final_step(options, last_station, alias, handler, history, station_list=None global ffplay # always needed global player - # check target URL for the last time - if options["target_url"].strip() == "": - log.error("something is wrong with the url") - sys.exit(1) - - if options["audio_player"] == "vlc": + # check target URL + target_url = (options.get("target_url") or "").strip() + if target_url == "": + log.info("Type 's' to search for a station or '?' for help") + player = None + elif options["audio_player"] == "vlc": from radioactive.vlc import VLC vlc = VLC(options["volume"]) @@ -76,32 +76,32 @@ def final_step(options, last_station, alias, handler, history, station_list=None log.error("Unsupported media player selected") sys.exit(1) - if options["curr_station_name"].strip() == "": + if (options.get("curr_station_name") or "").strip() == "": options["curr_station_name"] = "N/A" - handle_save_last_station( - last_station, options["curr_station_name"], options["target_url"] - ) + if target_url != "": + handle_save_last_station(last_station, options["curr_station_name"], target_url) - handle_save_to_history(history, options["curr_station_name"], options["target_url"]) + handle_save_to_history(history, options["curr_station_name"], target_url) - if options["add_to_favorite"]: - handle_add_to_favorite( - alias, options["curr_station_name"], options["target_url"] - ) + if options["add_to_favorite"]: + handle_add_to_favorite(alias, options["curr_station_name"], target_url) - handle_current_play_panel(options["curr_station_name"]) + handle_current_play_panel(options["curr_station_name"]) if options["record_stream"]: - handle_record( - options["target_url"], - options["curr_station_name"], - options["record_file_path"], - options["record_file"], - options["record_file_format"], - options["loglevel"], - options.get("record_duration"), - ) + if target_url == "": + log.error("Cannot record in idle mode. Please select a station first.") + else: + handle_record( + target_url, + options["curr_station_name"], + options["record_file_path"], + options["record_file"], + options["record_file_format"], + options["loglevel"], + options.get("record_duration"), + ) handle_listen_keypress( alias, @@ -117,6 +117,8 @@ def final_step(options, last_station, alias, handler, history, station_list=None last_station=last_station, station_list=station_list, history=history, + audio_player=options["audio_player"], + volume=options["volume"], ) @@ -415,7 +417,8 @@ def cleanup(): ) = handle_user_choice_from_search_result(handler, response) final_step(options, last_station, alias, handler, history, response) else: - sys.exit(0) + log.info("No stations found for this country.") + final_step(options, last_station, alias, handler, history) # -------------- state ------------- # if options["discover_state"]: @@ -432,7 +435,8 @@ def cleanup(): ) = handle_user_choice_from_search_result(handler, response) final_step(options, last_station, alias, handler, history, response) else: - sys.exit(0) + log.info("No stations found for this state.") + final_step(options, last_station, alias, handler, history) # ----------- language ------------ # if options["discover_language"]: @@ -449,7 +453,8 @@ def cleanup(): ) = handle_user_choice_from_search_result(handler, response) final_step(options, last_station, alias, handler, history, response) else: - sys.exit(0) + log.info("No stations found for this language.") + final_step(options, last_station, alias, handler, history) # -------------- tag ------------- # if options["discover_tag"]: @@ -466,7 +471,8 @@ def cleanup(): ) = handle_user_choice_from_search_result(handler, response) final_step(options, last_station, alias, handler, history, response) else: - sys.exit(0) + log.info("No stations found for this tag.") + final_step(options, last_station, alias, handler, history) # -------------------- NOTHING PROVIDED --------------------- # if ( @@ -514,7 +520,7 @@ def cleanup(): # print(response) final_step(options, last_station, alias, handler, history, response) else: - sys.exit(0) + final_step(options, last_station, alias, handler, history) # ------------------------- direct play ------------------------# if options["direct_play"] is not None: options["curr_station_name"], options["target_url"] = handle_direct_play( diff --git a/radioactive/actions.py b/radioactive/actions.py index ae2674a..bd3b247 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -298,7 +298,7 @@ def handle_station_uuid_play(handler, station_uuid: str) -> Tuple[str, str]: except Exception as e: log.debug(f"{e}") log.error("Something went wrong") - sys.exit(1) + return None, None return station_name, station_url @@ -353,7 +353,7 @@ def handle_direct_play( log.error( "No station found on your favorite list or history with that name" ) - sys.exit(1) + return None, None else: log.debug(f"Direct play: {response}") return response["name"], response.get("uuid_or_url") or response.get( @@ -429,7 +429,7 @@ def handle_play_random_station(alias) -> Tuple[str, str]: alias_map = alias.alias_map if not alias_map: log.error("No favorite stations found") - sys.exit(1) + return None, None index = randint(0, len(alias_map) - 1) station = alias_map[index] diff --git a/radioactive/ffplay.py b/radioactive/ffplay.py index 1720b03..8277a0c 100644 --- a/radioactive/ffplay.py +++ b/radioactive/ffplay.py @@ -139,6 +139,7 @@ def _handle_error(self, stderr_result: str) -> None: if len(parts) > 1: log.error(parts[1].strip()) else: + print() log.error(stderr_result.strip()) except Exception as e: log.debug(f"Error parsing stderr: {e}") diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 9ecde16..8f75cb4 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -109,9 +109,9 @@ def handle_station_selection_menu(handler, last_station, alias) -> Tuple[str, st options = station_selection_names if len(options) == 0: log.info( - f"{RED_COLOR}No stations to play. please search for a station first!{END_COLOR}" + f"{RED_COLOR}No stations found in favorites or history. Entering idle mode.{END_COLOR}" ) - sys.exit(0) + return None, None _, index = pick(options, title, indicator="-->") @@ -379,7 +379,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: """ if not response: log.debug("No result found!") - sys.exit(0) + return None, None if len(response) == 1: # single station found @@ -389,7 +389,7 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: user_input = input("Want to play this station? Y/N: ") except EOFError: print() - sys.exit(0) + return None, None if user_input in ["y", "Y"]: log.debug("Playing UUID from single response") @@ -398,8 +398,8 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: return handle_station_uuid_play(handler, response[0]["stationuuid"]) else: - log.debug("Quitting") - sys.exit(0) + log.debug("User chose not to play") + return None, None else: # multiple station log.debug("Asking for user input") @@ -409,9 +409,8 @@ def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: user_input = input("Type the result ID to play: ") except EOFError: print() - log.info("Exiting") - log.debug("EOF reached, quitting") - sys.exit(0) + log.info("Returning to command prompt") + return None, None try: if user_input in ["n", "N", "next"]: @@ -461,6 +460,8 @@ def handle_listen_keypress( last_station=None, station_list=None, history=None, + audio_player="ffplay", + volume=80, ) -> None: """ Listen for user input during playback to perform actions. @@ -479,15 +480,18 @@ def handle_listen_keypress( sys.exit(0) if RECORDING_FEATURE and user_input in ["r", "R", "record"]: - process, outfile_path = handle_record( - target_url, - station_name, - record_file_path, - record_file, - record_file_format, - loglevel, - ) - handle_recording_popup(process, outfile_path) + if not target_url: + log.error("Please select a station to record first.") + else: + process, outfile_path = handle_record( + target_url, + station_name, + record_file_path, + record_file, + record_file_format, + loglevel, + ) + handle_recording_popup(process, outfile_path) continue elif RECORDING_FEATURE and user_input in ["rf", "RF", "recordfile"]: @@ -520,15 +524,18 @@ def handle_listen_keypress( file_name = user_input if user_input.strip() != "": - process, outfile_path = handle_record( - target_url, - station_name, - record_file_path, - file_name, - record_file_format, - loglevel, - ) - handle_recording_popup(process, outfile_path) + if not target_url: + log.error("Please select a station to record first.") + else: + process, outfile_path = handle_record( + target_url, + station_name, + record_file_path, + file_name, + record_file_format, + loglevel, + ) + handle_recording_popup(process, outfile_path) continue if INFO_FEATURE and user_input in ["i", "I", "info"]: @@ -573,7 +580,8 @@ def stop_playback(): handle_add_to_favorite(alias, station_name, station_url) elif user_input in ["q", "Q", "quit"]: - player.stop() + if player: + player.stop() sys.exit(0) elif user_input in ["b", "B", "background"]: @@ -624,16 +632,32 @@ def stop_playback(): elif user_input in ["l", "L", "list"]: if handler and last_station: try: - new_station_name, new_target_url = handle_station_selection_menu( - handler, last_station, alias - ) + res = handle_station_selection_menu(handler, last_station, alias) + new_station_name, new_target_url = res if new_target_url: - if new_target_url == target_url: - log.info("Station is already playing!") - continue - player.stop() - player.url = new_target_url - player.play() + if player: + if new_target_url == target_url: + log.info("Station is already playing!") + continue + player.stop() + player.url = new_target_url + player.play() + else: + # Initialize player + if audio_player == "vlc": + from radioactive.vlc import VLC + + player = VLC(volume) + player.start(new_target_url) + elif audio_player == "mpv": + from radioactive.mpv import MPV + + player = MPV(volume) + player.start(new_target_url) + else: + from radioactive.ffplay import Ffplay + + player = Ffplay(new_target_url, volume, loglevel) handle_current_play_panel(new_station_name) # Save the new station as last played and add to history handle_save_last_station( @@ -659,7 +683,10 @@ def stop_playback(): auto_fetcher.toggle(target_url) elif user_input in ["p", "P"]: - player.toggle() + if player: + player.toggle() + else: + log.info("Nothing to play") elif SEARCH_FEATURE and user_input in ["s", "S", "search"]: if handler: @@ -682,13 +709,32 @@ def stop_playback(): ) ) if new_target_url: - if new_target_url == target_url: - log.info("Station is already playing!") - continue - # Stop current, switch - player.stop() - player.url = new_target_url - player.play() + if player: + if new_target_url == target_url: + log.info("Station is already playing!") + continue + # Stop current, switch + player.stop() + player.url = new_target_url + player.play() + else: + # Initialize player + if audio_player == "vlc": + from radioactive.vlc import VLC + + player = VLC(volume) + player.start(new_target_url) + elif audio_player == "mpv": + from radioactive.mpv import MPV + + player = MPV(volume) + player.start(new_target_url) + else: + from radioactive.ffplay import Ffplay + + player = Ffplay( + new_target_url, volume, loglevel + ) handle_current_play_panel(new_station_name) # Save the new station as last played and add to history handle_save_last_station( From 267d6f68610cdd5e3178f8be6361228196d17b87 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Fri, 17 Apr 2026 20:42:44 +0530 Subject: [PATCH 27/31] feat: enhance desktop notifications with station branding and track metadata --- radioactive/actions.py | 15 +++++++++++++-- radioactive/paths.py | 27 +++++++++++++++++++++++++++ radioactive/utilities.py | 14 +++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/radioactive/actions.py b/radioactive/actions.py index bd3b247..fbd9383 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -24,14 +24,25 @@ from radioactive.last_station import Last_station -def handle_notification(title: str, message: str) -> None: +def handle_notification(title: str, message: str, icon: str = None) -> None: """Send a desktop notification on Linux.""" from shutil import which + from radioactive.paths import get_logo_path + if which("notify-send"): + if not icon: + icon = get_logo_path() + + cmd = ["notify-send", "-a", "radioactive", "-u", "normal"] + if icon: + cmd.extend(["-i", icon]) + + cmd.extend([title, message]) + try: subprocess.Popen( - ["notify-send", "-a radioactive", "-u normal", title, message], + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/radioactive/paths.py b/radioactive/paths.py index e503380..1b45753 100644 --- a/radioactive/paths.py +++ b/radioactive/paths.py @@ -147,3 +147,30 @@ def get_pid_path(): """ base_dir = get_base_dir() return os.path.join(base_dir, "radioactive.pid") + + +def get_logo_path(): + """ + Get the path to the application logo (logo.png) within the package. + """ + try: + # Try to find logo in the package directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + # assuming the package structure is radioactive/paths.py and the logo is at radioactive/../images/logo.png or radioactive/logo.png + # but from the list_dir earlier, it is in /home/deep/code/radio-active/images/logo.png + # if installed, it might be in a different place. + # Let's try to locate it relative to the radioactive directory. + parent_dir = os.path.dirname(current_dir) + logo_path = os.path.join(parent_dir, "images", "logo.png") + + if os.path.exists(logo_path): + return logo_path + + # Fallback if images dir is inside radioactive (though not seen in list_dir) + logo_path_internal = os.path.join(current_dir, "images", "logo.png") + if os.path.exists(logo_path_internal): + return logo_path_internal + + return None + except Exception: + return None diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 8f75cb4..ba1c430 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -368,7 +368,19 @@ def _run(self): if self.target_url: current_song = get_current_track_name(self.target_url) if current_song and current_song != self.last_song: - handle_notification("New song: ", current_song) + station_info = get_global_station_info() + station_name = station_info.get("name", "Unknown Station") + + notification_title = f"Now Playing on {station_name}" + notification_message = f"🎶 {current_song}" + + # Add extra info if available + codec = station_info.get("codec") + bitrate = station_info.get("bitrate") + if codec and bitrate: + notification_message += f"\n({codec} • {bitrate} kbps)" + + handle_notification(notification_title, notification_message) self.last_song = current_song time.sleep(10) From a704f13bb68697ae7a06a155c5c3e0404b4aae8f Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Fri, 17 Apr 2026 20:49:36 +0530 Subject: [PATCH 28/31] refactor: optimize track polling interval and update ffprobe metadata extraction logic --- radioactive/actions.py | 24 ++++++++++++++++-------- radioactive/utilities.py | 7 ++++++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/radioactive/actions.py b/radioactive/actions.py index fbd9383..914645c 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -57,28 +57,36 @@ def handle_notification(title: str, message: str, icon: str = None) -> None: def get_current_track_name(url: str) -> str: """Fetch currently playing track information and return it""" # Run ffprobe command and capture the metadata + # -i is implicit if it's the last arg, but let's be explicit cmd = [ "ffprobe", "-v", "quiet", + "-select_streams", + "a:0", + "-show_entries", + "format_tags=StreamTitle", "-print_format", "json", - "-show_format", - "-show_entries", - "format=icy", url, ] track_name = "" try: - output = subprocess.check_output(cmd).decode("utf-8") + # 10 second timeout for the ffprobe command itself + output = subprocess.check_output(cmd, timeout=10).decode("utf-8") data = json.loads(output) log.debug(f"station info: {data}") - # Extract the station name (icy-name) if available - track_name = data.get("format", {}).get("tags", {}).get("StreamTitle", "") - except Exception: - log.debug("Error while fetching the track name") + # Extract the song title (StreamTitle) if available + # It's usually in format -> tags -> StreamTitle + track_name = ( + data.get("format", {}).get("tags", {}).get("StreamTitle", "").strip() + ) + except subprocess.TimeoutExpired: + log.debug("Track info fetch timed out") + except Exception as e: + log.debug(f"Error while fetching the track name: {e}") return track_name diff --git a/radioactive/utilities.py b/radioactive/utilities.py index ba1c430..71d2289 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -365,6 +365,7 @@ def update_url(self, url): def _run(self): while self.enabled: + start_time = time.time() if self.target_url: current_song = get_current_track_name(self.target_url) if current_song and current_song != self.last_song: @@ -382,7 +383,11 @@ def _run(self): handle_notification(notification_title, notification_message) self.last_song = current_song - time.sleep(10) + + # Calculate remaining time to sleep to keep a 10s interval + elapsed = time.time() - start_time + sleep_time = max(0, 10 - elapsed) + time.sleep(sleep_time) def handle_user_choice_from_search_result(handler, response) -> Tuple[str, str]: From 873285cd30c41eecda951be7c88c023448f3ac65 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Fri, 17 Apr 2026 21:03:20 +0530 Subject: [PATCH 29/31] chore: comment out volume increment/decrement help text in utilities menu --- radioactive/utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 71d2289..8e29373 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -295,7 +295,7 @@ def add(cmd, desc): add("f / fav", "Add station to favorite list") add("l / list", "Open favorite station selection menu") add("v <0-100>", "Set volume level") - add("v+ / v-", "Increase / Decrease volume level") + # add("v+ / v-", "Increase / Decrease volume level") if SEARCH_FEATURE: add("s / search", "Search for a new station") From 8396bd9298daedff93c9f60b968a2cd27f68f097 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Fri, 17 Apr 2026 21:11:48 +0530 Subject: [PATCH 30/31] feat: add song identification feature using Shazam and FFmpeg recording --- radioactive/actions.py | 80 ++++++++++++++++++++++++++++++++++++++++ radioactive/utilities.py | 7 ++++ requirements.txt | 1 + 3 files changed, 88 insertions(+) diff --git a/radioactive/actions.py b/radioactive/actions.py index 914645c..de39131 100644 --- a/radioactive/actions.py +++ b/radioactive/actions.py @@ -2,6 +2,7 @@ Core logical actions for radio-active. """ +import asyncio import datetime import json import os @@ -21,6 +22,7 @@ if RECORDING_FEATURE: from radioactive.recorder import record_audio_auto_codec, record_audio_from_url + from radioactive.last_station import Last_station @@ -453,3 +455,81 @@ def handle_play_random_station(alias) -> Tuple[str, str]: index = randint(0, len(alias_map) - 1) station = alias_map[index] return station["name"], station["uuid_or_url"] + + +async def _identify_music(audio_path): + """Internal async helper for Shazam identification.""" + try: + from shazamio import Shazam + + shazam = Shazam() + out = await shazam.recognize(audio_path) + return out + except Exception as e: + log.debug(f"Shazam identification failed: {e}") + return None + + +def handle_shazam(target_url: str): + """ + Record 7 seconds of audio and identify using Shazam. + """ + import tempfile + + if not target_url: + log.error("No station is playing, cannot identify.") + return + + log.info("Identifying current song (7 seconds) ...") + + # Create temp file + temp_dir = tempfile.gettempdir() + temp_file = os.path.join(temp_dir, "radioactive_shazam.mp3") + + # ffmpeg command to record 7 seconds + # we use libmp3lame to ensure it's a valid mp3 for shazam + cmd = [ + "ffmpeg", + "-y", # overwrite + "-i", + target_url, + "-t", + "7", + "-c:a", + "libmp3lame", + "-loglevel", + "error", + temp_file, + ] + + try: + # Run ffmpeg synchronously (blocking for 7s) + subprocess.run(cmd, check=True) + + if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0: + # shazamio is async, we need a runner + result = asyncio.run(_identify_music(temp_file)) + + if result and result.get("track"): + track = result.get("track") + title = track.get("title") + artist = track.get("subtitle") + log.info(f"🎶 Match found: {title} -- {artist}") + + # Also send a notification if possible + handle_notification("Song Identified", f"{title} - {artist}") + else: + log.warning("No match found for this song.") + else: + log.error("Could not record audio for identification.") + + except subprocess.CalledProcessError: + log.error("FFmpeg error while recording for identification.") + except Exception as e: + log.error(f"Error during identification: {e}") + finally: + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except: + pass diff --git a/radioactive/utilities.py b/radioactive/utilities.py index 8e29373..95c459f 100644 --- a/radioactive/utilities.py +++ b/radioactive/utilities.py @@ -49,6 +49,7 @@ handle_save_last_station, handle_save_to_history, handle_search_stations, + handle_shazam, handle_station_name_from_headers, handle_station_uuid_play, ) @@ -169,6 +170,8 @@ def handle_vim_style_prompt(alias, history) -> str: "s": "search", "n": "next station", "a": "auto track info", + "sz": "shazam identify", + "shazam": "shazam identify", "timer": "timer", "sleep": "sleep", "b": "background", @@ -303,6 +306,7 @@ def add(cmd, desc): add("n / next", "Play next result from search or favorites") if TRACK_FEATURE: add("a / auto", "Fetch track info every 10s") + add("sz / shazam", "Identify current song using Shazam") if TIMER_FEATURE: add("timer / sleep", "Set a sleep timer") @@ -699,6 +703,9 @@ def stop_playback(): elif TRACK_FEATURE and user_input in ["a", "A", "auto"]: auto_fetcher.toggle(target_url) + elif user_input in ["sz", "SZ", "shazam"]: + handle_shazam(target_url) + elif user_input in ["p", "P"]: if player: player.toggle() diff --git a/requirements.txt b/requirements.txt index f48ae4c..479bbf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ requests-cache rich pick zenlog +shazamio From 5f39a9881adc68de762d2e2dc61ade7eb6e3e821 Mon Sep 17 00:00:00 2001 From: Dipankar Pal Date: Fri, 17 Apr 2026 21:19:39 +0530 Subject: [PATCH 31/31] feat: add Shazam song identification, background playback, and updated documentation --- CHANGELOG.md | 20 +++++++++++--------- README.md | 8 +++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8c199..bcadd52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,16 @@ ## 4.0.0 -1. Major UI changes -2. Command prompt with advanced tab completion 🚀 -3. Real-time fuzzy station search across favorites and history -4. Integrated volume control for MPV, VLC, and FFplay players -5. Interactive station selection menu via `l/list` command -6. Improved help menu using a Rich-based interactive interface -7. Zen mode -8. New recording window -9. Stability improvements +1. Shazam !! song identification built-in 🚀 +2. Auto fetch current track information and send desktop notification +3. Volume control +4. Major UI changes +5. Command prompt with advanced tab completion 🚀 +6. Real-time fuzzy station search across favorites and history +7. Interactive station selection menu via `l/list` command +8. Improved help menu and recording UI +9. Zen mode +10. Background play +11. Stability improvements ## 3.0.4 diff --git a/README.md b/README.md index aae2f3c..ebbd32c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ - [x] Supports more than 40K stations !! :radio: - [x] Record audio from live radio on demand :zap: - [x] Get song information on run-time 🎶 +- [x] Shazam identification of tracks - [x] Saves last station information - [x] Favorite stations :heart: - [x] Selection menu for favorite stations @@ -55,7 +56,7 @@ - [x] Sleep Timer (pomodoro) ⏲️ - [x] History/Recently Played stations - [x] Scheduled Recording -- [ ] I'm feeling lucky! Play Random stations +- [x] I'm feeling lucky! Play Random stations > See my progress ➡️ [here](https://github.com/users/deep5050/projects/5) @@ -218,12 +219,13 @@ Radioactive features a modern, **Vim-style command bar** at the bottom of the sc | `r` | `record` | Start/Stop recording | | `rf` | `recordfile` | Record with a specific filename | | `f` | `fav` | Add current station to favorites | -| `l` | `list` | Open favorite selection menu | +| `l` | `list` | Open favorite station selection menu | | `s` | `search` | Search for a new station online | | `n` | `next` | Play next station (from search/favs) | | `timer` | `sleep` | Set a sleep timer | | `v` | `volume` | Set volume (e.g., `v 50`) | -| `v+` / `v-` | | Increase / Decrease volume | +| `sz` | `shazam` | Identify current song using Shazam | +| `b` | `background` | Run radioactive in the background | | `q` | `quit` | Exit Radioactive | #### Power Features: