lushapp 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lush/ui.py ADDED
@@ -0,0 +1,690 @@
1
+ # LUSH - User Interface and Main View
2
+ import time
3
+ import curses
4
+ import pyperclip
5
+ from .constants import (
6
+ LOGO_LINES, COMPACT_LOGO, SIDEBAR_SECTIONS, ALL_SIDEBAR_ITEMS,
7
+ AMBIENT_PRESETS, VISUALIZER_MODES, VISUALIZER_LABELS,
8
+ SHIMMER_PALETTES, DEFAULT_THEMES
9
+ )
10
+ from .state import PlayerState, save_stations, save_favorites, save_config
11
+ from .ui_helpers import get_source_tag, trim, apply_theme, get_shimmer_color_pair
12
+ from .modals import (
13
+ show_age_verification_modal,
14
+ show_headphone_warning_modal,
15
+ show_add_station_modal,
16
+ show_settings_modal,
17
+ show_ambient_modal,
18
+ show_theme_selector,
19
+ show_help_modal,
20
+ show_welcome_modal,
21
+ show_insights_modal
22
+ )
23
+
24
+ def render_now_playing_deck(stdscr, box2_y, right_start_x, right_w, box2_h, state, playing, t_now):
25
+ max_h, max_w = stdscr.getmaxyx()
26
+ def safe_add(y, x, text, attr=0):
27
+ if 0 <= y < max_h and 0 <= x < max_w:
28
+ avail = max_w - x
29
+ if avail > 0:
30
+ try:
31
+ stdscr.addstr(y, x, text[:avail], attr)
32
+ except Exception:
33
+ pass
34
+
35
+ deck_style = getattr(state, "deck_style", "vinyl")
36
+ cur_station = state.stations[state.idx] if (0 <= state.idx < len(state.stations)) else {"name": "LUSH Radio", "genre": "Various"}
37
+ cur_track = state.current_track or cur_station["name"]
38
+
39
+ # Outer Box Frame
40
+ deck_title = "TECHNICS DIRECT-DRIVE VINYL" if deck_style == "vinyl" else "NAKAMICHI STUDIO CASSETTE"
41
+ title = f" NOW PLAYING STUDIO • {deck_title} "
42
+ top_line = f"┌─[{title}]" + "─" * max(0, right_w - len(title) - 5) + "┐"
43
+ safe_add(box2_y, right_start_x, trim(top_line, right_w), curses.color_pair(8))
44
+
45
+ for y_rel in range(1, box2_h - 1):
46
+ safe_add(box2_y + y_rel, right_start_x, "│", curses.color_pair(8))
47
+ safe_add(box2_y + y_rel, right_start_x + right_w - 1, "│", curses.color_pair(8))
48
+
49
+ bot_line = "└" + "─" * (right_w - 2) + "┘"
50
+ safe_add(box2_y + box2_h - 1, right_start_x, trim(bot_line, right_w), curses.color_pair(8))
51
+
52
+ inner_w = max(10, right_w - 4)
53
+ inner_x = right_start_x + 2
54
+
55
+ # 1. VINYL TURNTABLE DECK
56
+ if deck_style == "vinyl":
57
+ spindles = ['◴', '◵', '◶', '◷']
58
+ spin_ch = spindles[int(t_now * 4.5) % 4] if playing else '◴'
59
+ strobe_status = "● LOCKED" if playing else "○ STANDBY"
60
+ tonearm_status = "ENGAGED" if playing else "RESTING"
61
+
62
+ vinyl_lines = [
63
+ " . ──' ░▒▓████████████████▓▒░ '── . \\===[O]",
64
+ " .´ ░▒▓████▀▀▀▀ ▀▀▀▀████▓▒░ `. \\",
65
+ "/ ░▒▓███▀ ╭────────╮ ▀███▓▒░ \\ \\",
66
+ ": ░▒▓██│ ● │ LUSH ♫ │ ● │██▓▒░ : │",
67
+ f": ░▒▓███. ╰───({spin_ch})──╯ .███▓▒░ : │",
68
+ "\\ ░▒▓███▄ VINYL LP ▄███▓▒░ / ╱ \\",
69
+ " `. ░▒▓████▄▄▄▄ ▄▄▄▄████▓▒░ .´ ╱ [▮]",
70
+ " `── . ░▒▓████████████████▓▒░ . ──´ ╱ •needle"
71
+ ]
72
+ start_deck_y = box2_y + 1
73
+ for r_i, v_l in enumerate(vinyl_lines):
74
+ draw_y = start_deck_y + r_i
75
+ if draw_y >= box2_y + box2_h - 5: break
76
+ safe_add(draw_y, inner_x + max(0, (inner_w - len(v_l)) // 2), trim(v_l, inner_w), curses.color_pair(2) | curses.A_BOLD)
77
+
78
+ stat_y = start_deck_y + len(vinyl_lines)
79
+ if stat_y < box2_y + box2_h - 4:
80
+ strobe_bar = f"SPEED: [33⅓ RPM] STROBE: {strobe_status} TONEARM: [{tonearm_status}]"
81
+ safe_add(stat_y, inner_x + max(0, (inner_w - len(strobe_bar)) // 2), trim(strobe_bar, inner_w), curses.color_pair(6))
82
+
83
+ # 2. CASSETTE MASTER DECK
84
+ else:
85
+ reels = ['✸', '✹', '✶', '✦']
86
+ reel_ch = reels[int(t_now * 5.0) % 4] if playing else '✸'
87
+ time_sec = int(t_now) % 3600
88
+ counter_str = f"{time_sec // 60:02d} : {time_sec % 60:02d}"
89
+
90
+ cassette_lines = [
91
+ "╔═══════════════════════════════════════════════════════╗",
92
+ "║ [A] LUSH MASTER TAPE • 70µs EQ • NR: [DOLBY B-C] ║",
93
+ "║ ┌───────────────────────────────────────────────────┐ ║",
94
+ "║ │ ╭───╮ ▓▓▓▓▓▓▓▓▓\\ /░░░░░ ╭───╮ │ ║",
95
+ f"║ │ │ ( {reel_ch} ) │ ══════════\\─[ ♫ ]─/═══════ │ ( {reel_ch} ) │ │ ║",
96
+ "║ │ ╰───╯ ◄◄ REEL-L REEL-R ►►╰───╯ │ ║",
97
+ "║ └───────────────────[ O ═ █HEAD█ ═ O ]──────────────┘ ║",
98
+ f"║ [◀◀ REW] [▶ PLAY] [■ STOP] COUNTER: [ {counter_str} ] ║",
99
+ "╚═══════════════════════════════════════════════════════╝"
100
+ ]
101
+ start_deck_y = box2_y + 1
102
+ for r_i, c_l in enumerate(cassette_lines):
103
+ draw_y = start_deck_y + r_i
104
+ if draw_y >= box2_y + box2_h - 5: break
105
+ safe_add(draw_y, inner_x + max(0, (inner_w - len(c_l)) // 2), trim(c_l, inner_w), curses.color_pair(2) | curses.A_BOLD)
106
+
107
+ stat_y = start_deck_y + len(cassette_lines)
108
+ if stat_y < box2_y + box2_h - 4 and hasattr(state, "cava") and state.cava:
109
+ bars = list(state.cava.bars)
110
+ half = len(bars) // 2
111
+ l_amp = max(bars[:half]) if half > 0 else 0
112
+ r_amp = max(bars[half:]) if half > 0 else 0
113
+ l_bar = '█' * int((l_amp / 8.0) * 12) + '░' * (12 - int((l_amp / 8.0) * 12))
114
+ r_bar = '█' * int((r_amp / 8.0) * 12) + '░' * (12 - int((r_amp / 8.0) * 12))
115
+ vu_str = f"CH-L: [{l_bar}] -{max(0, 8 - l_amp)}dB CH-R: [{r_bar}] -{max(0, 8 - r_amp)}dB"
116
+ safe_add(stat_y, inner_x + max(0, (inner_w - len(vu_str)) // 2), trim(vu_str, inner_w), curses.color_pair(6) | curses.A_BOLD)
117
+
118
+ # Info line
119
+ info_y = box2_y + box2_h - 4
120
+ if info_y > box2_y + 4:
121
+ tr_info = f"→ Track : {cur_track} ({cur_station.get('genre', 'Music')})"
122
+ safe_add(info_y, inner_x, trim(tr_info, inner_w), curses.color_pair(4) | curses.A_BOLD)
123
+
124
+ # CAVA Spectrum line
125
+ cava_y = box2_y + box2_h - 3
126
+ if cava_y > box2_y + 5 and hasattr(state, "cava") and state.cava:
127
+ spec_str = f"→ CAVA : {state.cava.get_spectrum_str(min(42, inner_w - 14), playing)}"
128
+ safe_add(cava_y, inner_x, trim(spec_str, inner_w), curses.color_pair(2) | curses.A_BOLD)
129
+
130
+ # Hotkey hints
131
+ hint_y = box2_y + box2_h - 2
132
+ if hint_y > box2_y + 6:
133
+ other_deck = "Cassette Deck" if deck_style == "vinyl" else "Vinyl Turntable"
134
+ hint_text = f" [Space:Play/Pause] [d:Switch to {other_deck}] [b:Favs] [c:Copy Track] [r:Record FLAC] "
135
+ safe_add(hint_y, inner_x + max(0, (inner_w - len(hint_text)) // 2), trim(hint_text, inner_w), curses.A_DIM)
136
+
137
+
138
+ def curses_main(stdscr, state: PlayerState):
139
+ curses.curs_set(0)
140
+ stdscr.timeout(0)
141
+ stdscr.keypad(True)
142
+ try:
143
+ curses.set_escdelay(25)
144
+ except Exception:
145
+ pass
146
+
147
+ if curses.has_colors():
148
+ curses.start_color()
149
+ try:
150
+ curses.use_default_colors()
151
+ except Exception:
152
+ pass
153
+
154
+ apply_theme(state.theme_name, state.themes)
155
+
156
+ def draw():
157
+ stdscr.erase()
158
+ h, w = stdscr.getmaxyx()
159
+
160
+ def safe_addstr(y, x, s, attr=curses.A_NORMAL):
161
+ if 0 <= y < h and 0 <= x < w:
162
+ try:
163
+ stdscr.addstr(y, x, trim(s, w - x - 1), attr)
164
+ except Exception:
165
+ pass
166
+ apply_theme(state.theme_name, state.themes)
167
+
168
+ # -------------------------------------------------------------
169
+ # 1. TOP HEADER & LUSH LOGO WITH LIVE SHIMMER WAVE
170
+ # -------------------------------------------------------------
171
+ t_now = time.time()
172
+ shimmer_eff = getattr(state, "shimmer_effect", "wave")
173
+
174
+ if h >= 22:
175
+ for row_i, l_text in enumerate(LOGO_LINES):
176
+ for c_idx, ch in enumerate(l_text):
177
+ if ch == " ": continue
178
+ x_norm = c_idx / max(1, len(l_text) - 1)
179
+ cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
180
+ p_id = get_shimmer_color_pair(shimmer_eff, x_norm, c_idx, row_i, t_now, cava_bars=cava_bars, is_playing=state.playing)
181
+ attr = curses.color_pair(p_id) | curses.A_BOLD
182
+ stdscr.addstr(1 + row_i, 2 + c_idx, ch, attr)
183
+ main_start_y = 4
184
+ else:
185
+ for c_idx, ch in enumerate(COMPACT_LOGO):
186
+ x_norm = c_idx / max(1, len(COMPACT_LOGO) - 1)
187
+ cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
188
+ p_id = get_shimmer_color_pair(shimmer_eff, x_norm, c_idx, 0, t_now, cava_bars=cava_bars, is_playing=state.playing)
189
+ attr = curses.color_pair(p_id) | curses.A_BOLD
190
+ stdscr.addstr(0, 2 + c_idx, ch, attr)
191
+ main_start_y = 2
192
+
193
+ # Right side stats: Real Live Network Speeds (↓ Down / ↑ Up) + Bitrate + Rec
194
+ with state.lock:
195
+ st_count = len(state.stations)
196
+ playing = state.playing
197
+ current_station = state.stations[state.idx]
198
+ current_track = state.current_track
199
+ filtered_stations = state.get_filtered_stations_unlocked()
200
+ fav_count = len(state.favorites)
201
+ hist_count = len(state.history)
202
+ vol_str = f"Vol: {state.volume}%"
203
+ rec_on = state.audio.recording
204
+ rec_dur = int(time.time() - state.audio.rec_start_time) if rec_on else 0
205
+ amb_preset = state.ambient_preset
206
+ net_down = state.net_down_str
207
+ net_up = state.net_up_str
208
+ stream_br = state.stream_bitrate
209
+
210
+ status_tag = f"▶ {stream_br}" if playing else "■ PAUSED"
211
+ hud_elements = [f"{net_down} {net_up}", status_tag]
212
+
213
+ if rec_on:
214
+ rec_blink = "● REC" if int(t_now * 2) % 2 == 0 else "○ REC"
215
+ rec_str = f"{rec_blink} [{rec_dur//60:02d}:{rec_dur%60:02d}]"
216
+ hud_elements.append(rec_str)
217
+
218
+ if amb_preset != "none":
219
+ amb_name = next((p["name"].split()[0] for p in AMBIENT_PRESETS if p["id"] == amb_preset), "FX")
220
+ hud_elements.append(f"{amb_name} {state.ambient_volume}%")
221
+
222
+ hud_elements.append(vol_str)
223
+ hud_elements.append(f"{st_count} stns")
224
+
225
+ header_stats = " │ ".join(hud_elements)
226
+ header_y = 1 if h >= 22 else 0
227
+ if w > len(LOGO_LINES[0]) + len(header_stats) + 4:
228
+ stat_x = w - len(header_stats) - 2
229
+ stdscr.addstr(header_y, stat_x, header_stats, curses.color_pair(4))
230
+ if rec_on:
231
+ rec_match = f"REC [{rec_dur//60:02d}:{rec_dur%60:02d}]"
232
+ r_pos = header_stats.find("REC")
233
+ if r_pos != -1:
234
+ stdscr.addstr(header_y, stat_x + r_pos - 2, "● " + rec_match, curses.color_pair(15) | curses.A_BOLD)
235
+
236
+ # -------------------------------------------------------------
237
+ # 2. LEFT SIDEBAR (DISCOVER / TRANSFERS / CONFIG)
238
+ # -------------------------------------------------------------
239
+ sidebar_w = 17
240
+ right_start_x = sidebar_w + 3
241
+ right_w = max(25, w - right_start_x - 2)
242
+
243
+ sb_y = main_start_y
244
+ flat_idx = 0
245
+
246
+ for sec in SIDEBAR_SECTIONS:
247
+ if sb_y >= h - 1: break
248
+ safe_addstr(sb_y, 2, sec["title"], curses.color_pair(2) | curses.A_BOLD)
249
+ sb_y += 1
250
+
251
+ for item in sec["items"]:
252
+ if sb_y >= h - 1:
253
+ flat_idx += 1
254
+ continue
255
+ is_selected_section = (item["key"] == state.section_key)
256
+ is_focused_sb = (state.region == "sidebar" and flat_idx == state.sidebar_idx)
257
+
258
+ count_str = ""
259
+ if item["key"] == "bookmarks":
260
+ count_str = f"({fav_count})"
261
+ elif item["key"] == "history":
262
+ count_str = f"({hist_count})"
263
+ elif item["key"] == "insights":
264
+ count_str = f"({state.stats.total_scrobbles})" if hasattr(state, "stats") else ""
265
+ elif item["key"] == "ambient_fx":
266
+ count_str = "ON" if amb_preset != "none" else "OFF"
267
+
268
+ if is_focused_sb:
269
+ prefix = "→ "
270
+ attr = curses.color_pair(6) | curses.A_BOLD
271
+ elif is_selected_section:
272
+ prefix = "→ "
273
+ attr = curses.color_pair(2) | curses.A_BOLD
274
+ else:
275
+ prefix = " "
276
+ attr = curses.A_NORMAL
277
+
278
+ display_text = f"{prefix}{item['label']}"
279
+ safe_addstr(sb_y, 2, trim(display_text, sidebar_w - 4), attr)
280
+ if count_str and len(display_text) + len(count_str) + 1 <= sidebar_w:
281
+ safe_addstr(sb_y, 2 + sidebar_w - len(count_str), count_str, curses.color_pair(9))
282
+
283
+ sb_y += 1
284
+ flat_idx += 1
285
+
286
+ if h >= 26:
287
+ sb_y += 1
288
+
289
+ # -------------------------------------------------------------
290
+ # 3. RIGHT MAIN PANELS (LUSH Bracketed Frame Boxes + Visualizer)
291
+ # -------------------------------------------------------------
292
+ # Box 1: [ NOW PLAYING / SEARCH / REAL-TIME CAVA VISUALIZER ]
293
+ search_box_y = main_start_y
294
+ search_box_h = 3
295
+
296
+ b1_title = " SEARCH " if state.search_active else " NOW PLAYING "
297
+ b1_frame_top = f"┌─[{b1_title}]" + "─" * max(0, right_w - len(b1_title) - 5) + "┐"
298
+ stdscr.addstr(search_box_y, right_start_x, trim(b1_frame_top, right_w), curses.color_pair(8))
299
+
300
+ stdscr.addstr(search_box_y + 1, right_start_x, "│", curses.color_pair(8))
301
+ if state.search_active:
302
+ search_line = f" → Search: {state.search_query}█"
303
+ stdscr.addstr(search_box_y + 1, right_start_x + 1, trim(search_line, right_w - 2), curses.color_pair(4) | curses.A_BOLD)
304
+ elif state.search_query:
305
+ search_line = f" → Filter: {state.search_query} (Press / to edit, ESC to clear)"
306
+ stdscr.addstr(search_box_y + 1, right_start_x + 1, trim(search_line, right_w - 2), curses.color_pair(4))
307
+ else:
308
+ status_icon = "▶" if playing else "■"
309
+ track_prefix = f" {status_icon} [{current_station['name']}] {current_track}"
310
+
311
+ vis_mode = state.visualizer_mode
312
+ if vis_mode != "off" and right_w > 50:
313
+ vis_w = min(34, max(12, right_w - len(track_prefix) - 6))
314
+ stdscr.addstr(search_box_y + 1, right_start_x + 1, trim(track_prefix, right_w - vis_w - 5), curses.color_pair(4) | curses.A_BOLD)
315
+ vis_start_x = right_start_x + right_w - vis_w - 1
316
+
317
+ max_draw_x = right_start_x + right_w - 2
318
+ elements = None
319
+ vis_art = None
320
+
321
+ if vis_mode == "shimmer_string":
322
+ elements = state.cava.get_shimmer_string_elements(vis_w, playing, t_now)
323
+ elif vis_mode == "harmonic_strings":
324
+ elements = state.cava.get_harmonic_strings_elements(vis_w, playing, t_now)
325
+ elif vis_mode == "laser_cords":
326
+ elements = state.cava.get_laser_cords_elements(vis_w, playing, t_now)
327
+ elif vis_mode == "quantum_strings":
328
+ elements = state.cava.get_quantum_strings_elements(vis_w, playing, t_now)
329
+ elif vis_mode == "aurora_ribbon":
330
+ elements = state.cava.get_aurora_ribbon_elements(vis_w, playing, t_now)
331
+ elif vis_mode == "stereo_harp":
332
+ elements = state.cava.get_stereo_harp_elements(vis_w, playing, t_now)
333
+ elif vis_mode == "shimmer_spectrum":
334
+ elements = state.cava.get_shimmer_spectrum_elements(vis_w, playing, t_now)
335
+ elif vis_mode == "mirrored_butterfly":
336
+ elements = state.cava.get_mirrored_butterfly_elements(vis_w, playing, t_now)
337
+ elif vis_mode == "matrix_rain":
338
+ elements = state.cava.get_matrix_rain_elements(vis_w, playing, t_now)
339
+ elif vis_mode == "frequency_beams":
340
+ elements = state.cava.get_frequency_beams_elements(vis_w, playing, t_now)
341
+ elif vis_mode == "sine_interference":
342
+ elements = state.cava.get_sine_interference_elements(vis_w, playing, t_now)
343
+ elif vis_mode == "braille_oscilloscope":
344
+ elements = state.cava.get_braille_oscilloscope_elements(vis_w, playing, t_now)
345
+ elif vis_mode == "radial_shock_burst":
346
+ elements = state.cava.get_radial_shock_burst_elements(vis_w, playing, t_now)
347
+ elif vis_mode == "starlight_constellation":
348
+ elements = state.cava.get_starlight_constellation_elements(vis_w, playing, t_now)
349
+ elif vis_mode == "waveform":
350
+ vis_art = state.cava.get_waveform_str(vis_w, playing)
351
+ elif vis_mode == "vu":
352
+ vis_art = state.cava.get_stereo_vu_str(vis_w, playing)
353
+ elif vis_mode == "dots":
354
+ elements = state.cava.get_peak_dots_elements(vis_w, playing, t_now)
355
+ else: # hud
356
+ vis_art = f"[{state.cava.get_spectrum_str(8, playing)}]"
357
+
358
+ if elements:
359
+ for col_i, (ch, p_id) in enumerate(elements):
360
+ draw_x = vis_start_x + col_i
361
+ if draw_x <= max_draw_x:
362
+ stdscr.addstr(search_box_y + 1, draw_x, ch, curses.color_pair(p_id) | curses.A_BOLD)
363
+ elif vis_art:
364
+ max_w = max_draw_x - vis_start_x + 1
365
+ if max_w > 0:
366
+ stdscr.addstr(search_box_y + 1, vis_start_x, trim(vis_art, max_w), curses.color_pair(2) | curses.A_BOLD)
367
+ else:
368
+ stdscr.addstr(search_box_y + 1, right_start_x + 1, trim(track_prefix, right_w - 2), curses.color_pair(4) | curses.A_BOLD)
369
+
370
+ stdscr.addstr(search_box_y + 1, right_start_x + right_w - 1, "│", curses.color_pair(8))
371
+
372
+ b1_frame_bot = "└" + "─" * (right_w - 2) + "┘"
373
+ stdscr.addstr(search_box_y + 2, right_start_x, trim(b1_frame_bot, right_w), curses.color_pair(8))
374
+
375
+ # Box 2: [ LATEST - N ]
376
+ box2_y = search_box_y + 3
377
+ box2_h = max(6, h - box2_y - 3)
378
+
379
+ cur_item = state.get_current_sidebar_item()
380
+ if cur_item["key"] == "playing":
381
+ render_now_playing_deck(stdscr, box2_y, right_start_x, right_w, box2_h, state, playing, t_now)
382
+ else:
383
+ b2_title = f" {cur_item['label'].upper()} • {len(filtered_stations)} "
384
+ b2_top_line = f"┌─[{b2_title}]" + "─" * max(0, right_w - len(b2_title) - 5) + "┐"
385
+ stdscr.addstr(box2_y, right_start_x, trim(b2_top_line, right_w), curses.color_pair(8))
386
+
387
+ # Subheader line with active Music Quick-Tabs
388
+ sub_title = f"Category: {cur_item['label']}"
389
+ stdscr.addstr(box2_y + 1, right_start_x, "│", curses.color_pair(8))
390
+
391
+ # Music & Radio Quick-Tabs (1:TOP 2:GENZ 3:LEGENDS 4:NEWAGE 5:ALL 0:RADIO)
392
+ tabs_def = [
393
+ ("1:TOP", "top_artists"),
394
+ ("2:GENZ", "artist_genz"),
395
+ ("3:LEGENDS" if right_w >= 66 else "3:LEG", "artist_legends"),
396
+ ("4:NEWAGE" if right_w >= 66 else "4:NEW", "artist_newage"),
397
+ ("5:ALL", "music"),
398
+ ("0:RADIO" if right_w >= 66 else "0:RAD", "all_radio")
399
+ ]
400
+
401
+ tab_tokens = []
402
+ for tag, k in tabs_def:
403
+ if cur_item["key"] == k:
404
+ tab_tokens.append((f"[{tag}]", True))
405
+ else:
406
+ tab_tokens.append((f" {tag} ", False))
407
+
408
+ tot_tab_len = sum(len(txt) for txt, _ in tab_tokens)
409
+ avail_sub = right_w - tot_tab_len - 4
410
+
411
+ if avail_sub > 8:
412
+ stdscr.addstr(box2_y + 1, right_start_x + 2, trim(sub_title, avail_sub), curses.color_pair(2))
413
+ tx = right_start_x + right_w - tot_tab_len - 1
414
+ for txt, is_act in tab_tokens:
415
+ attr = (curses.color_pair(6) | curses.A_BOLD) if is_act else curses.color_pair(7)
416
+ stdscr.addstr(box2_y + 1, tx, txt, attr)
417
+ tx += len(txt)
418
+ else:
419
+ stdscr.addstr(box2_y + 1, right_start_x + 2, trim(sub_title, right_w - 4), curses.color_pair(2))
420
+
421
+ stdscr.addstr(box2_y + 1, right_start_x + right_w - 1, "│", curses.color_pair(8))
422
+
423
+ # Table Column Headers
424
+ stdscr.addstr(box2_y + 2, right_start_x, "│", curses.color_pair(8))
425
+ hdr_name = "TRACK TITLE" if cur_item["key"] == "history" else ("TOP ARTIST" if cur_item["key"] == "top_artists" else ("ARTIST DISC" if cur_item["key"] in ("artist_genz", "artist_legends", "artist_newage", "music") else "STATION NAME"))
426
+ stdscr.addstr(box2_y + 2, right_start_x + 2, " ", curses.A_NORMAL)
427
+ stdscr.addstr(box2_y + 2, right_start_x + 4, f"{'#':>3}", curses.color_pair(2) | curses.A_BOLD)
428
+ stdscr.addstr(box2_y + 2, right_start_x + 7, " ", curses.A_NORMAL)
429
+ stdscr.addstr(box2_y + 2, right_start_x + 10, hdr_name, curses.color_pair(2) | curses.A_BOLD)
430
+
431
+ col_hdr_right = "STATION TIME STATUS" if cur_item["key"] == "history" else ("GENRE SRC STATUS")
432
+ if right_w > 50:
433
+ stdscr.addstr(box2_y + 2, right_start_x + right_w - len(col_hdr_right) - 3, col_hdr_right, curses.color_pair(2) | curses.A_BOLD)
434
+ stdscr.addstr(box2_y + 2, right_start_x + right_w - 1, "│", curses.color_pair(8))
435
+
436
+ # Stations Rows
437
+ max_rows = box2_h - 4
438
+ total_st = len(filtered_stations)
439
+
440
+ if total_st == 0:
441
+ stdscr.addstr(box2_y + 4, right_start_x + 4, "No stations found in this category / search.", curses.A_DIM)
442
+ else:
443
+ state.cursor_idx = max(0, min(state.cursor_idx, total_st - 1))
444
+
445
+ if total_st <= max_rows:
446
+ scroll_offset = 0
447
+ else:
448
+ scroll_offset = max(0, min(state.cursor_idx - max_rows // 2, total_st - max_rows))
449
+
450
+ visible_rows = filtered_stations[scroll_offset:scroll_offset + max_rows]
451
+
452
+ for row_rel, (orig_i, st) in enumerate(visible_rows):
453
+ cur_item_idx = scroll_offset + row_rel
454
+ is_selected = (cur_item_idx == state.cursor_idx and state.region == "content")
455
+ is_now_playing = (orig_i == state.idx)
456
+ is_favorite = (st["name"] in state.favorites)
457
+
458
+ arrow = "→ " if is_selected else " "
459
+ num_str = f"{orig_i + 1:>3}"
460
+ fav_mark = "* " if is_favorite else " "
461
+ prefix = f"{arrow}{num_str} {fav_mark}" # Strictly 8 characters
462
+
463
+ name_max_w = max(10, right_w - 40)
464
+ name_str = trim(st['name'], name_max_w)
465
+
466
+ if cur_item["key"] == "history":
467
+ genre_str = f"{trim(st.get('station', st.get('genre', '')), 14):<14}"
468
+ src_str = f"{trim(st.get('time', 'RECENT'), 6):<6}"
469
+ else:
470
+ genre_str = f"{trim(st.get('genre', ''), 14):<14}"
471
+ src_str = f"{get_source_tag(st.get('url', '')):<6}"
472
+ status_text = "▶ PLAYING" if (is_now_playing and playing) else ("■ PAUSED" if is_now_playing else " READY")
473
+
474
+ draw_y = box2_y + 3 + row_rel
475
+ stdscr.addstr(draw_y, right_start_x, "│", curses.color_pair(8))
476
+
477
+ row_left = f"{prefix}{name_str}"
478
+ row_right = f"{genre_str} {src_str} {status_text}"
479
+
480
+ if is_selected:
481
+ full_line = f"{row_left:<{right_w - len(row_right) - 4}} {row_right}"
482
+ stdscr.addstr(draw_y, right_start_x + 2, trim(full_line, right_w - 4), curses.color_pair(6) | curses.A_BOLD)
483
+ elif is_now_playing:
484
+ full_line = f"{row_left:<{right_w - len(row_right) - 4}} {row_right}"
485
+ stdscr.addstr(draw_y, right_start_x + 2, trim(full_line, right_w - 4), curses.color_pair(5) | curses.A_BOLD)
486
+ else:
487
+ stdscr.addstr(draw_y, right_start_x + 2, arrow, curses.A_NORMAL)
488
+ stdscr.addstr(draw_y, right_start_x + 4, num_str, curses.color_pair(4))
489
+ stdscr.addstr(draw_y, right_start_x + 7, " ", curses.A_NORMAL)
490
+ stdscr.addstr(draw_y, right_start_x + 8, fav_mark, curses.color_pair(4) | curses.A_BOLD if is_favorite else curses.A_NORMAL)
491
+ stdscr.addstr(draw_y, right_start_x + 10, name_str, curses.color_pair(7))
492
+ if right_w > 50:
493
+ stdscr.addstr(draw_y, right_start_x + right_w - len(row_right) - 3, row_right, curses.color_pair(7))
494
+
495
+ stdscr.addstr(draw_y, right_start_x + right_w - 1, "│", curses.color_pair(8))
496
+
497
+ for empty_r in range(len(visible_rows), max_rows):
498
+ draw_y = box2_y + 3 + empty_r
499
+ stdscr.addstr(draw_y, right_start_x, "│" + " " * (right_w - 2) + "│", curses.color_pair(8))
500
+
501
+ # Bottom bracket
502
+ b2_frame_bot = "└" + "─" * (right_w - 2) + "┘"
503
+ stdscr.addstr(box2_y + box2_h - 1, right_start_x, trim(b2_frame_bot, right_w), curses.color_pair(8))
504
+
505
+ # -------------------------------------------------------------
506
+ # 4. BOTTOM ACTION / HOTKEY BAR
507
+ # -------------------------------------------------------------
508
+ footer_y = h - 2
509
+
510
+ if state.toast_msg and time.time() < state.toast_time:
511
+ toast_text = f" {state.toast_msg} "
512
+ stdscr.addstr(footer_y, 2, toast_text, curses.color_pair(2) | curses.A_REVERSE | curses.A_BOLD)
513
+ else:
514
+ hk_items = [
515
+ ("↑↓←→", "Move"),
516
+ ("SPACE", "Play/Pause"),
517
+ ("ENTER", "Play"),
518
+ ("v", "Vis"),
519
+ ("e/E", "Ambient"),
520
+ ("R", "Record FLAC"),
521
+ ("b", "Fav"),
522
+ ("1-5", "Music"),
523
+ ("0", "Radio"),
524
+ ("/", "Search"),
525
+ ("+/-", "Vol"),
526
+ ("s", "Settings"),
527
+ ("I", "Insights"),
528
+ ("tab", "Switch"),
529
+ ("?", "Keys"),
530
+ ("q", "Quit")
531
+ ]
532
+ cx = 2
533
+ for k_code, k_desc in hk_items:
534
+ if cx + len(k_code) + len(k_desc) + 3 >= w:
535
+ break
536
+ stdscr.addstr(footer_y, cx, k_code, curses.color_pair(2) | curses.A_BOLD)
537
+ cx += len(k_code) + 1
538
+ stdscr.addstr(footer_y, cx, k_desc, curses.A_NORMAL)
539
+ cx += len(k_desc) + 2
540
+
541
+ stdscr.refresh()
542
+
543
+ # Main interaction loop (~33fps smooth animation)
544
+ draw()
545
+ while not state.quit:
546
+ try:
547
+ ch = stdscr.getch()
548
+ except Exception:
549
+ ch = -1
550
+ if ch != -1:
551
+ h, width = stdscr.getmaxyx()
552
+
553
+ # --- SEARCH INPUT MODE ---
554
+ if state.search_active:
555
+ if ch in (27,): # ESC cancels search
556
+ state.search_active = False
557
+ state.search_query = ""
558
+ elif ch in (ord('\n'), curses.KEY_ENTER, 10, 13):
559
+ state.search_active = False
560
+ state.region = "content"
561
+ elif ch in (curses.KEY_BACKSPACE, 127, 8):
562
+ if state.search_query:
563
+ state.search_query = state.search_query[:-1]
564
+ elif 32 <= ch <= 126:
565
+ state.search_query += chr(ch)
566
+ draw()
567
+ continue
568
+
569
+ # --- GLOBAL SHORTCUTS ---
570
+ if ch in (ord('q'), ord('Q')):
571
+ state.quit = True
572
+ break
573
+ elif ch in (9,): # TAB key: toggle focus between Sidebar and Content
574
+ state.region = "sidebar" if state.region == "content" else "content"
575
+ elif ch in (ord('/'),):
576
+ state.search_active = True
577
+ state.search_query = ""
578
+ elif ch in (ord(' '),):
579
+ state.toggle_play()
580
+ elif ch in (ord('v'), ord('V')):
581
+ state.cycle_visualizer()
582
+ elif ch == ord('e'):
583
+ state.toggle_ambient()
584
+ elif ch == ord('E'):
585
+ show_ambient_modal(stdscr, state); draw()
586
+ elif ch in (ord('r'), ord('R')): # R / r for Lossless FLAC Recording
587
+ state.toggle_recording()
588
+ elif ch in (ord('+'), ord('=')):
589
+ state.adjust_volume(5)
590
+ elif ch in (ord('-'), ord('_')):
591
+ state.adjust_volume(-5)
592
+ elif ch in (ord(']'),):
593
+ state.adjust_ambient_volume(5)
594
+ elif ch in (ord('['),):
595
+ state.adjust_ambient_volume(-5)
596
+ elif ch in (ord('p'), ord('P')):
597
+ state.select_section("playing")
598
+ state.region = "content"
599
+ elif ch in (ord('d'), ord('D')):
600
+ state.cycle_deck()
601
+ elif ch in (ord('b'), ord('B')):
602
+ state.toggle_favorite()
603
+ elif ch in (ord('c'), ord('C')):
604
+ with state.lock:
605
+ pyperclip.copy(state.current_track)
606
+ state.set_toast(f"Copied track: {state.current_track}")
607
+ elif ch in (ord('?'),):
608
+ show_help_modal(stdscr, state); draw()
609
+ elif ch == ord('t'):
610
+ state.cycle_theme()
611
+ elif ch == ord('T'):
612
+ show_theme_selector(stdscr, state); draw()
613
+ elif ch == ord('s'):
614
+ show_settings_modal(stdscr, state); draw()
615
+ elif ch == ord('S'):
616
+ state.cycle_shimmer()
617
+ elif ch in (ord('a'), ord('A')):
618
+ show_add_station_modal(stdscr, state); draw()
619
+ elif ch in (ord('I'),):
620
+ show_insights_modal(stdscr, state); draw()
621
+ elif ord('0') <= ch <= ord('9'):
622
+ music_shortcuts = {
623
+ ord('1'): "top_artists",
624
+ ord('2'): "artist_genz",
625
+ ord('3'): "artist_legends",
626
+ ord('4'): "artist_newage",
627
+ ord('5'): "music",
628
+ ord('0'): "all_radio"
629
+ }
630
+ sel_sec = music_shortcuts.get(ch)
631
+ if sel_sec:
632
+ state.select_section(sel_sec)
633
+ state.region = "content"
634
+ elif ch in (ord('m'), ord('M'), ord('*')):
635
+ state.select_section("top_artists" if ch == ord('*') else "music")
636
+ state.region = "content"
637
+
638
+ # --- NAVIGATION IN SIDEBAR (FIXED: Cursor moves visual focus only, Enter applies) ---
639
+ elif state.region == "sidebar":
640
+ if ch in (curses.KEY_UP, ord('k'), ord('K')):
641
+ state.sidebar_idx = (state.sidebar_idx - 1) % len(ALL_SIDEBAR_ITEMS)
642
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
643
+ state.sidebar_idx = (state.sidebar_idx + 1) % len(ALL_SIDEBAR_ITEMS)
644
+ elif ch in (ord('\n'), curses.KEY_ENTER, 10, 13, curses.KEY_RIGHT, ord('l'), ord('L')):
645
+ sel_item = ALL_SIDEBAR_ITEMS[state.sidebar_idx]
646
+ if sel_item["key"] == "themes":
647
+ show_theme_selector(stdscr, state); draw()
648
+ elif sel_item["key"] == "ambient_fx":
649
+ show_ambient_modal(stdscr, state); draw()
650
+ elif sel_item["key"] == "settings":
651
+ show_settings_modal(stdscr, state); draw()
652
+ elif sel_item["key"] == "insights":
653
+ show_insights_modal(stdscr, state); draw()
654
+ else:
655
+ if sel_item["key"] == "lush" and not state.headphone_warned_session:
656
+ show_headphone_warning_modal(stdscr, state)
657
+ state.headphone_warned_session = True
658
+ state.select_section(sel_item["key"])
659
+ state.region = "content"
660
+
661
+ # --- NAVIGATION IN CONTENT / STATIONS LIST ---
662
+ elif state.region == "content":
663
+ filtered = state.get_filtered_stations()
664
+ if ch in (curses.KEY_UP, ord('k'), ord('K')):
665
+ if filtered:
666
+ state.cursor_idx = (state.cursor_idx - 1) % len(filtered)
667
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
668
+ if filtered:
669
+ state.cursor_idx = (state.cursor_idx + 1) % len(filtered)
670
+ elif ch in (curses.KEY_LEFT, ord('h'), ord('H')):
671
+ state.region = "sidebar"
672
+ elif ch in (ord('\n'), curses.KEY_ENTER, 10, 13):
673
+ if filtered and state.cursor_idx < len(filtered):
674
+ target_orig_idx = filtered[state.cursor_idx][0]
675
+ state.play_station(target_orig_idx)
676
+ elif ch in (ord('a'), ord('A')):
677
+ show_add_station_modal(stdscr, state)
678
+ draw()
679
+
680
+ draw()
681
+ else:
682
+ t_frame_start = time.perf_counter()
683
+ draw()
684
+ fps = getattr(state, "framerate", 144)
685
+ interval = 1.0 / max(30, min(240, fps))
686
+ elapsed = time.perf_counter() - t_frame_start
687
+ sleep_dur = max(0.001, interval - elapsed)
688
+ time.sleep(sleep_dur)
689
+
690
+ state.shutdown()