import curses import time import threading from .client import SpotifyClient, TokenExpired from .player import Player class View: HOME = "home" PLAYLISTS = "playlists" PLAYLIST_DETAIL = "playlist_detail" LIKED_SONGS = "liked_songs" RECENT = "recent" ALBUMS = "albums" ARTISTS = "artists" SEARCH = "search" QUEUE = "queue" DEVICES = "devices" ALBUM_DETAIL = "album_detail" ARTIST_DETAIL = "artist_detail" ARTIST_TOP = "artist_top" LIBRARY = "library" class UI: NAV_ITEMS = [ (View.HOME, "Home"), (View.PLAYLISTS, "Playlists"), (View.LIKED_SONGS, "Liked Songs"), (View.RECENT, "Recently Played"), (View.LIBRARY, "Library"), (View.QUEUE, "Queue"), (View.DEVICES, "Devices"), (View.SEARCH, "Search"), ] def __init__(self, stdscr, client: SpotifyClient): self.stdscr = stdscr self.client = client self.player = Player(client) self.current_view = View.HOME self.nav_index = 0 self.content_scroll = 0 self.content_items = [] self.selected_index = 0 self.running = True self.message = "" self.message_time = 0 self.search_query = "" self.search_mode = False self.loading = False self.playlists_cache = [] self.liked_offset = 0 self.recent_data = [] self.devices_cache = [] self.albums_cache = [] self.artists_cache = [] self.search_results = {} self.playlist_tracks = [] self.album_tracks = [] self.artist_data = None self.artist_albums = [] self.artist_top_tracks = [] self.context_uri = None self.last_key_time = 0 curses.curs_set(0) curses.use_default_colors() curses.start_color() curses.init_pair(1, curses.COLOR_WHITE, -1) curses.init_pair(2, curses.COLOR_GREEN, -1) curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_GREEN) curses.init_pair(4, curses.COLOR_CYAN, -1) curses.init_pair(5, curses.COLOR_YELLOW, -1) curses.init_pair(6, curses.COLOR_MAGENTA, -1) curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(8, curses.COLOR_GREEN, curses.COLOR_BLUE) self.stdscr.timeout(500) self.player.start_polling(interval=2) def stop(self): self.running = False self.player.stop_polling() def show_message(self, msg, duration=3): self.message = msg self.message_time = time.time() + duration def draw(self): self.stdscr.erase() h, w = self.stdscr.getmaxyx() if h < 10 or w < 40: self.stdscr.addstr(0, 0, "Fenster zu klein! (min 40x10)") self.stdscr.refresh() return nav_w = 18 status_h = 1 now_h = 3 bar_h = 1 self._draw_status_bar(0, 0, w, status_h) self._draw_nav(status_h, 0, nav_w, h - status_h - now_h - bar_h) self._draw_content(status_h, nav_w, w - nav_w, h - status_h - now_h - bar_h) self._draw_now_playing(h - now_h - bar_h, 0, w, now_h) self._draw_bar(h - bar_h, 0, w, bar_h) self.stdscr.refresh() def _draw_status_bar(self, y, x, w, h): try: state = self.player.get_playback_state() if state: play = " Playing" if state["is_playing"] else " Paused" rep = f" R:{state['repeat_state']}" shuf = f" S:{'on' if state['shuffle_state'] else 'off'}" vol = f" Vol:{state['volume']}%" dev = f" {state['device']}" left = f"spotify-cli {play}{rep}{shuf}{vol}" right = dev pad = w - len(left) - len(right) - 2 line = left + " " * max(pad, 1) + right else: line = " spotify-cli -- No playback device" self.stdscr.addnstr(y, x, line[:w-1], w-1, curses.color_pair(3) | curses.A_BOLD) except Exception: self.stdscr.addnstr(y, x, " spotify-cli", w-1, curses.color_pair(3) | curses.A_BOLD) def _draw_nav(self, y, x, w, h): try: self.stdscr.addnstr(y, x, " NAV".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) except curses.error: pass for i, (vid, label) in enumerate(self.NAV_ITEMS): if i + 1 >= h: break try: prefix = "> " if i == self.nav_index else " " attr = curses.color_pair(8) if i == self.nav_index else curses.color_pair(1) if i == self.nav_index: attr |= curses.A_BOLD line = f"{prefix}{label}" self.stdscr.addnstr(y + 1 + i, x, line.ljust(w), w-1, attr) except curses.error: pass def _draw_content(self, y, x, w, h): try: self.stdscr.addnstr(y, x, "".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) except curses.error: pass header = self._get_view_header() try: self.stdscr.addnstr(y, x + 1, header[:w-2], w-2, curses.color_pair(7) | curses.A_BOLD) except curses.error: pass if self.loading: try: self.stdscr.addstr(y + 2, x + 2, "Laden...", curses.color_pair(5)) except curses.error: pass return visible_items = h - 1 start = self.content_scroll end = min(start + visible_items, len(self.content_items)) for i in range(start, end): row = y + 1 + (i - start) if row >= y + h: break item = self.content_items[i] is_selected = i == self.selected_index try: prefix = "> " if is_selected else " " if isinstance(item, dict): text = self._format_item(item) else: text = str(item) line = f"{prefix}{text}" attr = curses.color_pair(2) if is_selected else curses.color_pair(1) if is_selected: attr |= curses.A_BOLD self.stdscr.addnstr(row, x + 1, line[:w-2], w-2, attr) except curses.error: pass if not self.content_items and not self.loading: msg = self._get_empty_message() try: self.stdscr.addstr(y + 2, x + 2, msg, curses.color_pair(5)) except curses.error: pass def _draw_now_playing(self, y, x, w, h): info = self.player.get_track_info() try: self.stdscr.addnstr(y, x, "".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) except curses.error: pass try: self.stdscr.addnstr(y, x, " NOW PLAYING".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) except curses.error: pass if not info: return title = f"{info['artists']} - {info['name']}" album = info['album'] progress = info['progress_ms'] duration = info['duration_ms'] prog_bar = self._make_progress_bar(progress, duration, w - 4) line1 = f" {title}" line2 = f" {album} [{prog_bar}]" try: self.stdscr.addnstr(y + 1, x, line1[:w-1], w-1, curses.color_pair(4)) except curses.error: pass try: self.stdscr.addnstr(y + 2, x, line2[:w-1], w-1, curses.color_pair(1)) except curses.error: pass def _draw_bar(self, y, x, w, h): keys = "j/k:Move Enter:Select Space:Play/Pause n/p:Next/Prev +/-:Vol q:Quit" if self.search_mode: keys = f"Search: {self.search_query}_ Enter:Search Esc:Cancel" try: self.stdscr.addnstr(y, x, keys.ljust(w), w-1, curses.color_pair(7)) except curses.error: pass def _make_progress_bar(self, current, total, width): if total == 0: return "" ratio = current / total filled = int(width * ratio) bar = "=" * filled + ">" + "-" * (width - filled - 1) cur = self._format_ms(current) dur = self._format_ms(total) return f"{cur} [{bar}] {dur}" def _format_ms(self, ms): s = int(ms / 1000) m = s // 60 s = s % 60 return f"{m}:{s:02d}" def _get_view_header(self): headers = { View.HOME: "Home", View.PLAYLISTS: "Playlists", View.PLAYLIST_DETAIL: "Playlist", View.LIKED_SONGS: "Liked Songs", View.RECENT: "Recently Played", View.LIBRARY: "Library", View.QUEUE: "Queue", View.DEVICES: "Devices", View.SEARCH: "Search", View.ALBUM_DETAIL: "Album", View.ARTIST_DETAIL: "Artist", View.ARTIST_TOP: "Top Tracks", View.ALBUMS: "Albums", View.ARTISTS: "Artists", } return headers.get(self.current_view, "") def _get_empty_message(self): msgs = { View.PLAYLISTS: "Keine Playlists gefunden.", View.LIKED_SONGS: "Keine Liked Songs.", View.RECENT: "Keine kuerzlich gespielten Tracks.", View.QUEUE: "Warteschlange ist leer.", View.DEVICES: "Keine Geraete gefunden.", View.SEARCH: "Druecke '/' zum Suchen.", View.LIBRARY: "Keine Eintraege.", } return msgs.get(self.current_view, "Keine Eintraege.") def _format_item(self, item): kind = item.get("type", "") if kind == "track": artists = item.get("artists", "Unknown") name = item.get("name", "Unknown") album = item.get("album", "") dur = item.get("duration", "") return f"{artists} - {name} ({album}) {dur}" elif kind == "playlist": name = item.get("name", "Unknown") tracks = item.get("tracks", "") owner = item.get("owner", "") return f"{name} ({tracks} tracks, by {owner})" elif kind == "album": name = item.get("name", "Unknown") artist = item.get("artist", "") year = item.get("year", "") return f"{artist} - {name} ({year})" elif kind == "artist": name = item.get("name", "Unknown") followers = item.get("followers", "") return f"{name} ({followers} followers)" elif kind == "device": name = item.get("name", "Unknown") dtype = item.get("dtype", "") active = item.get("active", False) marker = " *" if active else "" return f"{name} ({dtype}){marker}" elif kind == "search_track": artists = item.get("artists", "Unknown") name = item.get("name", "Unknown") return f"[Track] {artists} - {name}" elif kind == "search_album": name = item.get("name", "Unknown") artist = item.get("artist", "") return f"[Album] {artist} - {name}" elif kind == "search_artist": name = item.get("name", "Unknown") return f"[Artist] {name}" elif kind == "search_playlist": name = item.get("name", "Unknown") return f"[Playlist] {name}" return str(item) # --- Data Loading --- def load_home(self): self.loading = True self.draw() try: recent = self.client.get_recently_played(limit=10) items = [] seen = set() for r in recent.get("items", []): track = r.get("track", {}) if track and track.get("id") not in seen: seen.add(track["id"]) items.append({ "type": "track", "id": track["id"], "name": track["name"], "artists": ", ".join(a["name"] for a in track.get("artists", [])), "album": track.get("album", {}).get("name", ""), "uri": track["uri"], "duration": self._format_ms(track.get("duration_ms", 0)), "context_uri": track.get("album", {}).get("uri", ""), }) self.content_items = items self.recent_data = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_playlists(self): self.loading = True self.draw() try: data = self.client.get_playlists(limit=50) items = [] for p in data.get("items", []): items.append({ "type": "playlist", "id": p["id"], "name": p["name"], "tracks": p.get("tracks", {}).get("total", 0), "owner": p.get("owner", {}).get("display_name", ""), "uri": p["uri"], }) self.content_items = items self.playlists_cache = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_playlist_detail(self, playlist_id): self.loading = True self.draw() try: data = self.client.get_playlist_tracks(playlist_id, limit=100) items = [] for t in data.get("items", []): track = t.get("track", {}) if not track: continue items.append({ "type": "track", "id": track["id"], "name": track["name"], "artists": ", ".join(a["name"] for a in track.get("artists", [])), "album": track.get("album", {}).get("name", ""), "uri": track["uri"], "duration": self._format_ms(track.get("duration_ms", 0)), "context_uri": f"spotify:playlist:{playlist_id}", }) self.content_items = items self.playlist_tracks = items self.context_uri = f"spotify:playlist:{playlist_id}" except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_liked_songs(self): self.loading = True self.draw() try: data = self.client.get_saved_tracks(limit=50, offset=self.liked_offset) items = [] for t in data.get("items", []): track = t.get("track", {}) if not track: continue items.append({ "type": "track", "id": track["id"], "name": track["name"], "artists": ", ".join(a["name"] for a in track.get("artists", [])), "album": track.get("album", {}).get("name", ""), "uri": track["uri"], "duration": self._format_ms(track.get("duration_ms", 0)), "context_uri": "spotify:library:tracks", }) self.content_items = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_recent(self): self.loading = True self.draw() try: data = self.client.get_recently_played(limit=50) items = [] seen = set() for r in data.get("items", []): track = r.get("track", {}) if track and track.get("id") not in seen: seen.add(track["id"]) items.append({ "type": "track", "id": track["id"], "name": track["name"], "artists": ", ".join(a["name"] for a in track.get("artists", [])), "album": track.get("album", {}).get("name", ""), "uri": track["uri"], "duration": self._format_ms(track.get("duration_ms", 0)), "context_uri": track.get("album", {}).get("uri", ""), }) self.content_items = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_library(self): self.loading = True self.draw() items = [ {"type": "nav", "label": "Liked Songs", "action": "liked"}, {"type": "nav", "label": "Albums", "action": "albums"}, {"type": "nav", "label": "Artists", "action": "artists"}, ] self.content_items = items self.loading = False def load_albums(self): self.loading = True self.draw() try: data = self.client.get_saved_albums(limit=50) items = [] for a in data.get("items", []): album = a.get("album", {}) items.append({ "type": "album", "id": album["id"], "name": album["name"], "artist": ", ".join(ar["name"] for ar in album.get("artists", [])), "year": album.get("release_year", ""), "uri": album["uri"], "total_tracks": album.get("total_tracks", 0), }) self.content_items = items self.albums_cache = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_artists(self): self.loading = True self.draw() try: data = self.client.get_saved_artists(limit=50) items = [] for a in data.get("items", []): items.append({ "type": "artist", "id": a["id"], "name": a["name"], "followers": a.get("followers", {}).get("total", 0), "uri": a["uri"], }) self.content_items = items self.artists_cache = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_queue(self): self.loading = True self.draw() try: data = self.client.get_queue() items = [] current = data.get("currently_playing") if current: items.append({ "type": "track", "id": current["id"], "name": f">>> {current['name']}", "artists": ", ".join(a["name"] for a in current.get("artists", [])), "album": current.get("album", {}).get("name", ""), "uri": current["uri"], "duration": self._format_ms(current.get("duration_ms", 0)), }) for t in data.get("queue", []): items.append({ "type": "track", "id": t["id"], "name": t["name"], "artists": ", ".join(a["name"] for a in t.get("artists", [])), "album": t.get("album", {}).get("name", ""), "uri": t["uri"], "duration": self._format_ms(t.get("duration_ms", 0)), }) self.content_items = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_devices(self): self.loading = True self.draw() try: self.player.refresh() items = [] for d in self.player.devices: items.append({ "type": "device", "id": d["id"], "name": d["name"], "dtype": d.get("type", ""), "active": d.get("is_active", False), }) self.content_items = items self.devices_cache = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_album_detail(self, album_id): self.loading = True self.draw() try: album = self.client.get_album(album_id) tracks = self.client.get_album_tracks(album_id, limit=50) items = [] for t in tracks.get("items", []): items.append({ "type": "track", "id": t["id"], "name": t["name"], "artists": ", ".join(a["name"] for a in t.get("artists", [])), "album": album.get("name", ""), "uri": t["uri"], "duration": self._format_ms(t.get("duration_ms", 0)), "context_uri": f"spotify:album:{album_id}", }) self.content_items = items self.album_tracks = items self.context_uri = f"spotify:album:{album_id}" except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def load_artist_detail(self, artist_id): self.loading = True self.draw() try: artist = self.client.get_artist(artist_id) self.artist_data = artist top = self.client.get_artist_top_tracks(artist_id) self.artist_top_tracks = [] for t in top.get("tracks", []): self.artist_top_tracks.append({ "type": "track", "id": t["id"], "name": t["name"], "artists": ", ".join(a["name"] for a in t.get("artists", [])), "album": t.get("album", {}).get("name", ""), "uri": t["uri"], "duration": self._format_ms(t.get("duration_ms", 0)), "context_uri": f"spotify:artist:{artist_id}", }) albums = self.client.get_artist_albums(artist_id, limit=20) self.artist_albums = [] for a in albums.get("items", []): self.artist_albums.append({ "type": "album", "id": a["id"], "name": a["name"], "artist": artist["name"], "year": a.get("release_date", "")[:4], "uri": a["uri"], "total_tracks": a.get("total_tracks", 0), }) items = [{"type": "section", "label": f"--- {artist['name']} Top Tracks ---"}] items.extend(self.artist_top_tracks) items.append({"type": "section", "label": "--- Albums ---"}) items.extend(self.artist_albums) self.content_items = items except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False def do_search(self, query): if not query: return self.loading = True self.draw() try: data = self.client.search(query, types="track,album,artist,playlist", limit=10) items = [] for t in data.get("tracks", {}).get("items", []): items.append({ "type": "search_track", "id": t["id"], "name": t["name"], "artists": ", ".join(a["name"] for a in t.get("artists", [])), "album": t.get("album", {}).get("name", ""), "uri": t["uri"], "duration": self._format_ms(t.get("duration_ms", 0)), "context_uri": t.get("album", {}).get("uri", ""), }) for a in data.get("albums", {}).get("items", []): items.append({ "type": "search_album", "id": a["id"], "name": a["name"], "artist": ", ".join(ar["name"] for ar in a.get("artists", [])), "year": a.get("release_date", "")[:4], "uri": a["uri"], }) for ar in data.get("artists", {}).get("items", []): items.append({ "type": "search_artist", "id": ar["id"], "name": ar["name"], "uri": ar["uri"], }) for p in data.get("playlists", {}).get("items", []): items.append({ "type": "search_playlist", "id": p["id"], "name": p["name"], "uri": p["uri"], }) self.content_items = items self.search_results = data except Exception as e: self.content_items = [] self.show_message(f"Fehler: {e}") self.loading = False # --- Navigation --- def navigate_to(self, view): self.current_view = view self.content_scroll = 0 self.selected_index = 0 self.content_items = [] loaders = { View.HOME: self.load_home, View.PLAYLISTS: self.load_playlists, View.LIKED_SONGS: self.load_liked_songs, View.RECENT: self.load_recent, View.LIBRARY: self.load_library, View.QUEUE: self.load_queue, View.DEVICES: self.load_devices, View.ALBUMS: self.load_albums, View.ARTISTS: self.load_artists, } if view in loaders: loaders[view]() def handle_select(self): if not self.content_items: return item = self.content_items[self.selected_index] kind = item.get("type", "") if kind == "playlist": self.current_view = View.PLAYLIST_DETAIL self.load_playlist_detail(item["id"]) elif kind == "album" or kind == "search_album": self.current_view = View.ALBUM_DETAIL self.load_album_detail(item["id"]) elif kind == "artist" or kind == "search_artist": self.current_view = View.ARTIST_DETAIL self.load_artist_detail(item["id"]) elif kind == "track" or kind == "search_track": ctx = item.get("context_uri") if self.context_uri: ctx = self.context_uri try: self.player.play_tracks([item["uri"]]) self.show_message(f"Spiele: {item['name']}") except Exception as e: self.show_message(f"Fehler: {e}") elif kind == "device": try: self.player.transfer_to(item["id"]) self.show_message(f"Übertragen zu: {item['name']}") except Exception as e: self.show_message(f"Fehler: {e}") elif kind == "nav": action = item.get("action", "") if action == "liked": self.navigate_to(View.LIKED_SONGS) elif action == "albums": self.navigate_to(View.ALBUMS) elif action == "artists": self.navigate_to(View.ARTISTS) def handle_back(self): if self.current_view in (View.PLAYLIST_DETAIL, View.LIBRARY): self.navigate_to(View.PLAYLISTS if self.current_view == View.PLAYLIST_DETAIL else View.HOME) elif self.current_view in (View.ALBUM_DETAIL, View.ARTIST_DETAIL): self.navigate_to(View.HOME) else: self.navigate_to(View.HOME) def run(self): self.navigate_to(View.HOME) while self.running: self.draw() key = self.stdscr.getch() if key == -1: continue if self.search_mode: self._handle_search_input(key) continue self._handle_key(key) def _handle_search_input(self, key): if key == 27: self.search_mode = False self.search_query = "" return elif key in (curses.KEY_ENTER, 10, 13): self.search_mode = False self.current_view = View.SEARCH self.do_search(self.search_query) return elif key in (curses.KEY_BACKSPACE, 127, 8): self.search_query = self.search_query[:-1] elif 32 <= key <= 126: self.search_query += chr(key) def _handle_key(self, key): nav_count = len(self.NAV_ITEMS) content_count = len(self.content_items) if key == ord("q"): self.running = False elif key == ord("j") or key == curses.KEY_DOWN: if self.selected_index < content_count - 1: self.selected_index += 1 self._adjust_scroll() elif key == ord("k") or key == curses.KEY_UP: if self.selected_index > 0: self.selected_index -= 1 self._adjust_scroll() elif key == curses.KEY_PPAGE: self.selected_index = max(0, self.selected_index - 10) self._adjust_scroll() elif key == curses.KEY_NPAGE: self.selected_index = min(content_count - 1, self.selected_index + 10) self._adjust_scroll() elif key == curses.KEY_HOME: self.selected_index = 0 self.content_scroll = 0 elif key == curses.KEY_END: self.selected_index = max(0, content_count - 1) self._adjust_scroll() elif key == 10 or key == 13: self.handle_select() elif key == ord(" "): try: self.player.toggle_play() self.show_message("Toggled play/pause") except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("n"): try: self.player.next() self.show_message("Next track") except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("p"): try: self.player.previous() self.show_message("Previous track") except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("+") or key == ord("="): try: self.player.volume_up() except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("-"): try: self.player.volume_down() except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("r"): try: self.player.toggle_repeat() self.show_message("Toggle repeat") except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("s"): try: self.player.toggle_shuffle() self.show_message("Toggle shuffle") except Exception as e: self.show_message(f"Fehler: {e}") elif key == ord("/"): self.search_mode = True self.search_query = "" elif key == ord("a"): if self.current_view == View.PLAYLISTS and self.content_items: self._add_to_playlist_prompt() elif key == ord("h") or key == 27: self.handle_back() elif key == ord("1"): self.nav_index = 0 self.navigate_to(self.NAV_ITEMS[0][0]) elif key == ord("2"): self.nav_index = 1 self.navigate_to(self.NAV_ITEMS[1][0]) elif key == ord("3"): self.nav_index = 2 self.navigate_to(self.NAV_ITEMS[2][0]) elif key == ord("4"): self.nav_index = 3 self.navigate_to(self.NAV_ITEMS[3][0]) elif key == ord("5"): self.nav_index = 4 self.navigate_to(self.NAV_ITEMS[4][0]) elif key == ord("6"): self.nav_index = 5 self.navigate_to(self.NAV_ITEMS[5][0]) elif key == ord("7"): self.nav_index = 6 self.navigate_to(self.NAV_ITEMS[6][0]) elif key == ord("8"): self.nav_index = 7 self.navigate_to(self.NAV_ITEMS[7][0]) elif key == ord("L"): self.navigate_to(View.LIBRARY) def _adjust_scroll(self): h, w = self.stdscr.getmaxyx() visible = h - 7 if self.selected_index < self.content_scroll: self.content_scroll = self.selected_index elif self.selected_index >= self.content_scroll + visible: self.content_scroll = self.selected_index - visible + 1 def _add_to_playlist_prompt(self): pass