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,550 @@
|
|
|
1
|
+
# LUSH - State and Storage Management
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from .constants import DEFAULT_THEMES, SHIMMER_EFFECTS, SHIMMER_EFFECT_LABELS, ALL_SIDEBAR_ITEMS, VISUALIZER_MODES, VISUALIZER_LABELS, AMBIENT_PRESETS
|
|
8
|
+
from .audio import AudioEngine
|
|
9
|
+
from .cava import CavaVisualizerEngine
|
|
10
|
+
from .net import get_net_bytes, format_speed
|
|
11
|
+
from .stats import StatsEngine
|
|
12
|
+
from .notify import NotificationManager
|
|
13
|
+
from .search import fuzzy_filter_stations
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "lush"
|
|
16
|
+
STATIONS_FILE = CONFIG_DIR / "stations.json"
|
|
17
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
18
|
+
THEMES_FILE = CONFIG_DIR / "themes.json"
|
|
19
|
+
FAVORITES_FILE = CONFIG_DIR / "favorites.json"
|
|
20
|
+
HISTORY_FILE = CONFIG_DIR / "history.json"
|
|
21
|
+
STATS_FILE = CONFIG_DIR / "stats.json"
|
|
22
|
+
|
|
23
|
+
def load_history():
|
|
24
|
+
try:
|
|
25
|
+
if HISTORY_FILE.exists():
|
|
26
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
27
|
+
data = json.load(f)
|
|
28
|
+
if isinstance(data, list):
|
|
29
|
+
return data
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
def save_history(history):
|
|
35
|
+
try:
|
|
36
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
tmp_file = HISTORY_FILE.with_suffix(".tmp")
|
|
38
|
+
with open(tmp_file, "w", encoding="utf-8") as f:
|
|
39
|
+
json.dump(history[:150], f, ensure_ascii=False, indent=2)
|
|
40
|
+
tmp_file.replace(HISTORY_FILE)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
CAVA_CFG_FILE = CONFIG_DIR / "cava.conf"
|
|
45
|
+
RECORDINGS_DIR = Path.home() / "Music" / "Recordings"
|
|
46
|
+
|
|
47
|
+
def get_bundled_stations():
|
|
48
|
+
try:
|
|
49
|
+
bundled_path = Path(__file__).parent / "data" / "stations.json"
|
|
50
|
+
if bundled_path.exists():
|
|
51
|
+
with open(bundled_path, "r", encoding="utf-8") as f:
|
|
52
|
+
data = json.load(f)
|
|
53
|
+
if isinstance(data, list) and data:
|
|
54
|
+
return data
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
DEFAULT_STATIONS = get_bundled_stations()
|
|
60
|
+
|
|
61
|
+
def load_stations():
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
bundled = get_bundled_stations()
|
|
64
|
+
try:
|
|
65
|
+
if STATIONS_FILE.exists():
|
|
66
|
+
with open(STATIONS_FILE, "r", encoding="utf-8") as f:
|
|
67
|
+
data = json.load(f)
|
|
68
|
+
if isinstance(data, list) and len(data) >= 100:
|
|
69
|
+
return data
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
if bundled:
|
|
73
|
+
save_stations(bundled)
|
|
74
|
+
return bundled
|
|
75
|
+
return DEFAULT_STATIONS
|
|
76
|
+
|
|
77
|
+
def save_stations(stations):
|
|
78
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
with open(STATIONS_FILE, "w", encoding="utf-8") as f:
|
|
80
|
+
json.dump(stations, f, ensure_ascii=False, indent=2)
|
|
81
|
+
|
|
82
|
+
def load_favorites():
|
|
83
|
+
try:
|
|
84
|
+
if FAVORITES_FILE.exists():
|
|
85
|
+
with open(FAVORITES_FILE, "r", encoding="utf-8") as f:
|
|
86
|
+
data = json.load(f)
|
|
87
|
+
if isinstance(data, list):
|
|
88
|
+
return set(data)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return set()
|
|
92
|
+
|
|
93
|
+
def save_favorites(favs):
|
|
94
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with open(FAVORITES_FILE, "w", encoding="utf-8") as f:
|
|
96
|
+
json.dump(list(favs), f, ensure_ascii=False, indent=2)
|
|
97
|
+
|
|
98
|
+
def load_config():
|
|
99
|
+
try:
|
|
100
|
+
if CONFIG_FILE.exists():
|
|
101
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
102
|
+
data = json.load(f)
|
|
103
|
+
if isinstance(data, dict):
|
|
104
|
+
return data
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return {
|
|
108
|
+
"theme": "Plur1bus",
|
|
109
|
+
"section": "top_artists",
|
|
110
|
+
"visualizer": "spectrum",
|
|
111
|
+
"ambient_preset": "none",
|
|
112
|
+
"ambient_volume": 40,
|
|
113
|
+
"cava_sensitivity": 180,
|
|
114
|
+
"master_volume": 100,
|
|
115
|
+
"nsfw_enabled": False
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def save_config(cfg):
|
|
119
|
+
try:
|
|
120
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
cur = load_config()
|
|
122
|
+
cur.update(cfg)
|
|
123
|
+
tmp_file = CONFIG_FILE.with_suffix(".tmp")
|
|
124
|
+
with open(tmp_file, "w", encoding="utf-8") as f:
|
|
125
|
+
json.dump(cur, f, ensure_ascii=False, indent=2)
|
|
126
|
+
tmp_file.replace(CONFIG_FILE)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def load_themes():
|
|
131
|
+
themes = dict(DEFAULT_THEMES)
|
|
132
|
+
try:
|
|
133
|
+
if THEMES_FILE.exists():
|
|
134
|
+
with open(THEMES_FILE, "r", encoding="utf-8") as f:
|
|
135
|
+
custom = json.load(f)
|
|
136
|
+
if isinstance(custom, dict):
|
|
137
|
+
themes.update(custom)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return themes
|
|
141
|
+
|
|
142
|
+
class PlayerState:
|
|
143
|
+
def __init__(self):
|
|
144
|
+
self.stations = load_stations()
|
|
145
|
+
self.favorites = load_favorites()
|
|
146
|
+
self.themes = load_themes()
|
|
147
|
+
cfg = load_config()
|
|
148
|
+
|
|
149
|
+
self.theme_name = cfg.get("theme", "Plur1bus")
|
|
150
|
+
if self.theme_name not in self.themes:
|
|
151
|
+
self.theme_name = "Plur1bus" if "Plur1bus" in self.themes else list(self.themes.keys())[0]
|
|
152
|
+
|
|
153
|
+
self.section_key = cfg.get("section", "top_artists")
|
|
154
|
+
if self.section_key in ("all", "lush"): self.section_key = "top_artists"
|
|
155
|
+
keys = [it["key"] for it in ALL_SIDEBAR_ITEMS]
|
|
156
|
+
if self.section_key not in keys:
|
|
157
|
+
self.section_key = "all"
|
|
158
|
+
|
|
159
|
+
self.sidebar_idx = keys.index(self.section_key)
|
|
160
|
+
self.region = "content"
|
|
161
|
+
self.search_active = False
|
|
162
|
+
self.search_query = ""
|
|
163
|
+
|
|
164
|
+
# Visualizer & Ambient
|
|
165
|
+
self.visualizer_mode = cfg.get("visualizer", "shimmer_string")
|
|
166
|
+
if self.visualizer_mode not in VISUALIZER_MODES:
|
|
167
|
+
self.visualizer_mode = "shimmer_string"
|
|
168
|
+
|
|
169
|
+
self.ambient_preset = cfg.get("ambient_preset", "none")
|
|
170
|
+
self.ambient_volume = cfg.get("ambient_volume", 40)
|
|
171
|
+
self.cava_sensitivity = cfg.get("cava_sensitivity", 180)
|
|
172
|
+
self.volume = cfg.get("master_volume", 100)
|
|
173
|
+
self.nsfw_enabled = cfg.get("nsfw_enabled", False)
|
|
174
|
+
self.headphone_warned_session = False
|
|
175
|
+
self.config = cfg
|
|
176
|
+
self.show_welcome = cfg.get("show_welcome", True)
|
|
177
|
+
self.deck_style = cfg.get("deck_style", "vinyl")
|
|
178
|
+
self.framerate = cfg.get("framerate", 144)
|
|
179
|
+
self.shimmer_effect = cfg.get("shimmer_effect", "cava_sync")
|
|
180
|
+
if self.shimmer_effect not in SHIMMER_EFFECTS: self.shimmer_effect = "cava_sync"
|
|
181
|
+
|
|
182
|
+
# Network and Bitrate
|
|
183
|
+
self.net_down_str = "↓ 0 B/s"
|
|
184
|
+
self.net_up_str = "↑ 0 B/s"
|
|
185
|
+
self.stream_bitrate = "128 kbps"
|
|
186
|
+
|
|
187
|
+
# Station selection: resume last station or pick random trending/legend artist on first launch
|
|
188
|
+
last_url = cfg.get("last_station_url")
|
|
189
|
+
last_name = cfg.get("last_station_name")
|
|
190
|
+
|
|
191
|
+
target_idx = None
|
|
192
|
+
if last_url:
|
|
193
|
+
for i, s in enumerate(self.stations):
|
|
194
|
+
if s.get("url") == last_url:
|
|
195
|
+
target_idx = i
|
|
196
|
+
break
|
|
197
|
+
if target_idx is None and last_name:
|
|
198
|
+
for i, s in enumerate(self.stations):
|
|
199
|
+
if s.get("name") == last_name:
|
|
200
|
+
target_idx = i
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
# First-time user boot experience: pick random trending or legends artist
|
|
204
|
+
if target_idx is None:
|
|
205
|
+
import random
|
|
206
|
+
popular_keywords = [
|
|
207
|
+
"taylor swift", "billie eilish", "olivia rodrigo", "the weeknd", "britney",
|
|
208
|
+
"daft punk", "lana del rey", "dua lipa", "queen", "michael jackson",
|
|
209
|
+
"eminem", "shakira", "justin timberlake", "timbaland", "nelly", "usher",
|
|
210
|
+
"ariana grande", "harry styles", "rihanna", "lady gaga", "k-pop", "sabrina carpenter",
|
|
211
|
+
"charli xcx", "post malone", "drake", "sza", "pink floyd", "david bowie"
|
|
212
|
+
]
|
|
213
|
+
candidates = [
|
|
214
|
+
i for i, s in enumerate(self.stations)
|
|
215
|
+
if (s.get("category") == "music" or "exclusive.radio" in s.get("url", "").lower())
|
|
216
|
+
and any(k in s.get("name", "").lower() for k in popular_keywords)
|
|
217
|
+
]
|
|
218
|
+
if candidates:
|
|
219
|
+
target_idx = random.choice(candidates)
|
|
220
|
+
else:
|
|
221
|
+
target_idx = 0
|
|
222
|
+
|
|
223
|
+
self.idx = target_idx
|
|
224
|
+
self.cursor_idx = target_idx
|
|
225
|
+
self.current_track = self.stations[self.idx]["name"]
|
|
226
|
+
self.history = load_history()
|
|
227
|
+
self.playing = True
|
|
228
|
+
self.quit = False
|
|
229
|
+
self.toast_msg = ""
|
|
230
|
+
self.toast_time = 0
|
|
231
|
+
self.lock = threading.RLock()
|
|
232
|
+
|
|
233
|
+
# SSD Wear Reduction: In-Memory Dirty Tracking
|
|
234
|
+
self._cfg_dirty = False
|
|
235
|
+
self._last_cfg_save = 0.0
|
|
236
|
+
|
|
237
|
+
# Subsystems
|
|
238
|
+
self.audio = AudioEngine(RECORDINGS_DIR)
|
|
239
|
+
self.cava = CavaVisualizerEngine(CAVA_CFG_FILE, sensitivity=self.cava_sensitivity, framerate=self.framerate)
|
|
240
|
+
self.stats = StatsEngine(STATS_FILE, HISTORY_FILE, RECORDINGS_DIR)
|
|
241
|
+
self.notify_mgr = NotificationManager(enabled=cfg.get("notifications_enabled", True))
|
|
242
|
+
|
|
243
|
+
# Initial playback
|
|
244
|
+
self.audio.set_volume(self.volume)
|
|
245
|
+
self.audio.play_station(self.stations[self.idx]["url"])
|
|
246
|
+
if self.ambient_preset != "none":
|
|
247
|
+
self.start_ambient_preset(self.ambient_preset, notify=False)
|
|
248
|
+
|
|
249
|
+
def add_history_entry(self, track_title, station_name, station_url, genre):
|
|
250
|
+
if not track_title:
|
|
251
|
+
return
|
|
252
|
+
entry = {
|
|
253
|
+
"name": track_title,
|
|
254
|
+
"station": station_name,
|
|
255
|
+
"genre": genre,
|
|
256
|
+
"url": station_url,
|
|
257
|
+
"time": time.strftime("%H:%M"),
|
|
258
|
+
"date": time.strftime("%Y-%m-%d"),
|
|
259
|
+
"timestamp": time.time()
|
|
260
|
+
}
|
|
261
|
+
if self.history and self.history[0].get("name") == track_title:
|
|
262
|
+
return
|
|
263
|
+
self.history.insert(0, entry)
|
|
264
|
+
if len(self.history) > 150:
|
|
265
|
+
self.history = self.history[:150]
|
|
266
|
+
save_history(self.history)
|
|
267
|
+
if hasattr(self, "stats") and self.stats:
|
|
268
|
+
self.stats.record_track(track_title, station_name, genre, station_url)
|
|
269
|
+
|
|
270
|
+
def get_current_sidebar_item(self):
|
|
271
|
+
for it in ALL_SIDEBAR_ITEMS:
|
|
272
|
+
if it["key"] == self.section_key:
|
|
273
|
+
return it
|
|
274
|
+
return ALL_SIDEBAR_ITEMS[0]
|
|
275
|
+
|
|
276
|
+
def get_filtered_stations_unlocked(self):
|
|
277
|
+
cur_item = self.get_current_sidebar_item()
|
|
278
|
+
k = cur_item["key"]
|
|
279
|
+
|
|
280
|
+
if k == "bookmarks":
|
|
281
|
+
filtered = [(i, s) for i, s in enumerate(self.stations) if s["name"] in self.favorites]
|
|
282
|
+
elif k == "playing":
|
|
283
|
+
filtered = [(self.idx, self.stations[self.idx])]
|
|
284
|
+
elif k == "history":
|
|
285
|
+
filtered = [(i, h) for i, h in enumerate(self.history)]
|
|
286
|
+
elif k == "lush":
|
|
287
|
+
if not self.nsfw_enabled:
|
|
288
|
+
filtered = []
|
|
289
|
+
else:
|
|
290
|
+
filtered = [(i, s) for i, s in enumerate(self.stations) if cur_item.get("match", lambda x: True)(s)]
|
|
291
|
+
elif "match" in cur_item:
|
|
292
|
+
match_fn = cur_item["match"]
|
|
293
|
+
filtered = [(i, s) for i, s in enumerate(self.stations) if match_fn(s)]
|
|
294
|
+
if "sort_key" in cur_item:
|
|
295
|
+
sort_fn = cur_item["sort_key"]
|
|
296
|
+
filtered.sort(key=lambda item: sort_fn(item[1]))
|
|
297
|
+
else:
|
|
298
|
+
filtered = [(i, s) for i, s in enumerate(self.stations)]
|
|
299
|
+
|
|
300
|
+
# Strictly filter out 18+ / sensual / ASMR content when 18+ is disabled
|
|
301
|
+
if not self.nsfw_enabled:
|
|
302
|
+
filtered = [(i, s) for i, s in filtered if not (
|
|
303
|
+
s.get("nsfw") is True or
|
|
304
|
+
s.get("category") == "lush" or
|
|
305
|
+
any(sens in s.get("genre", "").lower() for sens in ["sensual", "erotic", "adult", "18+", "nsfw"])
|
|
306
|
+
)]
|
|
307
|
+
|
|
308
|
+
if self.search_query:
|
|
309
|
+
matched = fuzzy_filter_stations(filtered, self.search_query)
|
|
310
|
+
if not matched and k not in ("playing", "history"):
|
|
311
|
+
# Fallback to search all available stations
|
|
312
|
+
all_indexed = [(i, s) for i, s in enumerate(self.stations)]
|
|
313
|
+
if not self.nsfw_enabled:
|
|
314
|
+
all_indexed = [(i, s) for i, s in all_indexed if not (
|
|
315
|
+
s.get("nsfw") is True or
|
|
316
|
+
s.get("category") == "lush" or
|
|
317
|
+
any(sens in s.get("genre", "").lower() for sens in ["sensual", "erotic", "adult", "18+", "nsfw"])
|
|
318
|
+
)]
|
|
319
|
+
matched = fuzzy_filter_stations(all_indexed, self.search_query)
|
|
320
|
+
filtered = matched
|
|
321
|
+
|
|
322
|
+
return filtered
|
|
323
|
+
|
|
324
|
+
def get_filtered_stations(self):
|
|
325
|
+
with self.lock:
|
|
326
|
+
return self.get_filtered_stations_unlocked()
|
|
327
|
+
|
|
328
|
+
def play_station(self, global_idx):
|
|
329
|
+
with self.lock:
|
|
330
|
+
if global_idx < 0 or global_idx >= len(self.stations): return
|
|
331
|
+
self.idx = global_idx
|
|
332
|
+
st = self.stations[self.idx]
|
|
333
|
+
self.current_track = st["name"]
|
|
334
|
+
self.add_history_entry(st["name"], st["name"], st["url"], st.get("genre", "Unknown"))
|
|
335
|
+
self.audio.play_station(st["url"])
|
|
336
|
+
self.playing = True
|
|
337
|
+
self.save_current_config()
|
|
338
|
+
self.set_toast(f"▶ Tuning into: {st['name']}")
|
|
339
|
+
|
|
340
|
+
def toggle_play(self):
|
|
341
|
+
with self.lock:
|
|
342
|
+
self.playing = self.audio.toggle_play(self.playing)
|
|
343
|
+
if self.playing:
|
|
344
|
+
self.set_toast("▶ Resumed")
|
|
345
|
+
else:
|
|
346
|
+
self.set_toast("→ PAUSED")
|
|
347
|
+
|
|
348
|
+
def adjust_volume(self, delta):
|
|
349
|
+
with self.lock:
|
|
350
|
+
new_v = max(0, min(100, int(self.volume + delta)))
|
|
351
|
+
self.volume = new_v
|
|
352
|
+
self.audio.set_volume(new_v)
|
|
353
|
+
save_config({"master_volume": new_v})
|
|
354
|
+
self.set_toast(f"Volume: {new_v}%")
|
|
355
|
+
|
|
356
|
+
def cycle_visualizer(self):
|
|
357
|
+
with self.lock:
|
|
358
|
+
cur_i = VISUALIZER_MODES.index(self.visualizer_mode) if self.visualizer_mode in VISUALIZER_MODES else 0
|
|
359
|
+
self.visualizer_mode = VISUALIZER_MODES[(cur_i + 1) % len(VISUALIZER_MODES)]
|
|
360
|
+
self.save_current_config()
|
|
361
|
+
self.set_toast(f"Visualizer: {VISUALIZER_LABELS[self.visualizer_mode]}")
|
|
362
|
+
|
|
363
|
+
def start_ambient_preset(self, preset_id, notify=True):
|
|
364
|
+
preset = next((p for p in AMBIENT_PRESETS if p["id"] == preset_id), None)
|
|
365
|
+
if not preset: return
|
|
366
|
+
self.ambient_preset = preset_id
|
|
367
|
+
save_config({"ambient_preset": preset_id, "ambient_volume": self.ambient_volume})
|
|
368
|
+
self.audio.play_ambient(preset["url"], self.ambient_volume)
|
|
369
|
+
if notify:
|
|
370
|
+
if not preset["url"]:
|
|
371
|
+
self.set_toast("Ambient FX: Muted")
|
|
372
|
+
else:
|
|
373
|
+
self.set_toast(f"Ambient FX: {preset['name']}")
|
|
374
|
+
|
|
375
|
+
def toggle_ambient(self):
|
|
376
|
+
with self.lock:
|
|
377
|
+
if self.ambient_preset != "none":
|
|
378
|
+
self.start_ambient_preset("none")
|
|
379
|
+
else:
|
|
380
|
+
self.start_ambient_preset("deep_rain")
|
|
381
|
+
|
|
382
|
+
def adjust_ambient_volume(self, delta):
|
|
383
|
+
with self.lock:
|
|
384
|
+
new_v = max(0, min(100, int(self.ambient_volume + delta)))
|
|
385
|
+
self.ambient_volume = new_v
|
|
386
|
+
self.audio.set_ambient_volume(new_v)
|
|
387
|
+
save_config({"ambient_volume": new_v})
|
|
388
|
+
self.set_toast(f"Ambient Vol: {new_v}%")
|
|
389
|
+
|
|
390
|
+
def toggle_recording(self):
|
|
391
|
+
with self.lock:
|
|
392
|
+
st = self.stations[self.idx]
|
|
393
|
+
ok, msg, path = self.audio.toggle_recording(st["name"], st["url"], getattr(self, "current_track", ""))
|
|
394
|
+
self.set_toast(msg, duration=3.0)
|
|
395
|
+
|
|
396
|
+
def toggle_favorite(self):
|
|
397
|
+
with self.lock:
|
|
398
|
+
filtered = self.get_filtered_stations_unlocked()
|
|
399
|
+
if not filtered or self.cursor_idx >= len(filtered): return
|
|
400
|
+
st_name = filtered[self.cursor_idx][1]["name"]
|
|
401
|
+
if st_name in self.favorites:
|
|
402
|
+
self.favorites.remove(st_name)
|
|
403
|
+
self.set_toast(f"Removed from Favs: {st_name}")
|
|
404
|
+
else:
|
|
405
|
+
self.favorites.add(st_name)
|
|
406
|
+
self.set_toast(f"Added to Favs: {st_name}")
|
|
407
|
+
save_favorites(self.favorites)
|
|
408
|
+
|
|
409
|
+
def select_section(self, section_key):
|
|
410
|
+
with self.lock:
|
|
411
|
+
keys = [it["key"] for it in ALL_SIDEBAR_ITEMS]
|
|
412
|
+
if section_key in keys:
|
|
413
|
+
self.section_key = section_key
|
|
414
|
+
self.sidebar_idx = keys.index(section_key)
|
|
415
|
+
self.cursor_idx = 0
|
|
416
|
+
self.save_current_config()
|
|
417
|
+
it = self.get_current_sidebar_item()
|
|
418
|
+
self.set_toast(f"Section: {it['label']}")
|
|
419
|
+
|
|
420
|
+
def save_current_config(self, force=False):
|
|
421
|
+
try:
|
|
422
|
+
self._cfg_dirty = True
|
|
423
|
+
t_now = time.time()
|
|
424
|
+
if not force and (t_now - self._last_cfg_save < 2.0):
|
|
425
|
+
return
|
|
426
|
+
cfg = {
|
|
427
|
+
"theme": self.theme_name,
|
|
428
|
+
"section": self.section_key,
|
|
429
|
+
"visualizer": self.visualizer_mode,
|
|
430
|
+
"ambient_preset": self.ambient_preset,
|
|
431
|
+
"ambient_volume": self.ambient_volume,
|
|
432
|
+
"cava_sensitivity": self.cava_sensitivity,
|
|
433
|
+
"master_volume": self.volume,
|
|
434
|
+
"nsfw_enabled": self.nsfw_enabled,
|
|
435
|
+
"show_welcome": getattr(self, "show_welcome", True),
|
|
436
|
+
"deck_style": getattr(self, "deck_style", "vinyl"),
|
|
437
|
+
"framerate": getattr(self, "framerate", 144),
|
|
438
|
+
"shimmer_effect": getattr(self, "shimmer_effect", "cava_sync"),
|
|
439
|
+
"notifications_enabled": self.notify_mgr.is_enabled() if hasattr(self, "notify_mgr") else True,
|
|
440
|
+
"last_station_url": self.stations[self.idx]["url"] if (self.stations and self.idx < len(self.stations)) else "",
|
|
441
|
+
"last_station_name": self.stations[self.idx]["name"] if (self.stations and self.idx < len(self.stations)) else ""
|
|
442
|
+
}
|
|
443
|
+
save_config(cfg)
|
|
444
|
+
self._cfg_dirty = False
|
|
445
|
+
self._last_cfg_save = t_now
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
def toggle_notifications(self):
|
|
450
|
+
with self.lock:
|
|
451
|
+
new_state = not self.notify_mgr.is_enabled()
|
|
452
|
+
self.notify_mgr.set_enabled(new_state)
|
|
453
|
+
self.save_current_config()
|
|
454
|
+
status = "ON" if new_state else "OFF"
|
|
455
|
+
self.set_toast(f"Desktop Notifications: {status}")
|
|
456
|
+
|
|
457
|
+
def cycle_framerate(self, step=1):
|
|
458
|
+
rates = [144, 120, 60, 240]
|
|
459
|
+
with self.lock:
|
|
460
|
+
cur_i = rates.index(self.framerate) if self.framerate in rates else 0
|
|
461
|
+
self.framerate = rates[(cur_i + step) % len(rates)]
|
|
462
|
+
self.cava.update_settings(self.cava_sensitivity, self.framerate)
|
|
463
|
+
self.save_current_config()
|
|
464
|
+
self.set_toast(f"Display Refresh: {self.framerate} Hz")
|
|
465
|
+
|
|
466
|
+
def cycle_deck(self):
|
|
467
|
+
with self.lock:
|
|
468
|
+
self.deck_style = "cassette" if self.deck_style == "vinyl" else "vinyl"
|
|
469
|
+
self.save_current_config()
|
|
470
|
+
lbl = "Vinyl Turntable" if self.deck_style == "vinyl" else "Cassette Master Deck"
|
|
471
|
+
self.set_toast(f"Now Playing Deck: {lbl}")
|
|
472
|
+
|
|
473
|
+
def cycle_shimmer(self):
|
|
474
|
+
with self.lock:
|
|
475
|
+
cur_i = SHIMMER_EFFECTS.index(self.shimmer_effect) if self.shimmer_effect in SHIMMER_EFFECTS else 0
|
|
476
|
+
self.shimmer_effect = SHIMMER_EFFECTS[(cur_i + 1) % len(SHIMMER_EFFECTS)]
|
|
477
|
+
self.save_current_config()
|
|
478
|
+
self.set_toast(f"Logo Shimmer: {SHIMMER_EFFECT_LABELS.get(self.shimmer_effect, self.shimmer_effect)}")
|
|
479
|
+
|
|
480
|
+
def cycle_theme(self):
|
|
481
|
+
with self.lock:
|
|
482
|
+
names = list(self.themes.keys())
|
|
483
|
+
if not names: return
|
|
484
|
+
cur = names.index(self.theme_name) if self.theme_name in names else 0
|
|
485
|
+
self.theme_name = names[(cur + 1) % len(names)]
|
|
486
|
+
self.save_current_config()
|
|
487
|
+
self.set_toast(f"Theme: {self.theme_name}")
|
|
488
|
+
|
|
489
|
+
def set_theme(self, name):
|
|
490
|
+
with self.lock:
|
|
491
|
+
if name in self.themes:
|
|
492
|
+
self.theme_name = name
|
|
493
|
+
self.save_current_config()
|
|
494
|
+
self.set_toast(f"Theme: {self.theme_name}")
|
|
495
|
+
|
|
496
|
+
def set_toast(self, msg, duration=1.8):
|
|
497
|
+
self.toast_msg = msg
|
|
498
|
+
self.toast_time = time.time() + duration
|
|
499
|
+
|
|
500
|
+
def shutdown(self):
|
|
501
|
+
with self.lock:
|
|
502
|
+
self.quit = True
|
|
503
|
+
self.save_current_config(force=True)
|
|
504
|
+
if hasattr(self, 'stats') and self.stats:
|
|
505
|
+
self.stats.flush()
|
|
506
|
+
if hasattr(self, 'cava') and self.cava:
|
|
507
|
+
self.cava.stop()
|
|
508
|
+
if hasattr(self, 'audio') and self.audio:
|
|
509
|
+
self.audio.shutdown()
|
|
510
|
+
|
|
511
|
+
def metadata_loop(state: PlayerState):
|
|
512
|
+
last_rx, last_tx = get_net_bytes()
|
|
513
|
+
last_t = time.time()
|
|
514
|
+
|
|
515
|
+
while not state.quit:
|
|
516
|
+
try:
|
|
517
|
+
now = time.time()
|
|
518
|
+
dt = max(0.1, now - last_t)
|
|
519
|
+
cur_rx, cur_tx = get_net_bytes()
|
|
520
|
+
|
|
521
|
+
rx_spd = max(0, (cur_rx - last_rx) / dt)
|
|
522
|
+
tx_spd = max(0, (cur_tx - last_tx) / dt)
|
|
523
|
+
last_rx, last_tx = cur_rx, cur_tx
|
|
524
|
+
last_t = now
|
|
525
|
+
|
|
526
|
+
with state.lock:
|
|
527
|
+
state.net_down_str = f"↓ {format_speed(rx_spd)}"
|
|
528
|
+
state.net_up_str = f"↑ {format_speed(tx_spd)}"
|
|
529
|
+
|
|
530
|
+
# Periodic debounced write of dirty config
|
|
531
|
+
if getattr(state, "_cfg_dirty", False) and (now - getattr(state, "_last_cfg_save", 0.0) >= 2.0):
|
|
532
|
+
state.save_current_config(force=True)
|
|
533
|
+
|
|
534
|
+
if state.audio.player:
|
|
535
|
+
meta = state.audio.player.metadata
|
|
536
|
+
if meta:
|
|
537
|
+
title = meta.get("icy-title") or meta.get("title")
|
|
538
|
+
br = meta.get("icy-br")
|
|
539
|
+
with state.lock:
|
|
540
|
+
if br:
|
|
541
|
+
state.stream_bitrate = f"{br} kbps"
|
|
542
|
+
if title and title != state.current_track:
|
|
543
|
+
state.current_track = title
|
|
544
|
+
st = state.stations[state.idx]
|
|
545
|
+
state.add_history_entry(title, st["name"], st["url"], st.get("genre", "Unknown"))
|
|
546
|
+
if hasattr(state, "notify_mgr") and state.notify_mgr:
|
|
547
|
+
state.notify_mgr.send_track_notification(title, st.get("name", ""), st.get("genre", ""), st.get("category", ""))
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
time.sleep(0.5)
|