/ linamp / widgets / playlist_panel.py
playlist_panel.py
  1  from textual.app import ComposeResult
  2  from textual.containers import Container
  3  from textual.widgets import ListView, ListItem, Label
  4  
  5  from linamp.messages import PlayerStateUpdate, StationSelected, LibraryChanged
  6  from linamp.stations import Station, all_stations, DEFAULT_LIBRARY
  7  
  8  
  9  class PlaylistPanel(Container):
 10      """Queue/playlist showing stations with active highlight."""
 11  
 12      DEFAULT_CSS = """
 13      PlaylistPanel {
 14          height: 1fr;
 15          border: round $primary;
 16      }
 17      PlaylistPanel ListView {
 18          height: 1fr;
 19      }
 20      PlaylistPanel .active-station {
 21          color: $success;
 22          text-style: bold;
 23      }
 24      """
 25  
 26      def __init__(self, stations: list[Station] | None = None, **kwargs) -> None:
 27          super().__init__(**kwargs)
 28          self._stations = stations or all_stations(DEFAULT_LIBRARY)
 29          self._active_index: int | None = None
 30  
 31      def compose(self) -> ComposeResult:
 32          items = []
 33          for i, station in enumerate(self._stations):
 34              label = Label(f"  {station.name} [{station.genre}]")
 35              items.append(ListItem(label, id=f"station-{i}"))
 36          yield ListView(*items)
 37  
 38      def on_list_view_selected(self, event: ListView.Selected) -> None:
 39          idx = event.list_view.index
 40          if idx is not None and 0 <= idx < len(self._stations):
 41              self.post_message(StationSelected(self._stations[idx]))
 42  
 43      def on_player_state_update(self, event: PlayerStateUpdate) -> None:
 44          if event.station is None:
 45              new_index = None
 46          else:
 47              new_index = next(
 48                  (i for i, s in enumerate(self._stations) if s.url == event.station.url),
 49                  None,
 50              )
 51  
 52          if new_index != self._active_index:
 53              # Clear old highlight
 54              if self._active_index is not None:
 55                  try:
 56                      old_item = self.query_one(f"#station-{self._active_index}", ListItem)
 57                      old_label = old_item.query_one(Label)
 58                      old_station = self._stations[self._active_index]
 59                      old_label.update(f"  {old_station.name} [{old_station.genre}]")
 60                      old_label.remove_class("active-station")
 61                  except Exception:
 62                      pass
 63  
 64              # Set new highlight
 65              if new_index is not None:
 66                  try:
 67                      new_item = self.query_one(f"#station-{new_index}", ListItem)
 68                      new_label = new_item.query_one(Label)
 69                      new_station = self._stations[new_index]
 70                      new_label.update(f"▶ {new_station.name} [{new_station.genre}]")
 71                      new_label.add_class("active-station")
 72                  except Exception:
 73                      pass
 74  
 75              self._active_index = new_index
 76  
 77      async def set_stations(self, stations: list[Station]) -> None:
 78          """Replace the playlist with a new station list."""
 79          self._stations = list(stations)
 80          self._active_index = None
 81          lv = self.query_one(ListView)
 82          await lv.clear()
 83          for i, station in enumerate(self._stations):
 84              label = Label(f"  {station.name} [{station.genre}]")
 85              lv.append(ListItem(label, id=f"station-{i}"))
 86  
 87      @property
 88      def selected_index(self) -> int | None:
 89          """Return the currently highlighted index in the ListView."""
 90          lv = self.query_one(ListView)
 91          idx = lv.index
 92          if idx is not None and 0 <= idx < len(self._stations):
 93              return idx
 94          return None
 95  
 96      @property
 97      def stations(self) -> list[Station]:
 98          """Return the current station list."""
 99          return list(self._stations)
100  
101      async def move_track(self, direction: int) -> None:
102          """Move the selected track up (direction=-1) or down (direction=1)."""
103          idx = self.selected_index
104          if idx is None:
105              return
106          new_idx = idx + direction
107          if new_idx < 0 or new_idx >= len(self._stations):
108              return
109          # Swap in the data list
110          self._stations[idx], self._stations[new_idx] = (
111              self._stations[new_idx],
112              self._stations[idx],
113          )
114          # Rebuild and re-select at new position
115          await self._rebuild_list(select_index=new_idx)
116  
117      async def delete_track(self) -> None:
118          """Delete the currently selected track."""
119          idx = self.selected_index
120          if idx is None:
121              return
122          self._stations.pop(idx)
123          # Select the next item (or previous if at end)
124          new_idx = min(idx, len(self._stations) - 1) if self._stations else None
125          await self._rebuild_list(select_index=new_idx)
126  
127      async def _rebuild_list(self, select_index: int | None = None) -> None:
128          """Rebuild the ListView from _stations and optionally select an index."""
129          self._active_index = None
130          lv = self.query_one(ListView)
131          await lv.clear()
132          for i, station in enumerate(self._stations):
133              label = Label(f"  {station.name} [{station.genre}]")
134              lv.append(ListItem(label, id=f"station-{i}"))
135          if select_index is not None and 0 <= select_index < len(self._stations):
136              lv.index = select_index
137  
138      async def on_library_changed(self, event: LibraryChanged) -> None:
139          await self.set_stations(all_stations(event.folders))