/ linamp / app.py
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()