925 lines
33 KiB
Python
925 lines
33 KiB
Python
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
|