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))