app.py
1 from textual.app import App, ComposeResult 2 from textual.binding import Binding 3 from textual.widgets import Footer 4 5 import json 6 import subprocess 7 import sys 8 9 from linamp.config import load_config, QUEUE_PATH 10 from linamp.messages import PlayerStateUpdate, PlaylistModeChanged, StationSelected, LibraryChanged 11 from linamp.player import AudioPlayer 12 from linamp.stations import Station, load_library, save_library, all_stations 13 from linamp.screens.player_view import PlayerView 14 from linamp.screens.browser_view import BrowserView 15 16 17 class LinampApp(App): 18 """Terminal music player - the love child of Winamp and Midnight Commander.""" 19 20 TITLE = "linamp" 21 22 BINDINGS = [ 23 Binding("tab", "toggle_view", "View", priority=True), 24 Binding("space", "toggle_pause", "Play/Pause", priority=True), 25 Binding("s", "stop", "Stop", priority=True), 26 Binding("plus,equal", "volume_up", "Vol+", priority=True), 27 Binding("minus", "volume_down", "Vol-", priority=True), 28 Binding("f1", "radio_mode", "Radio", priority=True), 29 Binding("f2", "local_mode", "Local", priority=True), 30 Binding("f5", "open_library", "Library", priority=True), 31 Binding("q", "quit", "Quit", priority=True), 32 ] 33 34 MODES = { 35 "player": PlayerView, 36 "browser": BrowserView, 37 } 38 39 DEFAULT_MODE = "player" 40 41 CSS = """ 42 Screen { 43 background: #1e1e2e; 44 color: #cdd6f4; 45 } 46 Footer { 47 background: #313244; 48 } 49 """ 50 51 def __init__(self) -> None: 52 super().__init__() 53 self.audio = AudioPlayer() 54 self.config = load_config() 55 self.library = load_library() 56 self.playlist_mode: str = "radio" 57 self.local_queue: list[Station] = [] 58 59 @property 60 def flat_stations(self) -> list: 61 return all_stations(self.library) 62 63 @property 64 def active_playlist(self) -> list[Station]: 65 if self.playlist_mode == "local": 66 return self.local_queue 67 return self.flat_stations 68 69 # Actions that should be suppressed when BrowserView is in edit mode, 70 # so their keys can be handled by the screen's on_key() instead. 71 _EDIT_MODE_SUPPRESSED = {"stop", "volume_up", "volume_down", "toggle_pause"} 72 73 def check_action(self, action: str, parameters: tuple) -> bool | None: 74 """Suppress global actions that conflict with edit-mode key handling.""" 75 if action in self._EDIT_MODE_SUPPRESSED: 76 screen = self.screen 77 if getattr(screen, "_edit_mode", False): 78 return False 79 return True 80 81 def on_mount(self) -> None: 82 self.set_interval(0.5, self._poll_player_state) 83 84 def compose(self) -> ComposeResult: 85 yield Footer() 86 87 def _poll_player_state(self) -> None: 88 # Auto-advance: if track ended naturally (not user-stopped), play next 89 if ( 90 not self.audio.is_stopped 91 and self.audio.idle_active 92 and self.audio.current_station is not None 93 ): 94 self._play_next_track() 95 96 state = dict( 97 is_playing=self.audio.is_playing, 98 is_paused=self.audio.is_paused, 99 is_stopped=self.audio.is_stopped, 100 station=self.audio.current_station, 101 icy_title=self.audio.icy_title, 102 media_title=self.audio.media_title, 103 time_pos=self.audio.time_pos, 104 volume=self.audio.volume, 105 ) 106 # Broadcast to all widgets that handle PlayerStateUpdate. 107 # post_message only reaches the target + ancestors (bubbles up), 108 # so we must create a fresh message per widget to avoid 109 # stop-propagation from a prior recipient killing delivery. 110 if self.screen: 111 for widget in self.screen.walk_children(): 112 handler = getattr(widget, "on_player_state_update", None) 113 if handler is not None: 114 widget.post_message(PlayerStateUpdate(**state)) 115 116 def action_toggle_view(self) -> None: 117 if self.current_mode == "player": 118 self.switch_mode("browser") 119 else: 120 self.switch_mode("player") 121 122 async def action_open_library(self) -> None: 123 """Suspend TUI and launch the library manager as a subprocess. 124 125 Audio playback continues — mpv runs independently of the TUI. 126 On return, import any queued tracks into the local playlist. 127 """ 128 # Clear any stale queue file before launching 129 if QUEUE_PATH.exists(): 130 QUEUE_PATH.unlink() 131 132 with self.suspend(): 133 subprocess.run([sys.executable, "-m", "linamp.library"]) 134 135 # Import queued tracks from library manager 136 await self._import_queue() 137 138 def action_toggle_pause(self) -> None: 139 self.audio.toggle_pause() 140 141 def action_stop(self) -> None: 142 self.audio.stop() 143 144 def action_volume_up(self) -> None: 145 self.audio.volume_up() 146 147 def action_volume_down(self) -> None: 148 self.audio.volume_down() 149 150 async def action_radio_mode(self) -> None: 151 """Switch to radio playlist mode.""" 152 if self.playlist_mode == "radio": 153 return 154 self.playlist_mode = "radio" 155 await self._broadcast_mode_change() 156 157 async def action_local_mode(self) -> None: 158 """Switch to local playlist mode.""" 159 if self.playlist_mode == "local": 160 return 161 self.playlist_mode = "local" 162 await self._broadcast_mode_change() 163 164 async def _import_queue(self) -> None: 165 """Read queued tracks from library manager and add to local playlist.""" 166 if not QUEUE_PATH.exists(): 167 return 168 try: 169 data = json.loads(QUEUE_PATH.read_text()) 170 QUEUE_PATH.unlink() 171 except Exception: 172 return 173 if not data: 174 return 175 176 added = False 177 first_new = None 178 for entry in data: 179 station = Station( 180 name=entry.get("name", ""), 181 url=entry.get("url", ""), 182 genre=entry.get("genre", ""), 183 ) 184 if not any(s.url == station.url for s in self.local_queue): 185 self.local_queue.append(station) 186 if first_new is None: 187 first_new = station 188 added = True 189 190 if added: 191 # Switch to local mode and start playing the first new track 192 self.playlist_mode = "local" 193 await self._broadcast_mode_change() 194 if first_new: 195 self.audio.play(first_new) 196 197 async def _broadcast_mode_change(self) -> None: 198 """Update all PlaylistPanels and mode indicators with new mode.""" 199 stations = self.active_playlist 200 # Update playlist panels 201 for panel in self.query("PlaylistPanel"): 202 await panel.set_stations(stations) 203 # Notify the screen itself (e.g. BrowserView swaps left pane) 204 msg = PlaylistModeChanged(self.playlist_mode, stations) 205 screen_handler = getattr(self.screen, "on_playlist_mode_changed", None) 206 if screen_handler is not None: 207 screen_handler(msg) 208 # Broadcast to child widgets (mode indicators etc.) 209 for widget in self.screen.walk_children(): 210 handler = getattr(widget, "on_playlist_mode_changed", None) 211 if handler is not None: 212 widget.post_message(PlaylistModeChanged(self.playlist_mode, stations)) 213 214 def _play_next_track(self) -> None: 215 """Advance to the next track in the active playlist.""" 216 playlist = self.active_playlist 217 if not playlist: 218 self.audio.stop() 219 return 220 current = self.audio.current_station 221 try: 222 idx = next(i for i, s in enumerate(playlist) if s.url == current.url) 223 next_idx = idx + 1 224 except (StopIteration, AttributeError): 225 next_idx = 0 226 if next_idx < len(playlist): 227 self.audio.play(playlist[next_idx]) 228 else: 229 # Reached end of playlist 230 self.audio.stop() 231 232 def on_station_selected(self, event: StationSelected) -> None: 233 # Auto-add local files to the local queue 234 if event.station.url.startswith("/"): 235 if not any(s.url == event.station.url for s in self.local_queue): 236 self.local_queue.append(event.station) 237 self.audio.play(event.station) 238 239 async def on_library_changed(self, event: LibraryChanged) -> None: 240 self.library = list(event.folders) 241 save_library(self.library) 242 # Re-broadcast to all PlaylistPanels (siblings don't receive bubbled messages) 243 for panel in self.query("PlaylistPanel"): 244 await panel.on_library_changed(event) 245 246 def on_unmount(self) -> None: 247 self.audio.shutdown()