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