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.
@@ -0,0 +1,1382 @@
1
+ # LUSH - Interactive Modal Dialogs
2
+ import curses
3
+ import time
4
+ import pyperclip
5
+ from .constants import (
6
+ AMBIENT_PRESETS, VISUALIZER_MODES, VISUALIZER_LABELS,
7
+ SHIMMER_PALETTES, DEFAULT_THEMES, SHIMMER_EFFECTS, SHIMMER_EFFECT_LABELS
8
+ )
9
+ from .state import PlayerState, save_stations, save_favorites, save_config
10
+ from .ui_helpers import trim, apply_theme, get_source_tag, get_shimmer_color_pair
11
+
12
+ def show_age_verification_modal(stdscr, state: PlayerState) -> bool:
13
+ h, w = stdscr.getmaxyx()
14
+ box_w = min(56, max(40, w - 4))
15
+ box_h = 10
16
+ start_y = max(0, (h - box_h) // 2)
17
+ start_x = max(0, (w - box_w) // 2)
18
+
19
+ cur_opt = 0 # 0: Yes, 1: No
20
+ curses.curs_set(0)
21
+ stdscr.timeout(50)
22
+
23
+ while True:
24
+ stdscr.erase()
25
+ apply_theme(state.theme_name, state.themes)
26
+
27
+ title = " AGE VERIFICATION "
28
+ stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
29
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
30
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title)
31
+ stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
32
+
33
+ for y_off in range(1, box_h - 1):
34
+ stdscr.addstr(start_y + y_off, start_x, "│", curses.color_pair(8))
35
+ stdscr.addstr(start_y + y_off, start_x + box_w - 1, "│", curses.color_pair(8))
36
+
37
+ msg1 = "You must be 18+ to enable this section."
38
+ msg2 = "Are you 18 years of age or older?"
39
+ stdscr.addstr(start_y + 2, start_x + max(2, (box_w - len(msg1)) // 2), msg1, curses.color_pair(4))
40
+ stdscr.addstr(start_y + 3, start_x + max(2, (box_w - len(msg2)) // 2), msg2, curses.A_BOLD)
41
+
42
+ btn_y = start_y + 6
43
+ yes_btn = " [ YES, I AM 18+ ] "
44
+ no_btn = " [ NO, CANCEL ] "
45
+
46
+ if cur_opt == 0:
47
+ stdscr.addstr(btn_y, start_x + 4, yes_btn, curses.color_pair(6) | curses.A_BOLD)
48
+ stdscr.addstr(btn_y, start_x + box_w - len(no_btn) - 4, no_btn, curses.A_NORMAL)
49
+ else:
50
+ stdscr.addstr(btn_y, start_x + 4, yes_btn, curses.A_NORMAL)
51
+ stdscr.addstr(btn_y, start_x + box_w - len(no_btn) - 4, no_btn, curses.color_pair(6) | curses.A_BOLD)
52
+
53
+ footer = " ←/→:Select ENTER:Confirm "
54
+ f_y = start_y + box_h - 1
55
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
56
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
57
+
58
+ stdscr.refresh()
59
+ try:
60
+ ch = stdscr.getch()
61
+ except Exception:
62
+ ch = -1
63
+
64
+ if ch in (curses.KEY_LEFT, ord('h'), curses.KEY_RIGHT, ord('l'), 9):
65
+ cur_opt = 1 - cur_opt
66
+ elif ch in (10, curses.KEY_ENTER, 10, 13):
67
+ stdscr.timeout(0)
68
+ return (cur_opt == 0)
69
+ elif ch in (ord('q'), ord('Q'), 27):
70
+ stdscr.timeout(0)
71
+ return False
72
+
73
+ # ---- Headphone Advisory Modal ----
74
+ def show_headphone_warning_modal(stdscr, state: PlayerState):
75
+ h, w = stdscr.getmaxyx()
76
+ box_w = min(58, max(42, w - 4))
77
+ box_h = 10
78
+ start_y = max(0, (h - box_h) // 2)
79
+ start_x = max(0, (w - box_w) // 2)
80
+
81
+ curses.curs_set(0)
82
+ stdscr.timeout(50)
83
+
84
+ while True:
85
+ stdscr.erase()
86
+ apply_theme(state.theme_name, state.themes)
87
+
88
+ title = " [!] HEADPHONE ADVISORY "
89
+ stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
90
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
91
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title)
92
+ stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
93
+
94
+ for y_off in range(1, box_h - 1):
95
+ stdscr.addstr(start_y + y_off, start_x, "│", curses.color_pair(8))
96
+ stdscr.addstr(start_y + y_off, start_x + box_w - 1, "│", curses.color_pair(8))
97
+
98
+ msg1 = "LUSH & ASMR AUDIO EXPERIENCE"
99
+ msg2 = "Please make sure your HEADPHONES are on"
100
+ msg3 = "for the intended binaural sound quality."
101
+
102
+ stdscr.addstr(start_y + 2, start_x + max(2, (box_w - len(msg1)) // 2), msg1, curses.color_pair(2) | curses.A_BOLD)
103
+ stdscr.addstr(start_y + 4, start_x + max(2, (box_w - len(msg2)) // 2), msg2, curses.color_pair(4))
104
+ stdscr.addstr(start_y + 5, start_x + max(2, (box_w - len(msg3)) // 2), msg3, curses.A_DIM)
105
+
106
+ btn = " [ PROCEED ] "
107
+ stdscr.addstr(start_y + 7, start_x + max(2, (box_w - len(btn)) // 2), btn, curses.color_pair(6) | curses.A_BOLD)
108
+
109
+ footer = " Press ENTER or SPACE to continue "
110
+ f_y = start_y + box_h - 1
111
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
112
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
113
+
114
+ stdscr.refresh()
115
+ try:
116
+ ch = stdscr.getch()
117
+ except Exception:
118
+ ch = -1
119
+
120
+ if ch in (10, curses.KEY_ENTER, 10, 13, ord(' '), ord('q'), ord('Q'), 27):
121
+ break
122
+
123
+ stdscr.timeout(0)
124
+
125
+
126
+ # ---- Add Custom Station Modal ----
127
+ def show_add_station_modal(stdscr, state: PlayerState):
128
+ h, w = stdscr.getmaxyx()
129
+ box_w = min(68, max(46, w - 4))
130
+ box_h = 18
131
+ start_y = max(0, (h - box_h) // 2)
132
+ start_x = max(0, (w - box_w) // 2)
133
+
134
+ GENRE_PRESETS = [
135
+ "Music (Artist Radio)",
136
+ "Electronic & Beats",
137
+ "Lo-Fi & Chill",
138
+ "Lush & ASMR",
139
+ "Synthwave",
140
+ "Cyberpunk",
141
+ "Ambient",
142
+ "Jazz & Lounge",
143
+ "Indie & Rock",
144
+ "Custom"
145
+ ]
146
+
147
+ fields = ["name", "genre", "url", "btn_add", "btn_cancel"]
148
+ preset_idx = 0
149
+ values = {"name": "", "genre": GENRE_PRESETS[0], "url": ""}
150
+ custom_genre_mode = False
151
+ cur_field = 0
152
+
153
+ curses.curs_set(0)
154
+ stdscr.timeout(50)
155
+
156
+ while True:
157
+ stdscr.erase()
158
+ apply_theme(state.theme_name, state.themes)
159
+
160
+ title = " ADD CUSTOM STATION "
161
+ stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
162
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
163
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title)
164
+ stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
165
+
166
+ for y_off in range(1, box_h - 1):
167
+ stdscr.addstr(start_y + y_off, start_x, "│", curses.color_pair(8))
168
+ stdscr.addstr(start_y + y_off, start_x + box_w - 1, "│", curses.color_pair(8))
169
+
170
+ # Field 1: Station Name
171
+ name_cursor = "█" if cur_field == 0 else ""
172
+ name_text = f" {values['name']}{name_cursor}"
173
+ stdscr.addstr(start_y + 1, start_x + 3, "Station Name:", curses.color_pair(2) | curses.A_BOLD)
174
+ name_attr = curses.color_pair(6) | curses.A_BOLD if cur_field == 0 else curses.color_pair(8)
175
+ stdscr.addstr(start_y + 2, start_x + 3, "┌" + "─" * (box_w - 8) + "┐", name_attr)
176
+ stdscr.addstr(start_y + 3, start_x + 3, "│", name_attr)
177
+ stdscr.addstr(start_y + 3, start_x + 4, trim(name_text, box_w - 10).ljust(box_w - 10), curses.color_pair(4) | (curses.A_BOLD if cur_field == 0 else 0))
178
+ stdscr.addstr(start_y + 3, start_x + box_w - 5, "│", name_attr)
179
+ stdscr.addstr(start_y + 4, start_x + 3, "└" + "─" * (box_w - 8) + "┘", name_attr)
180
+
181
+ # Field 2: Genre Selector (←/→ or Space cycles presets, typing edits custom)
182
+ stdscr.addstr(start_y + 5, start_x + 3, "Genre / Discover Category:", curses.color_pair(2) | curses.A_BOLD)
183
+ genre_attr = curses.color_pair(6) | curses.A_BOLD if cur_field == 1 else curses.color_pair(8)
184
+ stdscr.addstr(start_y + 6, start_x + 3, "┌" + "─" * (box_w - 8) + "┐", genre_attr)
185
+ stdscr.addstr(start_y + 7, start_x + 3, "│", genre_attr)
186
+
187
+ if custom_genre_mode:
188
+ genre_cursor = "█" if cur_field == 1 else ""
189
+ genre_display = f" {values['genre']}{genre_cursor}"
190
+ else:
191
+ genre_display = f" ◄ {values['genre']} ► (←/→: Cycle Type: Custom)"
192
+
193
+ stdscr.addstr(start_y + 7, start_x + 4, trim(genre_display, box_w - 10).ljust(box_w - 10), curses.color_pair(4) | (curses.A_BOLD if cur_field == 1 else 0))
194
+ stdscr.addstr(start_y + 7, start_x + box_w - 5, "│", genre_attr)
195
+ stdscr.addstr(start_y + 8, start_x + 3, "└" + "─" * (box_w - 8) + "┘", genre_attr)
196
+
197
+ # Field 3: Stream URL
198
+ url_cursor = "█" if cur_field == 2 else ""
199
+ url_text = f" {values['url']}{url_cursor}"
200
+ stdscr.addstr(start_y + 9, start_x + 3, "Stream URL (Direct MP3/AAC or YouTube Live link):", curses.color_pair(2) | curses.A_BOLD)
201
+ url_attr = curses.color_pair(6) | curses.A_BOLD if cur_field == 2 else curses.color_pair(8)
202
+ stdscr.addstr(start_y + 10, start_x + 3, "┌" + "─" * (box_w - 8) + "┐", url_attr)
203
+ stdscr.addstr(start_y + 11, start_x + 3, "│", url_attr)
204
+ stdscr.addstr(start_y + 11, start_x + 4, trim(url_text, box_w - 10).ljust(box_w - 10), curses.color_pair(4) | (curses.A_BOLD if cur_field == 2 else 0))
205
+ stdscr.addstr(start_y + 11, start_x + box_w - 5, "│", url_attr)
206
+ stdscr.addstr(start_y + 12, start_x + 3, "└" + "─" * (box_w - 8) + "┘", url_attr)
207
+
208
+ # Action Buttons
209
+ btn_y = start_y + 13
210
+ btn_add = " [ ADD STATION ] "
211
+ btn_cancel = " [ CANCEL ] "
212
+
213
+ add_attr = curses.color_pair(6) | curses.A_BOLD if cur_field == 3 else curses.color_pair(2) | curses.A_BOLD
214
+ cancel_attr = curses.color_pair(6) | curses.A_BOLD if cur_field == 4 else curses.A_NORMAL
215
+
216
+ stdscr.addstr(btn_y, start_x + 6, btn_add, add_attr)
217
+ stdscr.addstr(btn_y, start_x + box_w - len(btn_cancel) - 6, btn_cancel, cancel_attr)
218
+
219
+ footer = " TAB/↑↓:Move ←/→:Select Genre Ctrl+V:Paste ENTER:Submit ESC:Close "
220
+ f_y = start_y + box_h - 1
221
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
222
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
223
+
224
+ stdscr.refresh()
225
+ try:
226
+ ch = stdscr.getch()
227
+ except Exception:
228
+ ch = -1
229
+
230
+ if ch in (9, curses.KEY_DOWN):
231
+ cur_field = (cur_field + 1) % len(fields)
232
+ elif ch in (curses.KEY_UP, curses.KEY_BTAB):
233
+ cur_field = (cur_field - 1) % len(fields)
234
+ elif cur_field == 1 and ch in (curses.KEY_LEFT, ord('['), ord('h'), ord('H')):
235
+ custom_genre_mode = False
236
+ preset_idx = (preset_idx - 1) % len(GENRE_PRESETS)
237
+ values["genre"] = GENRE_PRESETS[preset_idx]
238
+ elif cur_field == 1 and ch in (curses.KEY_RIGHT, ord(']'), ord('l'), ord('L')):
239
+ custom_genre_mode = False
240
+ preset_idx = (preset_idx + 1) % len(GENRE_PRESETS)
241
+ values["genre"] = GENRE_PRESETS[preset_idx]
242
+ elif ch in (22, 21):
243
+ if cur_field in (0, 1, 2):
244
+ if cur_field == 1: custom_genre_mode = True
245
+ try:
246
+ clip_text = pyperclip.paste().strip()
247
+ values[fields[cur_field]] += clip_text
248
+ except Exception:
249
+ pass
250
+ elif ch in (curses.KEY_BACKSPACE, 127, 8):
251
+ if cur_field in (0, 2):
252
+ cur_k = fields[cur_field]
253
+ if values[cur_k]:
254
+ values[cur_k] = values[cur_k][:-1]
255
+ elif cur_field == 1:
256
+ custom_genre_mode = True
257
+ if values["genre"]:
258
+ values["genre"] = values["genre"][:-1]
259
+ elif 32 <= ch <= 126:
260
+ if cur_field in (0, 2):
261
+ values[fields[cur_field]] += chr(ch)
262
+ elif cur_field == 1:
263
+ if not custom_genre_mode:
264
+ custom_genre_mode = True
265
+ values["genre"] = chr(ch)
266
+ else:
267
+ values["genre"] += chr(ch)
268
+ elif ch in (10, curses.KEY_ENTER, 13):
269
+ if cur_field == 0:
270
+ cur_field = 1
271
+ elif cur_field == 1:
272
+ cur_field = 2
273
+ elif cur_field in (2, 3):
274
+ name_clean = values["name"].strip()
275
+ url_clean = values["url"].strip()
276
+ raw_genre = values["genre"].strip()
277
+ if "music" in raw_genre.lower() or "artist" in raw_genre.lower():
278
+ genre_clean = "Artist Radio"
279
+ cat_clean = "music"
280
+ else:
281
+ genre_clean = raw_genre or "Custom"
282
+ cat_clean = "general"
283
+
284
+ if name_clean and url_clean:
285
+ new_st = {
286
+ "name": name_clean,
287
+ "genre": genre_clean,
288
+ "category": cat_clean,
289
+ "url": url_clean
290
+ }
291
+ with state.lock:
292
+ state.stations.append(new_st)
293
+ save_stations(state.stations)
294
+ new_idx = len(state.stations) - 1
295
+ state.play_station(new_idx)
296
+ state.set_toast(f"→ Added station: {name_clean}", duration=2.5)
297
+ break
298
+ else:
299
+ state.set_toast("[!] Name and URL cannot be empty!")
300
+ elif cur_field == 4:
301
+ break
302
+ elif ch in (27,):
303
+ break
304
+
305
+ stdscr.timeout(0)
306
+
307
+ # ---- Settings HUD Modal ----
308
+ def show_settings_modal(stdscr, state: PlayerState):
309
+ h, w = stdscr.getmaxyx()
310
+ box_w = min(72, max(50, w - 4))
311
+ box_h = min(23, max(19, h - 2))
312
+ start_y = max(0, (h - box_h) // 2)
313
+ start_x = max(0, (w - box_w) // 2)
314
+
315
+ cur_row = 0
316
+ theme_names = list(state.themes.keys())
317
+
318
+ curses.curs_set(0)
319
+ stdscr.timeout(35) # 30-60Hz smooth live shimmer and visualizer rendering
320
+
321
+ while True:
322
+ stdscr.erase()
323
+ apply_theme(state.theme_name, state.themes)
324
+ t_now = time.time()
325
+
326
+ # Border Frame
327
+ stdscr.attron(curses.color_pair(8))
328
+ for y_off in range(1, box_h - 1):
329
+ stdscr.addstr(start_y + y_off, start_x, "│")
330
+ stdscr.addstr(start_y + y_off, start_x + box_w - 1, "│")
331
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
332
+ stdscr.addstr(start_y + box_h - 1, start_x, "└" + "─" * (box_w - 2) + "┘")
333
+ stdscr.attroff(curses.color_pair(8))
334
+
335
+ # Title
336
+ title = " LUSH CONFIGURATION & SETTINGS "
337
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title, curses.color_pair(1) | curses.A_BOLD)
338
+
339
+ # 1. LIVE NAME LOGO WITH REAL-TIME SHIMMER (TESTABLE LIVE IN SETTINGS)
340
+ shimmer_eff = getattr(state, "shimmer_effect", "wave")
341
+ logo_lines = [
342
+ " █ █ █ █▀▀ █ █ █▀▀█",
343
+ " █▄▄ █▄█ ▄██ █▀█ ██ ██"
344
+ ]
345
+ logo_y = start_y + 1
346
+ for row_i, l_text in enumerate(logo_lines):
347
+ l_x = start_x + max(2, (box_w - len(l_text)) // 2)
348
+ for c_idx, ch in enumerate(l_text):
349
+ if ch == " ": continue
350
+ x_norm = c_idx / max(1, len(l_text) - 1)
351
+ cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
352
+ p_id = get_shimmer_color_pair(shimmer_eff, x_norm, c_idx, row_i, t_now, cava_bars=cava_bars, is_playing=state.playing)
353
+ stdscr.addstr(logo_y + row_i, l_x + c_idx, ch, curses.color_pair(p_id) | curses.A_BOLD)
354
+
355
+ # Divider under logo
356
+ div_y = start_y + 3
357
+ stdscr.addstr(div_y, start_x + 1, "├" + "─" * (box_w - 2) + "┤", curses.color_pair(8))
358
+
359
+ vis_name = VISUALIZER_LABELS.get(state.visualizer_mode, state.visualizer_mode)
360
+ amb_name = next((p["name"] for p in AMBIENT_PRESETS if p["id"] == state.ambient_preset), "None")
361
+ shimmer_name = SHIMMER_EFFECT_LABELS.get(state.shimmer_effect, state.shimmer_effect)
362
+ deck_name = "→ Vinyl Turntable (Audiophile LP)" if getattr(state, "deck_style", "vinyl") == "vinyl" else "→ Cassette Deck (Studio Hi-Fi Tape)"
363
+ rate_hz = getattr(state, "framerate", 144)
364
+ rate_label = f" {rate_hz} Hz (Ultra-Fluid 144FPS)" if rate_hz == 144 else f" {rate_hz} Hz"
365
+ is_notify = state.notify_mgr.is_enabled() if hasattr(state, "notify_mgr") else True
366
+ notify_label = " Enabled (Desktop song alerts ON)" if is_notify else " Disabled (Alerts OFF)"
367
+
368
+ rows = [
369
+ ("Theme", f" {state.theme_name}", "←/→: Change theme (Live preview above)"),
370
+ ("Logo Shimmer", f" {shimmer_name}", "←/→: Change shimmer effect (Live preview)"),
371
+ ("Now Playing", f" {deck_name}", "←/→: Switch Vinyl / Cassette Deck style"),
372
+ ("Visualizer", f" {vis_name}", "←/→: Change 60-144Hz CAVA visualizer"),
373
+ ("Live Preview", "", "Real-time audio DSP visualizer preview"),
374
+ ("Ambient FX", f" {amb_name}", "←/→: Cycle soundscape layer"),
375
+ ("18+ Content", " Enabled (Lush & ASMR ON)" if state.nsfw_enabled else " Disabled (Lush & ASMR OFF)", "Toggle 18+ content filter"),
376
+ ("Notifications", notify_label, "←/→ or ENTER: Toggle desktop track notifications"),
377
+ ("Master Vol", f" {state.volume}%", "←/→: Adjust master volume"),
378
+ ("Ambient Vol", f" {state.ambient_volume}%", "←/→: Adjust ambient FX volume"),
379
+ ("CAVA Sens", f" {state.cava_sensitivity}", "←/→: Adjust CAVA DSP sensitivity"),
380
+ ("Refresh Rate", rate_label, "←/→: Toggle high refresh rate (144/120/60/240Hz)"),
381
+ ("Save Config", " [ Save All Settings ]", "ENTER: Save immediately")
382
+ ]
383
+
384
+ row_start_y = start_y + 4
385
+ for idx, (label, val, hint) in enumerate(rows):
386
+ y = row_start_y + idx
387
+ if y >= start_y + box_h - 1: break
388
+
389
+ is_active = (idx == cur_row)
390
+
391
+ # Special rendering for Live Preview row
392
+ if label == "Live Preview":
393
+ vis_w = min(36, max(14, box_w - 24))
394
+ vis_mode = state.visualizer_mode
395
+ box_color = curses.color_pair(6) | curses.A_BOLD if (cur_row in (3, 4)) else curses.color_pair(2) | curses.A_BOLD
396
+ stdscr.addstr(y, start_x + 2, f" {label:<13}: [", box_color)
397
+ vis_render_x = start_x + 19
398
+
399
+ # Render live visualizer elements based on active mode
400
+ if vis_mode == "shimmer_string":
401
+ elems = state.cava.get_shimmer_string_elements(vis_w, state.playing, t_now)
402
+ for col_i, (ch, p_id) in enumerate(elems):
403
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
404
+ elif vis_mode == "harmonic_strings":
405
+ elems = state.cava.get_harmonic_strings_elements(vis_w, state.playing, t_now)
406
+ for col_i, (ch, p_id) in enumerate(elems):
407
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
408
+ elif vis_mode == "laser_cords":
409
+ elems = state.cava.get_laser_cords_elements(vis_w, state.playing, t_now)
410
+ for col_i, (ch, p_id) in enumerate(elems):
411
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
412
+ elif vis_mode == "quantum_strings":
413
+ elems = state.cava.get_quantum_strings_elements(vis_w, state.playing, t_now)
414
+ for col_i, (ch, p_id) in enumerate(elems):
415
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
416
+ elif vis_mode == "aurora_ribbon":
417
+ elems = state.cava.get_aurora_ribbon_elements(vis_w, state.playing, t_now)
418
+ for col_i, (ch, p_id) in enumerate(elems):
419
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
420
+ elif vis_mode == "stereo_harp":
421
+ elems = state.cava.get_stereo_harp_elements(vis_w, state.playing, t_now)
422
+ for col_i, (ch, p_id) in enumerate(elems):
423
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
424
+ elif vis_mode == "shimmer_spectrum":
425
+ elems = state.cava.get_shimmer_spectrum_elements(vis_w, state.playing, t_now)
426
+ for col_i, (ch, p_id) in enumerate(elems):
427
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
428
+ elif vis_mode == "mirrored_butterfly":
429
+ elems = state.cava.get_mirrored_butterfly_elements(vis_w, state.playing, t_now)
430
+ for col_i, (ch, p_id) in enumerate(elems):
431
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
432
+ elif vis_mode == "matrix_rain":
433
+ elems = state.cava.get_matrix_rain_elements(vis_w, state.playing, t_now)
434
+ for col_i, (ch, p_id) in enumerate(elems):
435
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
436
+ elif vis_mode == "frequency_beams":
437
+ elems = state.cava.get_frequency_beams_elements(vis_w, state.playing, t_now)
438
+ for col_i, (ch, p_id) in enumerate(elems):
439
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
440
+ elif vis_mode == "sine_interference":
441
+ elems = state.cava.get_sine_interference_elements(vis_w, state.playing, t_now)
442
+ for col_i, (ch, p_id) in enumerate(elems):
443
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
444
+ elif vis_mode == "braille_oscilloscope":
445
+ elems = state.cava.get_braille_oscilloscope_elements(vis_w, state.playing, t_now)
446
+ for col_i, (ch, p_id) in enumerate(elems):
447
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
448
+ elif vis_mode == "radial_shock_burst":
449
+ elems = state.cava.get_radial_shock_burst_elements(vis_w, state.playing, t_now)
450
+ for col_i, (ch, p_id) in enumerate(elems):
451
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
452
+ elif vis_mode == "starlight_constellation":
453
+ elems = state.cava.get_starlight_constellation_elements(vis_w, state.playing, t_now)
454
+ for col_i, (ch, p_id) in enumerate(elems):
455
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
456
+ elif vis_mode == "waveform":
457
+ vis_art = state.cava.get_waveform_str(vis_w, state.playing)
458
+ stdscr.addstr(y, vis_render_x, vis_art, curses.color_pair(2) | curses.A_BOLD)
459
+ elif vis_mode == "vu":
460
+ vis_art = state.cava.get_stereo_vu_str(vis_w, state.playing)
461
+ stdscr.addstr(y, vis_render_x, vis_art, curses.color_pair(2) | curses.A_BOLD)
462
+ elif vis_mode == "dots":
463
+ elems = state.cava.get_peak_dots_elements(vis_w, state.playing, t_now)
464
+ for col_i, (ch, p_id) in enumerate(elems):
465
+ stdscr.addstr(y, vis_render_x + col_i, ch, curses.color_pair(p_id) | curses.A_BOLD)
466
+ elif vis_mode == "off":
467
+ stdscr.addstr(y, vis_render_x, " (Disabled / Focus Mode) ".center(vis_w), curses.A_DIM)
468
+ else: # hud
469
+ stdscr.addstr(y, vis_render_x, f"[{state.cava.get_spectrum_str(min(12, vis_w-2), state.playing)}]".center(vis_w), curses.color_pair(2) | curses.A_BOLD)
470
+
471
+ stdscr.addstr(y, vis_render_x + vis_w, "]", box_color)
472
+ continue
473
+
474
+ row_str = f" {label:<13}: {val}"
475
+ if is_active:
476
+ stdscr.addstr(y, start_x + 2, trim(row_str, box_w - 4).ljust(box_w - 4), curses.color_pair(6) | curses.A_BOLD)
477
+ else:
478
+ stdscr.addstr(y, start_x + 2, f" {label:<13}:", curses.color_pair(2) | curses.A_BOLD)
479
+ stdscr.addstr(y, start_x + 18, trim(val, box_w - 20), curses.A_NORMAL)
480
+
481
+ footer = " ↑/↓:Select ←/→:Adjust ENTER:Apply ESC/q:Close "
482
+ f_y = start_y + box_h - 1
483
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
484
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
485
+
486
+ stdscr.refresh()
487
+ try:
488
+ ch = stdscr.getch()
489
+ except Exception:
490
+ ch = -1
491
+
492
+ if ch in (curses.KEY_UP, ord('k'), ord('K')):
493
+ cur_row = (cur_row - 1) % len(rows)
494
+ if cur_row == 4: cur_row = 3
495
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
496
+ cur_row = (cur_row + 1) % len(rows)
497
+ if cur_row == 4: cur_row = 5
498
+ elif ch in (curses.KEY_LEFT, ord('h'), ord('H')):
499
+ if cur_row == 0: # Theme
500
+ t_idx = theme_names.index(state.theme_name) if state.theme_name in theme_names else 0
501
+ state.set_theme(theme_names[(t_idx - 1) % len(theme_names)])
502
+ elif cur_row == 1: # Logo Shimmer
503
+ s_idx = SHIMMER_EFFECTS.index(state.shimmer_effect) if state.shimmer_effect in SHIMMER_EFFECTS else 0
504
+ state.shimmer_effect = SHIMMER_EFFECTS[(s_idx - 1) % len(SHIMMER_EFFECTS)]
505
+ state.save_current_config()
506
+ elif cur_row == 2: # Now Playing Deck
507
+ state.cycle_deck()
508
+ elif cur_row in (3, 4): # Vis
509
+ v_idx = VISUALIZER_MODES.index(state.visualizer_mode) if state.visualizer_mode in VISUALIZER_MODES else 0
510
+ state.visualizer_mode = VISUALIZER_MODES[(v_idx - 1) % len(VISUALIZER_MODES)]
511
+ state.save_current_config()
512
+ elif cur_row == 5: # Ambient
513
+ a_idx = next((i for i, p in enumerate(AMBIENT_PRESETS) if p["id"] == state.ambient_preset), 0)
514
+ state.start_ambient_preset(AMBIENT_PRESETS[(a_idx - 1) % len(AMBIENT_PRESETS)]["id"])
515
+ elif cur_row == 6: # 18+ Content
516
+ if not state.nsfw_enabled:
517
+ if show_age_verification_modal(stdscr, state):
518
+ state.nsfw_enabled = True
519
+ state.save_current_config()
520
+ show_headphone_warning_modal(stdscr, state)
521
+ state.headphone_warned_session = True
522
+ state.set_toast("18+ Content: Enabled (Lush & ASMR Unlocked)")
523
+ else:
524
+ state.nsfw_enabled = False
525
+ if state.section_key == "lush":
526
+ state.section_key = "top_artists"
527
+ state.save_current_config()
528
+ state.set_toast("18+ Content: Disabled (Lush & ASMR Filtered)")
529
+ elif cur_row == 7: # Notifications
530
+ state.toggle_notifications()
531
+ elif cur_row == 8: # Master Vol
532
+ state.adjust_volume(-5)
533
+ elif cur_row == 9: # Ambient Vol
534
+ state.adjust_ambient_volume(-5)
535
+ elif cur_row == 10: # CAVA Sens
536
+ state.cava_sensitivity = max(50, state.cava_sensitivity - 10)
537
+ state.cava.update_settings(state.cava_sensitivity, getattr(state, "framerate", 144))
538
+ state.save_current_config()
539
+ elif cur_row == 11: # Refresh Rate
540
+ state.cycle_framerate(-1)
541
+ elif ch in (curses.KEY_RIGHT, ord('l'), ord('L')):
542
+ if cur_row == 0: # Theme
543
+ t_idx = theme_names.index(state.theme_name) if state.theme_name in theme_names else 0
544
+ state.set_theme(theme_names[(t_idx + 1) % len(theme_names)])
545
+ elif cur_row == 1: # Logo Shimmer
546
+ s_idx = SHIMMER_EFFECTS.index(state.shimmer_effect) if state.shimmer_effect in SHIMMER_EFFECTS else 0
547
+ state.shimmer_effect = SHIMMER_EFFECTS[(s_idx + 1) % len(SHIMMER_EFFECTS)]
548
+ state.save_current_config()
549
+ elif cur_row == 2: # Now Playing Deck
550
+ state.cycle_deck()
551
+ elif cur_row in (3, 4): # Vis
552
+ v_idx = VISUALIZER_MODES.index(state.visualizer_mode) if state.visualizer_mode in VISUALIZER_MODES else 0
553
+ state.visualizer_mode = VISUALIZER_MODES[(v_idx + 1) % len(VISUALIZER_MODES)]
554
+ state.save_current_config()
555
+ elif cur_row == 5: # Ambient
556
+ a_idx = next((i for i, p in enumerate(AMBIENT_PRESETS) if p["id"] == state.ambient_preset), 0)
557
+ state.start_ambient_preset(AMBIENT_PRESETS[(a_idx + 1) % len(AMBIENT_PRESETS)]["id"])
558
+ elif cur_row == 6: # 18+ Content
559
+ if not state.nsfw_enabled:
560
+ if show_age_verification_modal(stdscr, state):
561
+ state.nsfw_enabled = True
562
+ state.save_current_config()
563
+ show_headphone_warning_modal(stdscr, state)
564
+ state.headphone_warned_session = True
565
+ state.set_toast("18+ Content: Enabled (Lush & ASMR Unlocked)")
566
+ else:
567
+ state.nsfw_enabled = False
568
+ if state.section_key == "lush":
569
+ state.section_key = "top_artists"
570
+ state.save_current_config()
571
+ state.set_toast("18+ Content: Disabled (Lush & ASMR Filtered)")
572
+ elif cur_row == 7: # Notifications
573
+ state.toggle_notifications()
574
+ elif cur_row == 8: # Master Vol
575
+ state.adjust_volume(5)
576
+ elif cur_row == 9: # Ambient Vol
577
+ state.adjust_ambient_volume(5)
578
+ elif cur_row == 10: # CAVA Sens
579
+ state.cava_sensitivity = min(350, state.cava_sensitivity + 10)
580
+ state.cava.update_settings(state.cava_sensitivity, getattr(state, "framerate", 144))
581
+ state.save_current_config()
582
+ elif cur_row == 11: # Refresh Rate
583
+ state.cycle_framerate(1)
584
+ elif ch in (10, 13, curses.KEY_ENTER, ord(' ')):
585
+ if cur_row == 0:
586
+ show_theme_selector(stdscr, state)
587
+ elif cur_row == 1:
588
+ state.cycle_shimmer()
589
+ elif cur_row == 2:
590
+ state.cycle_deck()
591
+ elif cur_row in (3, 4):
592
+ state.cycle_visualizer()
593
+ elif cur_row == 5:
594
+ show_ambient_modal(stdscr, state)
595
+ elif cur_row == 6: # 18+ Content toggle
596
+ if not state.nsfw_enabled:
597
+ if show_age_verification_modal(stdscr, state):
598
+ state.nsfw_enabled = True
599
+ state.save_current_config()
600
+ show_headphone_warning_modal(stdscr, state)
601
+ state.headphone_warned_session = True
602
+ state.set_toast("18+ Content: Enabled (Lush & ASMR Unlocked)")
603
+ else:
604
+ state.nsfw_enabled = False
605
+ if state.section_key == "lush":
606
+ state.section_key = "top_artists"
607
+ state.save_current_config()
608
+ state.set_toast("18+ Content: Disabled (Lush & ASMR Filtered)")
609
+ elif cur_row == 7: # Notifications toggle
610
+ state.toggle_notifications()
611
+ elif cur_row == 11: # Refresh Rate toggle
612
+ state.cycle_framerate(1)
613
+ elif cur_row == 12: # Save Config
614
+ state.save_current_config()
615
+ state.set_toast("Configuration saved successfully!")
616
+ break
617
+ elif ch in (ord('q'), ord('Q'), 27):
618
+ state.save_current_config()
619
+ break
620
+
621
+ stdscr.timeout(0)
622
+
623
+
624
+ # ---- Ambient FX Modal ----
625
+ def show_ambient_modal(stdscr, state: PlayerState):
626
+ h, w = stdscr.getmaxyx()
627
+ box_w = min(66, max(46, w - 4))
628
+ box_h = len(AMBIENT_PRESETS) + 8
629
+ start_y = max(0, (h - box_h) // 2)
630
+ start_x = max(0, (w - box_w) // 2)
631
+
632
+ cur_sel = 0
633
+ for i, p in enumerate(AMBIENT_PRESETS):
634
+ if p["id"] == state.ambient_preset:
635
+ cur_sel = i
636
+ break
637
+
638
+ curses.curs_set(0)
639
+ stdscr.timeout(50)
640
+
641
+ while True:
642
+ stdscr.erase()
643
+ apply_theme(state.theme_name, state.themes)
644
+
645
+ title = " AMBIENT FX SOUNDSCAPES "
646
+ stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
647
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
648
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title)
649
+ stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
650
+
651
+ for idx, p in enumerate(AMBIENT_PRESETS):
652
+ y = start_y + 1 + idx
653
+ stdscr.addstr(y, start_x, "│", curses.color_pair(8))
654
+ stdscr.addstr(y, start_x + box_w - 1, "│", curses.color_pair(8))
655
+
656
+ is_active = (p["id"] == state.ambient_preset)
657
+ marker = "● " if is_active else " "
658
+ item_text = f" {marker}{p['name']:<28} {trim(p['desc'], box_w - 36)}"
659
+
660
+ if idx == cur_sel:
661
+ stdscr.addstr(y, start_x + 2, trim(item_text, box_w - 4).ljust(box_w - 4), curses.color_pair(6) | curses.A_BOLD)
662
+ elif is_active:
663
+ stdscr.addstr(y, start_x + 2, trim(item_text, box_w - 4), curses.color_pair(2) | curses.A_BOLD)
664
+ else:
665
+ stdscr.addstr(y, start_x + 2, trim(item_text, box_w - 4), curses.A_NORMAL)
666
+
667
+ vol_y = start_y + len(AMBIENT_PRESETS) + 2
668
+ stdscr.addstr(vol_y, start_x, "│", curses.color_pair(8))
669
+ stdscr.addstr(vol_y, start_x + box_w - 1, "│", curses.color_pair(8))
670
+
671
+ bar_len = 20
672
+ fill_len = int((state.ambient_volume / 100.0) * bar_len)
673
+ slider_str = f" Ambient Volume: [{'█' * fill_len}{'░' * (bar_len - fill_len)}] {state.ambient_volume}%"
674
+ stdscr.addstr(vol_y, start_x + 2, slider_str, curses.color_pair(4) | curses.A_BOLD)
675
+
676
+ footer = " ↑/↓:Select ←/→:Volume ENTER:Apply ESC:Close "
677
+ f_y = start_y + box_h - 1
678
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
679
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
680
+
681
+ stdscr.refresh()
682
+ try:
683
+ ch = stdscr.getch()
684
+ except Exception:
685
+ ch = -1
686
+
687
+ if ch in (curses.KEY_UP, ord('k'), ord('K')):
688
+ cur_sel = (cur_sel - 1) % len(AMBIENT_PRESETS)
689
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
690
+ cur_sel = (cur_sel + 1) % len(AMBIENT_PRESETS)
691
+ elif ch in (curses.KEY_LEFT, ord('[')):
692
+ state.adjust_ambient_volume(-5)
693
+ elif ch in (curses.KEY_RIGHT, ord(']')):
694
+ state.adjust_ambient_volume(5)
695
+ elif ch in (ord('\n'), curses.KEY_ENTER, 10, 13):
696
+ state.start_ambient_preset(AMBIENT_PRESETS[cur_sel]["id"])
697
+ break
698
+ elif ch in (ord('q'), ord('Q'), 27):
699
+ break
700
+
701
+ stdscr.timeout(0)
702
+
703
+ # ---- Theme Selector Modal ----
704
+ def show_theme_selector(stdscr, state: PlayerState):
705
+ h, w = stdscr.getmaxyx()
706
+ theme_names = list(state.themes.keys())
707
+ if not theme_names: return
708
+
709
+ cur_sel = theme_names.index(state.theme_name) if state.theme_name in theme_names else 0
710
+ orig_theme = state.theme_name
711
+
712
+ curses.curs_set(0)
713
+ stdscr.timeout(50)
714
+
715
+ while True:
716
+ apply_theme(theme_names[cur_sel], state.themes)
717
+ stdscr.erase()
718
+
719
+ box_w = min(62, max(42, w - 4))
720
+ box_h = min(len(theme_names) + 6, h - 2)
721
+ start_y = max(0, (h - box_h) // 2)
722
+ start_x = max(0, (w - box_w) // 2)
723
+
724
+ title = " Theme Selector "
725
+ stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
726
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
727
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title)
728
+ stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
729
+
730
+ max_items = max(1, box_h - 4)
731
+ scroll_offset = max(0, min(cur_sel - max_items // 2, max(0, len(theme_names) - max_items)))
732
+
733
+ for idx in range(min(max_items, len(theme_names) - scroll_offset)):
734
+ t_idx = scroll_offset + idx
735
+ t_name = theme_names[t_idx]
736
+ t_desc = state.themes[t_name].get("desc", "")
737
+ active_marker = "● " if t_name == orig_theme else " "
738
+ line_text = f" {active_marker}{t_name:<13} {t_desc}"
739
+ y = start_y + 1 + idx
740
+
741
+ stdscr.addstr(y, start_x, "│", curses.color_pair(8))
742
+ stdscr.addstr(y, start_x + box_w - 1, "│", curses.color_pair(8))
743
+
744
+ padded = trim(line_text, box_w - 4).ljust(box_w - 4)
745
+ if t_idx == cur_sel:
746
+ stdscr.addstr(y, start_x + 2, padded, curses.color_pair(6) | curses.A_BOLD)
747
+ else:
748
+ stdscr.addstr(y, start_x + 2, padded)
749
+
750
+ footer_line = " ↑/↓/j/k:Browse ENTER:Select ESC/q:Cancel "
751
+ f_y = start_y + box_h - 1
752
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
753
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer_line)) // 2), footer_line, curses.A_DIM)
754
+
755
+ stdscr.refresh()
756
+ try:
757
+ key = stdscr.getch()
758
+ except Exception:
759
+ key = -1
760
+
761
+ if key in (curses.KEY_UP, ord('k'), ord('K')):
762
+ cur_sel = (cur_sel - 1) % len(theme_names)
763
+ elif key in (curses.KEY_DOWN, ord('j'), ord('J')):
764
+ cur_sel = (cur_sel + 1) % len(theme_names)
765
+ elif key in (ord('\n'), curses.KEY_ENTER, 10, 13):
766
+ state.set_theme(theme_names[cur_sel])
767
+ break
768
+ elif key in (ord('q'), ord('Q'), 27):
769
+ state.set_theme(orig_theme)
770
+ apply_theme(orig_theme, state.themes)
771
+ break
772
+
773
+ stdscr.timeout(0)
774
+
775
+ # ---- Help Modal ----
776
+ def show_help_modal(stdscr, state: PlayerState):
777
+ h, w = stdscr.getmaxyx()
778
+ box_w = min(70, max(46, w - 4))
779
+ box_h = 22
780
+ start_y = max(0, (h - box_h) // 2)
781
+ start_x = max(0, (w - box_w) // 2)
782
+
783
+ help_lines = [
784
+ ("NAVIGATION", ""),
785
+ (" ↑ / ↓ or k / j", "Move cursor up / down in active panel"),
786
+ (" ← / →", "Switch between Sidebar and Station list"),
787
+ (" TAB", "Cycle focus between Sidebar <-> List"),
788
+ (" 1 - 5", "Jump to Music: 1:Top, 2:Gen Z, 3:Legends, 4:New Age, 5:All"),
789
+ (" 0", "Jump to All Web Radios"),
790
+ (" m / *", "Jump to All Music Artists / Top Artists"),
791
+ (" /", "Activate live search bar"),
792
+ ("", ""),
793
+ ("AUDIO & IMMERSION (LIVE CAVA)", ""),
794
+ (" v", "Cycle Visualizer (Live CAVA Spectrum, Wave, VU, Dots)"),
795
+ (" e / E", "Toggle Ambient FX / Open Soundscapes menu"),
796
+ (" R (Shift+R)", "Toggle Live Stream Recording to ~/Music"),
797
+ (" + / -", "Increase / Decrease Main audio volume"),
798
+ (" [ / ]", "Increase / Decrease Ambient FX volume"),
799
+ ("", ""),
800
+ ("CONTROLS & SETTINGS", ""),
801
+ (" ENTER", "Play station / Select sidebar item"),
802
+ (" SPACE", "Toggle Play / Pause"),
803
+ (" d", "Switch deck style (Vinyl Turntable ↔ Cassette Deck)"),
804
+ (" b", "Add / Remove from Favs"),
805
+ (" c", "Copy current track title to clipboard"),
806
+ (" r / R", "Toggle Lossless FLAC Recording"),
807
+ (" t / T", "Cycle theme / Open Theme menu"),
808
+ (" S (Shift+S)", "Cycle Logo Shimmer (Wave, Pulse, Glint, Rainbow...)"),
809
+ (" I (Shift+I)", "Open Listening Diary & Insights Dashboard"),
810
+ (" s", "Open full Settings HUD modal"),
811
+ (" a", "Add custom station"),
812
+ (" q / ESC", "Close modal / Quit Lush")
813
+ ]
814
+
815
+ curses.curs_set(0)
816
+ stdscr.timeout(50)
817
+
818
+ while True:
819
+ stdscr.erase()
820
+ apply_theme(state.theme_name, state.themes)
821
+
822
+ title = " LUSH IMMERSION & CONTROLS "
823
+ stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
824
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
825
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title)
826
+ stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
827
+
828
+ for idx, (k_col, v_col) in enumerate(help_lines[:box_h - 3]):
829
+ y = start_y + 1 + idx
830
+ stdscr.addstr(y, start_x, "│", curses.color_pair(8))
831
+ stdscr.addstr(y, start_x + box_w - 1, "│", curses.color_pair(8))
832
+
833
+ if not v_col and k_col:
834
+ stdscr.addstr(y, start_x + 2, k_col, curses.color_pair(2) | curses.A_BOLD)
835
+ elif k_col:
836
+ stdscr.addstr(y, start_x + 2, k_col, curses.color_pair(4) | curses.A_BOLD)
837
+ stdscr.addstr(y, start_x + 20, trim(v_col, box_w - 22), curses.A_NORMAL)
838
+
839
+ footer = " Press any key to return "
840
+ f_y = start_y + box_h - 1
841
+ stdscr.addstr(f_y, start_x, "└" + "─" * (box_w - 2) + "┘", curses.color_pair(8))
842
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
843
+
844
+ stdscr.refresh()
845
+ try:
846
+ ch = stdscr.getch()
847
+ except Exception:
848
+ ch = -1
849
+ if ch != -1:
850
+ break
851
+
852
+ stdscr.timeout(0)
853
+
854
+ # ---- Main LUSH TUI Loop ----
855
+
856
+ # ---- Welcome Splash Modal ----
857
+ def show_welcome_modal(stdscr, state: PlayerState):
858
+ h, w = stdscr.getmaxyx()
859
+ box_w = min(68, max(50, w - 4))
860
+ box_h = min(23, max(19, h - 2))
861
+ start_y = max(0, (h - box_h) // 2)
862
+ start_x = max(0, (w - box_w) // 2)
863
+
864
+ curses.curs_set(0)
865
+ stdscr.timeout(35)
866
+ dont_show = not getattr(state, 'show_welcome', True)
867
+
868
+ while True:
869
+ stdscr.erase()
870
+ apply_theme(state.theme_name, state.themes)
871
+ t_now = time.time()
872
+ wave_pos = ((t_now * 0.95) % 2.2) - 0.4
873
+
874
+ # Border Frame
875
+ stdscr.attron(curses.color_pair(8))
876
+ for y_off in range(1, box_h - 1):
877
+ stdscr.addstr(start_y + y_off, start_x, "│")
878
+ stdscr.addstr(start_y + y_off, start_x + box_w - 1, "│")
879
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
880
+ stdscr.addstr(start_y + box_h - 1, start_x, "└" + "─" * (box_w - 2) + "┘")
881
+ stdscr.attroff(curses.color_pair(8))
882
+
883
+ # Title Tag
884
+ title = " → WELCOME TO LUSH "
885
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title, curses.color_pair(1) | curses.A_BOLD)
886
+
887
+ # ASCII Logo with Live Metallic Shimmer Wave
888
+ logo_lines = [
889
+ " █ █ █ █▀▀ █ █ █▀▀█",
890
+ " █▄▄ █▄█ ▄██ █▀█ ██ ██"
891
+ ]
892
+ logo_start_y = start_y + 1
893
+ for row_i, l_text in enumerate(logo_lines):
894
+ l_x = start_x + max(2, (box_w - len(l_text)) // 2)
895
+ for c_idx, ch in enumerate(l_text):
896
+ if ch == " ": continue
897
+ x_norm = c_idx / max(1, len(l_text) - 1)
898
+ dist = abs(x_norm - wave_pos)
899
+ if dist < 0.06:
900
+ attr = curses.color_pair(10) | curses.A_BOLD
901
+ elif dist < 0.14:
902
+ attr = curses.color_pair(11) | curses.A_BOLD
903
+ elif dist < 0.22:
904
+ attr = curses.color_pair(12) | curses.A_BOLD
905
+ elif dist < 0.32:
906
+ attr = curses.color_pair(13) | curses.A_BOLD
907
+ else:
908
+ attr = curses.color_pair(14) | curses.A_BOLD
909
+ stdscr.addstr(logo_start_y + row_i, l_x + c_idx, ch, attr)
910
+
911
+ # Subtitle
912
+ sub = "─ AESTHETIC TERMINAL RADIO & DSP VISUALIZER ─"
913
+ stdscr.addstr(start_y + 3, start_x + max(2, (box_w - len(sub)) // 2), sub, curses.color_pair(2))
914
+
915
+ # Feature Highlights
916
+ features = [
917
+ "[→] 500+ 24/7 Live Stations & Artist Discographies",
918
+ "[→] 19 CAVA DSP Visualizers & 30 Audio-Reactive Logo Shimmers",
919
+ "[→] Audiophile Listening Diary, Heatmap & FLAC Vault"
920
+ ]
921
+ feat_y = start_y + 5
922
+ for idx, feat in enumerate(features):
923
+ if feat_y + idx < start_y + box_h - 9:
924
+ stdscr.addstr(feat_y + idx, start_x + max(2, (box_w - len(feat)) // 2), feat, curses.color_pair(4))
925
+
926
+ # Controls Section
927
+ ctrl_title = "─ QUICK CONTROLS ─"
928
+ ctrl_y = start_y + 9
929
+ stdscr.addstr(ctrl_y, start_x + max(2, (box_w - len(ctrl_title)) // 2), ctrl_title, curses.color_pair(9))
930
+
931
+ col1_x = start_x + 4
932
+ col2_x = start_x + (box_w // 2) + 2
933
+
934
+ shortcuts = [
935
+ ("<Space> Play / Pause", "<1-5> Music Artists"),
936
+ ("</> Instant Search", "<0> All Web Radios"),
937
+ ("<v> 19 Visualizers", "<t/T> Select Themes"),
938
+ ("<e/E> Ambient FX", "<Shift+R> Stream Record"),
939
+ ("<p/d> Now Playing Studio", "<q> Clean Quit")
940
+ ]
941
+ for row_i, (c1, c2) in enumerate(shortcuts):
942
+ r_y = ctrl_y + 1 + row_i
943
+ if r_y < start_y + box_h - 4:
944
+ stdscr.addstr(r_y, col1_x, c1, curses.A_NORMAL)
945
+ stdscr.addstr(r_y, col2_x, c2, curses.A_NORMAL)
946
+
947
+ # Start Button with pulse glow
948
+ btn_str = " [ PRESS ENTER OR SPACE TO START ] "
949
+ btn_y = start_y + box_h - 3
950
+ pulse = curses.color_pair(6) | curses.A_BOLD if int(t_now * 3) % 2 == 0 else curses.color_pair(1) | curses.A_BOLD
951
+ stdscr.addstr(btn_y, start_x + max(2, (box_w - len(btn_str)) // 2), btn_str, pulse)
952
+
953
+ # Don't show again toggle
954
+ toggle_char = "x" if dont_show else " "
955
+ toggle_str = f"[{toggle_char}] Don't show on startup (Press D or Tab to toggle)"
956
+ tog_y = start_y + box_h - 2
957
+ stdscr.addstr(tog_y, start_x + max(2, (box_w - len(toggle_str)) // 2), toggle_str, curses.color_pair(7))
958
+
959
+ stdscr.refresh()
960
+ try:
961
+ ch = stdscr.getch()
962
+ except Exception:
963
+ ch = -1
964
+
965
+ if ch in (10, 13, curses.KEY_ENTER, ord(' '), 27, ord('q'), ord('Q')):
966
+ state.show_welcome = not dont_show
967
+ state.config["show_welcome"] = state.show_welcome
968
+ save_config(state.config)
969
+ break
970
+ elif ch in (ord('d'), ord('D'), 9): # 'd' or TAB toggles 'Don't show again'
971
+ dont_show = not dont_show
972
+
973
+ # ---- Audiophile Insights & Listening Diary Modal ----
974
+ def show_insights_modal(stdscr, state: PlayerState):
975
+ from datetime import datetime
976
+ h, w = stdscr.getmaxyx()
977
+ box_w = min(104, max(60, w - 2))
978
+ box_h = min(28, max(22, h - 2))
979
+ start_y = max(0, (h - box_h) // 2)
980
+ start_x = max(0, (w - box_w) // 2)
981
+
982
+ cur_tab = 0 # 0: Heatmap, 1: Top Charts, 2: Sonic Habits, 3: Vault, 4: Diary Log
983
+ tab_names = ["1: HEATMAP", "2: TOP CHARTS", "3: SONIC HABITS", "4: VAULT", "5: DIARY LOG"]
984
+
985
+ # Interactive Cursor States
986
+ hm_w = 15 # default to current week
987
+ hm_d = datetime.now().weekday() # default to today
988
+ chart_tf = "all" # "all", "month", "today"
989
+ chart_idx = 0
990
+ vault_idx = 0
991
+ vault_scroll = 0
992
+ diary_idx = 0
993
+ diary_scroll = 0
994
+
995
+ curses.curs_set(0)
996
+ stdscr.timeout(50)
997
+
998
+ while True:
999
+ stdscr.erase()
1000
+ apply_theme(state.theme_name, state.themes)
1001
+ t_now = time.time()
1002
+
1003
+ # 1. Outer Border Frame
1004
+ stdscr.attron(curses.color_pair(8))
1005
+ for y_off in range(1, box_h - 1):
1006
+ stdscr.addstr(start_y + y_off, start_x, "│")
1007
+ stdscr.addstr(start_y + y_off, start_x + box_w - 1, "│")
1008
+ stdscr.addstr(start_y, start_x, "┌" + "─" * (box_w - 2) + "┐")
1009
+ stdscr.addstr(start_y + box_h - 1, start_x, "└" + "─" * (box_w - 2) + "┘")
1010
+ stdscr.attroff(curses.color_pair(8))
1011
+
1012
+ # Title (Zero emoticons, pure arrow glyph)
1013
+ title = " → LUSH AUDIOPHILE LISTENING DIARY & INSIGHTS "
1014
+ stdscr.addstr(start_y, start_x + max(1, (box_w - len(title)) // 2), title, curses.color_pair(1) | curses.A_BOLD)
1015
+
1016
+ # 2. Interactive Navigation Tab Bar
1017
+ tab_y = start_y + 1
1018
+ cur_x = start_x + 3
1019
+ for i, t_name in enumerate(tab_names):
1020
+ is_active = (i == cur_tab)
1021
+ t_str = f"[{t_name}]" if is_active else f" {t_name} "
1022
+ attr = (curses.color_pair(6) | curses.A_BOLD) if is_active else curses.color_pair(7)
1023
+ if cur_x + len(t_str) < start_x + box_w - 2:
1024
+ stdscr.addstr(tab_y, cur_x, t_str, attr)
1025
+ cur_x += len(t_str) + 1
1026
+
1027
+ # Divider under tabs
1028
+ stdscr.addstr(start_y + 2, start_x + 1, "├" + "─" * (box_w - 2) + "┤", curses.color_pair(8))
1029
+
1030
+ # Content Area Y
1031
+ c_y = start_y + 3
1032
+ inner_w = box_w - 4
1033
+
1034
+ # =========================================================================
1035
+ # TAB 0: GITHUB-STYLE 16-WEEK HEATMAP MATRIX
1036
+ # =========================================================================
1037
+ if cur_tab == 0:
1038
+ matrix, month_labels, streaks = state.stats.get_heatmap_matrix(num_weeks=16)
1039
+
1040
+ # Header Title
1041
+ stdscr.addstr(c_y, start_x + 3, "→ 4-MONTH LISTENING INTENSITY MATRIX (2026)", curses.color_pair(2) | curses.A_BOLD)
1042
+
1043
+ # Month Header Row
1044
+ m_y = c_y + 1
1045
+ m_start_x = start_x + 9
1046
+ for col_idx, m_name in month_labels:
1047
+ lbl_x = m_start_x + (col_idx * 4)
1048
+ if lbl_x < start_x + box_w - 6:
1049
+ stdscr.addstr(m_y, lbl_x, m_name, curses.color_pair(7) | curses.A_DIM)
1050
+
1051
+ # 7-Day Rows (Mon..Sun)
1052
+ day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
1053
+ for d_idx, d_name in enumerate(day_names):
1054
+ r_y = m_y + 1 + d_idx
1055
+ stdscr.addstr(r_y, start_x + 4, d_name, curses.color_pair(7))
1056
+
1057
+ for w_idx in range(len(matrix[d_idx])):
1058
+ cell = matrix[d_idx][w_idx]
1059
+ cell_x = m_start_x + (w_idx * 4)
1060
+ if cell_x >= start_x + box_w - 5: break
1061
+
1062
+ is_cursor = (w_idx == hm_w and d_idx == hm_d)
1063
+ intens = cell["intensity"]
1064
+
1065
+ if intens == -1: # future
1066
+ ch_str = " · "
1067
+ attr = curses.A_DIM
1068
+ elif intens == 0:
1069
+ ch_str = " · "
1070
+ attr = curses.color_pair(8) | curses.A_DIM
1071
+ elif intens == 1:
1072
+ ch_str = " ■ "
1073
+ attr = curses.color_pair(4)
1074
+ elif intens == 2:
1075
+ ch_str = " ■ "
1076
+ attr = curses.color_pair(2)
1077
+ elif intens == 3:
1078
+ ch_str = " ■ "
1079
+ attr = curses.color_pair(6)
1080
+ else: # 4 (30+)
1081
+ ch_str = " ■ "
1082
+ attr = curses.color_pair(1) | curses.A_BOLD
1083
+
1084
+ if is_cursor:
1085
+ stdscr.addstr(r_y, cell_x - 1, f"[{ch_str.strip()}]", curses.color_pair(1) | curses.A_REVERSE | curses.A_BOLD)
1086
+ else:
1087
+ stdscr.addstr(r_y, cell_x, ch_str, attr)
1088
+
1089
+ # Heatmap Legend
1090
+ leg_y = m_y + 9
1091
+ leg_str = "Intensity: Less · ■ ■ ■ ■ More Total: {:,} tracks scrobbed".format(state.stats.total_scrobbles)
1092
+ stdscr.addstr(leg_y, start_x + 4, leg_str, curses.color_pair(7))
1093
+
1094
+ # Day Inspector Card
1095
+ card_y = leg_y + 2
1096
+ stdscr.addstr(card_y, start_x + 2, "┌─[ Day Inspector ]" + "─" * (inner_w - 18) + "┐", curses.color_pair(8))
1097
+
1098
+ sel_cell = matrix[hm_d][hm_w] if (hm_d < len(matrix) and hm_w < len(matrix[hm_d])) else None
1099
+ if sel_cell:
1100
+ d_str = sel_cell["date"]
1101
+ c_count = sel_cell["count"]
1102
+ d_name = sel_cell["day_name"]
1103
+ today_mark = " (Today)" if sel_cell["is_today"] else ""
1104
+ info_line = f"Date: {d_str} ({d_name}){today_mark} • Scrobbles: {c_count} tracks • Est. Time: {c_count * 3.5:.1f} mins"
1105
+ stdscr.addstr(card_y + 1, start_x + 4, trim(info_line, inner_w - 4), curses.color_pair(2) | curses.A_BOLD)
1106
+
1107
+ streak_line = f"▲ Current Streak: {streaks['current_streak']} Days • ◈ Longest: {streaks['longest_streak']} Days • ◆ Total Active: {streaks['total_active_days']} Days"
1108
+ stdscr.addstr(card_y + 2, start_x + 4, trim(streak_line, inner_w - 4), curses.color_pair(6) | curses.A_BOLD)
1109
+ stdscr.addstr(card_y + 3, start_x + 2, "└" + "─" * (inner_w - 1) + "┘", curses.color_pair(8))
1110
+
1111
+ # =========================================================================
1112
+ # TAB 1: TOP CHARTS & LEADERBOARDS
1113
+ # =========================================================================
1114
+ elif cur_tab == 1:
1115
+ tf_labels = {"all": "[ All-Time ]", "month": "[ This Month ]", "today": "[ Today ]"}
1116
+ stdscr.addstr(c_y, start_x + 3, f"Timeframe: {tf_labels[chart_tf]} (Press 't' to toggle timeframe)", curses.color_pair(2) | curses.A_BOLD)
1117
+
1118
+ col_w = inner_w // 2
1119
+ top_art = state.stats.get_top_artists(10, timeframe=chart_tf)
1120
+ top_stn = state.stats.get_top_stations(10, timeframe=chart_tf)
1121
+
1122
+ # Left Col: Top Artists
1123
+ stdscr.addstr(c_y + 2, start_x + 3, "→ TOP 10 ARTISTS", curses.color_pair(6) | curses.A_BOLD)
1124
+ for idx, (art, count) in enumerate(top_art):
1125
+ r_y = c_y + 4 + idx
1126
+ if r_y >= start_y + box_h - 4: break
1127
+ pct = int((count / max(1, state.stats.total_scrobbles)) * 100)
1128
+ bar_len = min(14, max(1, int(pct * 0.3)))
1129
+ bar_str = "█" * bar_len
1130
+ line = f"{idx+1:>2}. {trim(art, col_w - 24):<16} {bar_str:<14} {count:>3} ({pct:>2}%)"
1131
+ attr = (curses.color_pair(1) | curses.A_BOLD) if (idx == chart_idx) else curses.A_NORMAL
1132
+ prefix = "→ " if (idx == chart_idx) else " "
1133
+ stdscr.addstr(r_y, start_x + 3, trim(prefix + line, col_w - 2), attr)
1134
+
1135
+ # Right Col: Top Stations
1136
+ stdscr.addstr(c_y + 2, start_x + col_w + 3, "→ TOP 10 STATIONS", curses.color_pair(6) | curses.A_BOLD)
1137
+ for idx, (st, count) in enumerate(top_stn):
1138
+ r_y = c_y + 4 + idx
1139
+ if r_y >= start_y + box_h - 4: break
1140
+ pct = int((count / max(1, state.stats.total_scrobbles)) * 100)
1141
+ bar_len = min(14, max(1, int(pct * 0.3)))
1142
+ bar_str = "█" * bar_len
1143
+ line = f" {idx+1:>2}. {trim(st, col_w - 24):<16} {bar_str:<14} {count:>3} ({pct:>2}%)"
1144
+ stdscr.addstr(r_y, start_x + col_w + 3, trim(line, col_w - 2), curses.A_NORMAL)
1145
+
1146
+ hint_y = start_y + box_h - 3
1147
+ stdscr.addstr(hint_y, start_x + 4, "→ Press ENTER on highlighted artist to tune in immediately!", curses.color_pair(7) | curses.A_DIM)
1148
+
1149
+ # =========================================================================
1150
+ # TAB 2: SONIC HABITS & 24-HOUR RHYTHM
1151
+ # =========================================================================
1152
+ elif cur_tab == 2:
1153
+ rhythm = state.stats.get_hourly_rhythm()
1154
+ stdscr.addstr(c_y, start_x + 3, "→ 24-HOUR LISTENING RHYTHM (When do you tune in?)", curses.color_pair(2) | curses.A_BOLD)
1155
+
1156
+ # 24-Hour Histogram
1157
+ counts = rhythm["counts"]
1158
+ max_c = max(counts) or 1
1159
+ bar_chars = [" ", " ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
1160
+
1161
+ hours_line = " ".join(f"{h:02d}" for h in range(0, 24, 2))
1162
+ hist_line = " ".join(bar_chars[min(8, int((counts[h] / max_c) * 8))] for h in range(0, 24, 2))
1163
+
1164
+ stdscr.addstr(c_y + 2, start_x + 4, hist_line, curses.color_pair(6) | curses.A_BOLD)
1165
+ stdscr.addstr(c_y + 3, start_x + 4, hours_line, curses.color_pair(7) | curses.A_DIM)
1166
+
1167
+ # Sonic Profile Badge
1168
+ stdscr.addstr(c_y + 5, start_x + 3, f"→ Sonic Profile: {rhythm['persona']}", curses.color_pair(1) | curses.A_BOLD)
1169
+ stdscr.addstr(c_y + 6, start_x + 6, trim(rhythm["desc"], inner_w - 8), curses.color_pair(7))
1170
+
1171
+ # Split Section Divider
1172
+ stdscr.addstr(c_y + 8, start_x + 2, "├" + "─" * (inner_w) + "┤", curses.color_pair(8))
1173
+
1174
+ col_w = inner_w // 2
1175
+ # Left: Genre Breakdown
1176
+ stdscr.addstr(c_y + 10, start_x + 3, "→ GENRE BREAKDOWN", curses.color_pair(2) | curses.A_BOLD)
1177
+ for idx, (g, count, pct) in enumerate(state.stats.get_top_genres(5)):
1178
+ r_y = c_y + 11 + idx
1179
+ if r_y >= start_y + box_h - 3: break
1180
+ bar_len = min(14, max(1, int(pct * 0.3)))
1181
+ bar_str = "█" * bar_len
1182
+ line = f"• {trim(g, col_w - 24):<16} {bar_str:<14} {pct:>2}%"
1183
+ stdscr.addstr(r_y, start_x + 4, trim(line, col_w - 4), curses.A_NORMAL)
1184
+
1185
+ # Right: Earned Badges
1186
+ stdscr.addstr(c_y + 10, start_x + col_w + 3, "→ EARNED AUDIOPHILE BADGES", curses.color_pair(2) | curses.A_BOLD)
1187
+ for idx, (ico, name, desc) in enumerate(state.stats.get_earned_badges(state)[:5]):
1188
+ r_y = c_y + 11 + idx
1189
+ if r_y >= start_y + box_h - 3: break
1190
+ line = f"{ico} {name:<18} : {trim(desc, col_w - 24)}"
1191
+ stdscr.addstr(r_y, start_x + col_w + 4, trim(line, col_w - 4), curses.color_pair(6))
1192
+
1193
+ # =========================================================================
1194
+ # TAB 3: AUDIOPHILE VAULT (FLAC Master Recordings)
1195
+ # =========================================================================
1196
+ elif cur_tab == 3:
1197
+ vault = state.stats.get_flac_vault()
1198
+ files = vault["files"]
1199
+ max_f_rows = max(1, box_h - 9)
1200
+
1201
+ # Smooth scrolling logic
1202
+ vault_idx = max(0, min(vault_idx, len(files) - 1)) if files else 0
1203
+ if vault_idx < vault_scroll:
1204
+ vault_scroll = vault_idx
1205
+ elif vault_idx >= vault_scroll + max_f_rows:
1206
+ vault_scroll = vault_idx - max_f_rows + 1
1207
+
1208
+ pos_info = f"[{vault_idx+1}/{len(files)}]" if files else "[0/0]"
1209
+ v_hdr = f"Archive: ~/Music/Recordings/ • Total: {vault['total_files']} Files ({vault['total_mb']:.2f} MB) {pos_info}"
1210
+ stdscr.addstr(c_y, start_x + 3, v_hdr, curses.color_pair(2) | curses.A_BOLD)
1211
+
1212
+ # Column Headers
1213
+ stdscr.addstr(c_y + 2, start_x + 3, f"{'#':<3} {'RECORDING FILE NAME':<38} {'DATE':<17} {'FORMAT':<12} {'SIZE'}", curses.color_pair(7) | curses.A_BOLD)
1214
+
1215
+ if not files:
1216
+ stdscr.addstr(c_y + 5, start_x + 4, "No recordings found in ~/Music/Recordings/ yet.", curses.A_DIM)
1217
+ stdscr.addstr(c_y + 6, start_x + 4, "Press 'R' (Shift+R) while playing any station to capture bit-perfect 24-bit FLAC!", curses.color_pair(6))
1218
+ else:
1219
+ visible_files = files[vault_scroll : vault_scroll + max_f_rows]
1220
+ for offset_idx, f_info in enumerate(visible_files):
1221
+ actual_idx = vault_scroll + offset_idx
1222
+ r_y = c_y + 3 + offset_idx
1223
+ is_sel = (actual_idx == vault_idx)
1224
+ prefix = "→ " if is_sel else " "
1225
+ f_name = trim(f_info["name"], 38)
1226
+ line = f"{prefix}{actual_idx+1:>2} {f_name:<38} {f_info['date']:<17} {f_info['format']:<12} {f_info['size_mb']:>5.2f} MB"
1227
+ attr = (curses.color_pair(1) | curses.A_BOLD) if is_sel else curses.A_NORMAL
1228
+ stdscr.addstr(r_y, start_x + 3, trim(line, inner_w - 2), attr)
1229
+
1230
+ hint_y = start_y + box_h - 3
1231
+ stdscr.addstr(hint_y, start_x + 4, "→ ENTER: Play recording • d: Delete recording • o: Open folder", curses.color_pair(7) | curses.A_DIM)
1232
+
1233
+ # =========================================================================
1234
+ # TAB 4: LISTENING DIARY LOG (Live Scrobbler History)
1235
+ # =========================================================================
1236
+ elif cur_tab == 4:
1237
+ events = list(reversed(state.stats.recent_events))
1238
+ max_d_rows = max(1, box_h - 9)
1239
+
1240
+ # Smooth scrolling logic: keeps focus locked on screen
1241
+ diary_idx = max(0, min(diary_idx, len(events) - 1)) if events else 0
1242
+ if diary_idx < diary_scroll:
1243
+ diary_scroll = diary_idx
1244
+ elif diary_idx >= diary_scroll + max_d_rows:
1245
+ diary_scroll = diary_idx - max_d_rows + 1
1246
+
1247
+ pos_info = f"[{diary_idx+1}/{len(events)}]" if events else "[0/0]"
1248
+ stdscr.addstr(c_y, start_x + 3, f"→ RECENT SCROBBLES DIARY (Total: {len(events)} logged) {pos_info}", curses.color_pair(2) | curses.A_BOLD)
1249
+
1250
+ # Column Headers
1251
+ stdscr.addstr(c_y + 2, start_x + 3, f"{'#':<3} {'TIME':<6} {'TRACK TITLE / ARTIST':<38} {'STATION':<22} {'GENRE'}", curses.color_pair(7) | curses.A_BOLD)
1252
+
1253
+ if not events:
1254
+ stdscr.addstr(c_y + 5, start_x + 4, "No recent playback events recorded yet.", curses.A_DIM)
1255
+ else:
1256
+ visible_events = events[diary_scroll : diary_scroll + max_d_rows]
1257
+ for offset_idx, ev in enumerate(visible_events):
1258
+ actual_idx = diary_scroll + offset_idx
1259
+ r_y = c_y + 3 + offset_idx
1260
+ is_sel = (actual_idx == diary_idx)
1261
+ prefix = "→ " if is_sel else " "
1262
+ tr_name = trim(ev.get("track", ""), 38)
1263
+ st_name = trim(ev.get("station", ""), 22)
1264
+ gn_name = trim(ev.get("genre", ""), 16)
1265
+ line = f"{prefix}{actual_idx+1:>2} {ev.get('time', ''):<6} {tr_name:<38} {st_name:<22} {gn_name}"
1266
+ attr = (curses.color_pair(1) | curses.A_BOLD) if is_sel else curses.A_NORMAL
1267
+ stdscr.addstr(r_y, start_x + 3, trim(line, inner_w - 2), attr)
1268
+
1269
+ hint_y = start_y + box_h - 3
1270
+ stdscr.addstr(hint_y, start_x + 4, "→ b: Fav station • ENTER: Replay track station • ↑/↓: Scroll list", curses.color_pair(7) | curses.A_DIM)
1271
+
1272
+ # 3. Action Footer Bar
1273
+ footer = " TAB:Cycle Views (1-5) x:Export Report r:Refresh q/ESC:Close "
1274
+ f_y = start_y + box_h - 1
1275
+ stdscr.addstr(f_y, start_x + max(1, (box_w - len(footer)) // 2), footer, curses.A_DIM)
1276
+
1277
+ stdscr.refresh()
1278
+ try:
1279
+ ch = stdscr.getch()
1280
+ except Exception:
1281
+ ch = -1
1282
+
1283
+ # Global Hotkeys
1284
+ if ch in (27, ord('q'), ord('Q')):
1285
+ break
1286
+ elif ch in (9,): # TAB
1287
+ cur_tab = (cur_tab + 1) % len(tab_names)
1288
+ elif ord('1') <= ch <= ord('5'):
1289
+ cur_tab = ch - ord('1')
1290
+ elif ch in (ord('x'), ord('X')):
1291
+ p = state.stats.export_report()
1292
+ state.set_toast(f"Exported: {p.name}")
1293
+ elif ch in (ord('r'), ord('R')):
1294
+ state.stats._load_or_backfill()
1295
+ state.stats.get_flac_vault(force=True)
1296
+ state.stats.get_heatmap_matrix(force=True)
1297
+ state.stats.get_hourly_rhythm(force=True)
1298
+ state.set_toast("Refreshed listening statistics!")
1299
+
1300
+ # Tab-Specific Navigation
1301
+ elif cur_tab == 0: # Heatmap navigation
1302
+ matrix, _, _ = state.stats.get_heatmap_matrix(16)
1303
+ if ch in (curses.KEY_LEFT, ord('h'), ord('H')):
1304
+ hm_w = max(0, hm_w - 1)
1305
+ elif ch in (curses.KEY_RIGHT, ord('l'), ord('L')):
1306
+ hm_w = min(15, hm_w + 1)
1307
+ elif ch in (curses.KEY_UP, ord('k'), ord('K')):
1308
+ hm_d = max(0, hm_d - 1)
1309
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
1310
+ hm_d = min(6, hm_d + 1)
1311
+
1312
+ elif cur_tab == 1: # Top Charts
1313
+ top_art = state.stats.get_top_artists(10, chart_tf)
1314
+ if ch in (ord('t'), ord('T')):
1315
+ tfs = ["all", "month", "today"]
1316
+ chart_tf = tfs[(tfs.index(chart_tf) + 1) % len(tfs)]
1317
+ chart_idx = 0
1318
+ elif ch in (curses.KEY_UP, ord('k'), ord('K')):
1319
+ chart_idx = max(0, chart_idx - 1)
1320
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
1321
+ chart_idx = min(len(top_art) - 1, chart_idx + 1)
1322
+ elif ch in (10, 13, curses.KEY_ENTER):
1323
+ if top_art and chart_idx < len(top_art):
1324
+ sel_art = top_art[chart_idx][0]
1325
+ # Find matching station
1326
+ match_idx = next((i for i, s in enumerate(state.stations) if sel_art.lower() in s["name"].lower()), None)
1327
+ if match_idx is not None:
1328
+ state.play_station(match_idx)
1329
+ state.set_toast(f"Tuning into: {state.stations[match_idx]['name']}")
1330
+ break
1331
+
1332
+ elif cur_tab == 3: # Vault
1333
+ vault = state.stats.get_flac_vault()
1334
+ files = vault["files"]
1335
+ if ch in (curses.KEY_UP, ord('k'), ord('K')):
1336
+ vault_idx = max(0, vault_idx - 1)
1337
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
1338
+ vault_idx = min(len(files) - 1, vault_idx + 1)
1339
+ elif ch in (10, 13, curses.KEY_ENTER):
1340
+ if files and vault_idx < len(files):
1341
+ target_file = files[vault_idx]["path"]
1342
+ state.audio.play_station(str(target_file))
1343
+ state.current_track = f"Playback: {target_file.name}"
1344
+ state.set_toast(f"▶ Playing FLAC: {target_file.name}")
1345
+ break
1346
+ elif ch in (ord('d'), ord('D')):
1347
+ if files and vault_idx < len(files):
1348
+ f_to_del = files[vault_idx]["path"]
1349
+ try:
1350
+ f_to_del.unlink()
1351
+ state.stats.get_flac_vault(force=True)
1352
+ state.set_toast(f"Deleted: {f_to_del.name}")
1353
+ except Exception as e:
1354
+ state.set_toast(f"Delete error: {e}")
1355
+
1356
+ elif cur_tab == 4: # Diary Log
1357
+ events = list(reversed(state.stats.recent_events))
1358
+ if ch in (curses.KEY_UP, ord('k'), ord('K')):
1359
+ diary_idx = max(0, diary_idx - 1)
1360
+ elif ch in (curses.KEY_DOWN, ord('j'), ord('J')):
1361
+ diary_idx = min(len(events) - 1, diary_idx + 1)
1362
+ elif ch in (ord('b'), ord('B')):
1363
+ if events and diary_idx < len(events):
1364
+ st_name = events[diary_idx].get("station", "")
1365
+ if st_name:
1366
+ if st_name in state.favorites:
1367
+ state.favorites.remove(st_name)
1368
+ state.set_toast(f"Removed from Favs: {st_name}")
1369
+ else:
1370
+ state.favorites.add(st_name)
1371
+ state.set_toast(f"Added to Favs: {st_name}")
1372
+ save_favorites(state.favorites)
1373
+ elif ch in (10, 13, curses.KEY_ENTER):
1374
+ if events and diary_idx < len(events):
1375
+ st_name = events[diary_idx].get("station", "")
1376
+ match_idx = next((i for i, s in enumerate(state.stations) if st_name.lower() in s["name"].lower()), None)
1377
+ if match_idx is not None:
1378
+ state.play_station(match_idx)
1379
+ state.set_toast(f"Tuning into: {state.stations[match_idx]['name']}")
1380
+ break
1381
+
1382
+ stdscr.timeout(0)