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/LICENSE +21 -0
- package/README.md +104 -0
- package/bin/lush +62 -0
- package/bin/lush.js +48 -0
- package/package.json +61 -0
- package/pyproject.toml +52 -0
- package/scripts/postinstall.js +58 -0
- package/scripts/sync-version.js +38 -0
- package/setup.py +23 -0
- package/src/lush/__init__.py +30 -0
- package/src/lush/__main__.py +42 -0
- package/src/lush/audio.py +222 -0
- package/src/lush/cava.py +665 -0
- package/src/lush/constants.py +686 -0
- package/src/lush/data/cava.conf +19 -0
- package/src/lush/data/stations.json +3586 -0
- package/src/lush/modals.py +1382 -0
- package/src/lush/net.py +27 -0
- package/src/lush/notify.py +154 -0
- package/src/lush/search.py +185 -0
- package/src/lush/state.py +550 -0
- package/src/lush/stats.py +555 -0
- package/src/lush/ui.py +690 -0
- package/src/lush/ui_helpers.py +569 -0
|
@@ -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)
|