lushapp 2.0.0 → 2.0.3
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/bin/lush +64 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +10 -0
- package/setup.py +1 -1
- package/src/lush/__init__.py +28 -4
- package/src/lush/audio.py +63 -7
- package/src/lush/constants.py +2 -2
- package/src/lush/notify.py +27 -13
- package/src/lush/ui.py +8 -2
- package/src/lush/ui_helpers.py +13 -1
package/bin/lush
CHANGED
|
@@ -17,6 +17,69 @@ for path_cand in [
|
|
|
17
17
|
import shutil
|
|
18
18
|
import subprocess
|
|
19
19
|
|
|
20
|
+
def ensure_python_deps():
|
|
21
|
+
"""Detects missing Python dependencies and auto-installs them on demand."""
|
|
22
|
+
missing = []
|
|
23
|
+
|
|
24
|
+
# Check curses on Windows
|
|
25
|
+
if sys.platform == "win32" or os.name == "nt":
|
|
26
|
+
try:
|
|
27
|
+
import curses
|
|
28
|
+
except ImportError:
|
|
29
|
+
try:
|
|
30
|
+
import windows_curses
|
|
31
|
+
except ImportError:
|
|
32
|
+
missing.append("windows-curses")
|
|
33
|
+
|
|
34
|
+
# Check requests
|
|
35
|
+
try:
|
|
36
|
+
import requests
|
|
37
|
+
except ImportError:
|
|
38
|
+
missing.append("requests")
|
|
39
|
+
|
|
40
|
+
# Check pyperclip
|
|
41
|
+
try:
|
|
42
|
+
import pyperclip
|
|
43
|
+
except ImportError:
|
|
44
|
+
missing.append("pyperclip")
|
|
45
|
+
|
|
46
|
+
# Check yt-dlp
|
|
47
|
+
try:
|
|
48
|
+
import yt_dlp
|
|
49
|
+
except ImportError:
|
|
50
|
+
missing.append("yt-dlp")
|
|
51
|
+
|
|
52
|
+
if not missing:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
install_cmd = [sys.executable, "-m", "pip", "install", *missing]
|
|
56
|
+
cmd_str = f"pip install {' '.join(missing)}"
|
|
57
|
+
|
|
58
|
+
if sys.stdin.isatty():
|
|
59
|
+
print("\033[1;95m┌────────────────────────────────────────────────────────────────────────┐\033[0m")
|
|
60
|
+
print(f"\033[1;95m│\033[0m \033[1;96mLUSH Setup:\033[0m Missing Python package(s): \033[1;93m{', '.join(missing):<29}\033[0m \033[1;95m│\033[0m")
|
|
61
|
+
print(f"\033[1;95m│\033[0m Auto-installing via \033[1;92mpip\033[0m... \033[1;95m│\033[0m")
|
|
62
|
+
print("\033[1;95m└────────────────────────────────────────────────────────────────────────┘\033[0m")
|
|
63
|
+
try:
|
|
64
|
+
ans = input(f"Install required packages ({', '.join(missing)})? [Y/n]: ").strip().lower()
|
|
65
|
+
if ans in ("", "y", "yes"):
|
|
66
|
+
print(f"\n\033[1;96mInstalling: {', '.join(missing)}...\033[0m\n")
|
|
67
|
+
res = subprocess.run(install_cmd)
|
|
68
|
+
if res.returncode == 0:
|
|
69
|
+
print("\033[1;92m✓ Python dependencies installed successfully!\033[0m\n")
|
|
70
|
+
else:
|
|
71
|
+
print(f"\n\033[1;91m[ERROR]\033[0m Installation failed. Please run: \033[1;96m{cmd_str}\033[0m\n")
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
else:
|
|
74
|
+
print(f"\n\033[1;93m[NOTICE]\033[0m Please run manually: \033[1;96m{cmd_str}\033[0m\n")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
except (KeyboardInterrupt, EOFError):
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
else:
|
|
79
|
+
print(f"\033[1;91m[ERROR]\033[0m Missing Python dependencies: {', '.join(missing)}")
|
|
80
|
+
print(f"Please install via: \033[1;96m{cmd_str}\033[0m")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
20
83
|
def ensure_audio_backend():
|
|
21
84
|
"""Detects if an audio player backend is available; offers auto-install if missing."""
|
|
22
85
|
if shutil.which("mpv") or shutil.which("ffplay") or shutil.which("cvlc"):
|
|
@@ -54,6 +117,7 @@ def ensure_audio_backend():
|
|
|
54
117
|
except (KeyboardInterrupt, EOFError):
|
|
55
118
|
pass
|
|
56
119
|
|
|
120
|
+
ensure_python_deps()
|
|
57
121
|
ensure_audio_backend()
|
|
58
122
|
|
|
59
123
|
from lush.__main__ import main
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lushapp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "A hyper-aesthetic terminal radio player with 560+ live stations, real-time CAVA DSP audio visualizers, ambient soundscapes, and artist discographies.",
|
|
5
5
|
"main": "bin/lush.js",
|
|
6
6
|
"bin": {
|
|
@@ -58,4 +58,4 @@
|
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=14.0.0"
|
|
60
60
|
}
|
|
61
|
-
}
|
|
61
|
+
}
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lushapp"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.3"
|
|
8
8
|
description = "A hyper-aesthetic terminal radio player with 560+ live stations, real-time CAVA DSP visualizers, and artist discographies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "pseudoshell" }]
|
package/scripts/postinstall.js
CHANGED
|
@@ -17,6 +17,16 @@ if (!which('mpv')) missing.push('mpv');
|
|
|
17
17
|
if (!which('cava')) missing.push('cava');
|
|
18
18
|
if (!which('ffmpeg')) missing.push('ffmpeg');
|
|
19
19
|
|
|
20
|
+
const pyCmd = which('python3') ? 'python3' : (which('python') ? 'python' : (which('py') ? 'py' : null));
|
|
21
|
+
if (pyCmd) {
|
|
22
|
+
try {
|
|
23
|
+
const pipDeps = process.platform === 'win32'
|
|
24
|
+
? ['windows-curses', 'requests', 'pyperclip', 'yt-dlp']
|
|
25
|
+
: ['requests', 'pyperclip', 'yt-dlp'];
|
|
26
|
+
execSync(`${pyCmd} -m pip install -q ${pipDeps.join(' ')}`, { stdio: 'ignore' });
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
if (missing.length === 0) {
|
|
21
31
|
console.log('\x1b[1;92m✓ [LUSH]\x1b[0m All system dependencies (python3, mpv, cava, ffmpeg) are verified.');
|
|
22
32
|
process.exit(0);
|
package/setup.py
CHANGED
package/src/lush/__init__.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import sys
|
|
2
3
|
os.environ["ESCDELAY"] = "25"
|
|
3
4
|
|
|
4
|
-
__version__ = "2.0.
|
|
5
|
+
__version__ = "2.0.3"
|
|
5
6
|
|
|
6
7
|
def run_app():
|
|
7
8
|
try:
|
|
@@ -10,10 +11,33 @@ def run_app():
|
|
|
10
11
|
try:
|
|
11
12
|
import windows_curses as curses
|
|
12
13
|
except ImportError:
|
|
13
|
-
print("\033[1;91m[ERROR]\033[0m Python curses library is required to run LUSH.")
|
|
14
14
|
if os.name == "nt":
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
if sys.stdin.isatty():
|
|
16
|
+
print("\033[1;95m┌────────────────────────────────────────────────────────────────────────┐\033[0m")
|
|
17
|
+
print("\033[1;95m│\033[0m \033[1;96mLUSH Setup:\033[0m Windows requires \033[1;93mwindows-curses\033[0m for terminal UI. \033[1;95m│\033[0m")
|
|
18
|
+
print("\033[1;95m└────────────────────────────────────────────────────────────────────────┘\033[0m")
|
|
19
|
+
try:
|
|
20
|
+
ans = input("Auto-install windows-curses via pip? [Y/n]: ").strip().lower()
|
|
21
|
+
if ans in ("", "y", "yes"):
|
|
22
|
+
import subprocess
|
|
23
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "windows-curses"])
|
|
24
|
+
try:
|
|
25
|
+
import windows_curses as curses
|
|
26
|
+
except ImportError:
|
|
27
|
+
print("\033[1;91m[ERROR]\033[0m Failed to load curses. Please run: \033[1;96mpip install windows-curses\033[0m")
|
|
28
|
+
return
|
|
29
|
+
else:
|
|
30
|
+
print("\n\033[1;93m[NOTICE]\033[0m Please run: \033[1;96mpip install windows-curses\033[0m\n")
|
|
31
|
+
return
|
|
32
|
+
except (KeyboardInterrupt, EOFError):
|
|
33
|
+
return
|
|
34
|
+
else:
|
|
35
|
+
print("\033[1;91m[ERROR]\033[0m Python curses library is required to run LUSH.")
|
|
36
|
+
print("On Windows, install it via: \033[1;96mpip install windows-curses\033[0m")
|
|
37
|
+
return
|
|
38
|
+
else:
|
|
39
|
+
print("\033[1;91m[ERROR]\033[0m Python curses library is required to run LUSH.")
|
|
40
|
+
return
|
|
17
41
|
|
|
18
42
|
import threading
|
|
19
43
|
from .state import PlayerState, metadata_loop
|
package/src/lush/audio.py
CHANGED
|
@@ -15,30 +15,86 @@ except Exception:
|
|
|
15
15
|
class CLIPlayer:
|
|
16
16
|
"""Universal subprocess-based audio player fallback (supports mpv, ffplay, vlc)."""
|
|
17
17
|
def __init__(self, volume: int = 100, user_agent: str = "LUSH/2.0 (Terminal Audio Player)"):
|
|
18
|
-
self.
|
|
18
|
+
self._volume = volume
|
|
19
19
|
self.user_agent = user_agent
|
|
20
20
|
self.proc = None
|
|
21
|
-
self.
|
|
21
|
+
self._pause = False
|
|
22
22
|
self.current_url = None
|
|
23
23
|
|
|
24
|
+
@property
|
|
25
|
+
def pause(self) -> bool:
|
|
26
|
+
return self._pause
|
|
27
|
+
|
|
28
|
+
@pause.setter
|
|
29
|
+
def pause(self, val: bool):
|
|
30
|
+
self._pause = val
|
|
31
|
+
if val:
|
|
32
|
+
# Pause playback
|
|
33
|
+
if self.proc and self.proc.poll() is None:
|
|
34
|
+
if shutil.which("mpv") and self.proc.stdin:
|
|
35
|
+
try:
|
|
36
|
+
self.proc.stdin.write(b"set pause yes\n")
|
|
37
|
+
self.proc.stdin.flush()
|
|
38
|
+
return
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
self.stop()
|
|
42
|
+
else:
|
|
43
|
+
# Resume playback
|
|
44
|
+
if self.proc and self.proc.poll() is None:
|
|
45
|
+
if shutil.which("mpv") and self.proc.stdin:
|
|
46
|
+
try:
|
|
47
|
+
self.proc.stdin.write(b"set pause no\n")
|
|
48
|
+
self.proc.stdin.flush()
|
|
49
|
+
return
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
if self.current_url:
|
|
53
|
+
self.play(self.current_url)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def volume(self) -> int:
|
|
57
|
+
return self._volume
|
|
58
|
+
|
|
59
|
+
@volume.setter
|
|
60
|
+
def volume(self, val: int):
|
|
61
|
+
self._volume = max(0, min(100, int(val)))
|
|
62
|
+
if self.proc and self.proc.poll() is None and self.proc.stdin and shutil.which("mpv"):
|
|
63
|
+
try:
|
|
64
|
+
self.proc.stdin.write(f"set volume {self._volume}\n".encode())
|
|
65
|
+
self.proc.stdin.flush()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
24
69
|
def play(self, url: str):
|
|
25
70
|
self.stop()
|
|
26
71
|
self.current_url = url
|
|
27
|
-
self.
|
|
72
|
+
self._pause = False
|
|
28
73
|
|
|
29
74
|
if shutil.which("mpv"):
|
|
30
|
-
cmd = [
|
|
75
|
+
cmd = [
|
|
76
|
+
"mpv",
|
|
77
|
+
"--no-video",
|
|
78
|
+
"--no-terminal",
|
|
79
|
+
"--input-terminal=no",
|
|
80
|
+
f"--volume={self._volume}",
|
|
81
|
+
f"--user-agent={self.user_agent}",
|
|
82
|
+
url
|
|
83
|
+
]
|
|
84
|
+
stdin_mode = subprocess.PIPE
|
|
31
85
|
elif shutil.which("ffplay"):
|
|
32
|
-
cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", str(int(self.
|
|
86
|
+
cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", str(int(self._volume)), url]
|
|
87
|
+
stdin_mode = subprocess.DEVNULL
|
|
33
88
|
elif shutil.which("cvlc"):
|
|
34
|
-
cmd = ["cvlc", "--no-video", "--gain", str(self.
|
|
89
|
+
cmd = ["cvlc", "--no-video", "--gain", str(self._volume / 100.0), url]
|
|
90
|
+
stdin_mode = subprocess.DEVNULL
|
|
35
91
|
else:
|
|
36
92
|
return
|
|
37
93
|
|
|
38
94
|
try:
|
|
39
95
|
self.proc = subprocess.Popen(
|
|
40
96
|
cmd,
|
|
41
|
-
stdin=
|
|
97
|
+
stdin=stdin_mode,
|
|
42
98
|
stdout=subprocess.DEVNULL,
|
|
43
99
|
stderr=subprocess.DEVNULL
|
|
44
100
|
)
|
package/src/lush/constants.py
CHANGED
package/src/lush/notify.py
CHANGED
|
@@ -139,16 +139,30 @@ class NotificationManager:
|
|
|
139
139
|
|
|
140
140
|
@staticmethod
|
|
141
141
|
def _dispatch_windows(title: str, body: str):
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
142
|
+
import base64
|
|
143
|
+
import html
|
|
144
|
+
safe_title = html.escape(f"{title} - Now Playing")
|
|
145
|
+
safe_body = html.escape(body)
|
|
146
|
+
ps_script = f"""[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
147
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
148
|
+
$template = @"
|
|
149
|
+
<toast>
|
|
150
|
+
<visual>
|
|
151
|
+
<binding template="ToastGeneric">
|
|
152
|
+
<text>{safe_title}</text>
|
|
153
|
+
<text>{safe_body}</text>
|
|
154
|
+
</binding>
|
|
155
|
+
</visual>
|
|
156
|
+
</toast>
|
|
157
|
+
"@
|
|
158
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
159
|
+
$xml.LoadXml($template)
|
|
160
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
161
|
+
$appId = '{{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}}\\WindowsPowerShell\\v1.0\\powershell.exe'
|
|
162
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
enc = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
|
|
166
|
+
subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", enc], capture_output=True, timeout=3.0)
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
package/src/lush/ui.py
CHANGED
|
@@ -179,7 +179,10 @@ def curses_main(stdscr, state: PlayerState):
|
|
|
179
179
|
cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
|
|
180
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
181
|
attr = curses.color_pair(p_id) | curses.A_BOLD
|
|
182
|
-
|
|
182
|
+
try:
|
|
183
|
+
stdscr.addstr(1 + row_i, 2 + c_idx, ch, attr)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
183
186
|
main_start_y = 4
|
|
184
187
|
else:
|
|
185
188
|
for c_idx, ch in enumerate(COMPACT_LOGO):
|
|
@@ -187,7 +190,10 @@ def curses_main(stdscr, state: PlayerState):
|
|
|
187
190
|
cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
|
|
188
191
|
p_id = get_shimmer_color_pair(shimmer_eff, x_norm, c_idx, 0, t_now, cava_bars=cava_bars, is_playing=state.playing)
|
|
189
192
|
attr = curses.color_pair(p_id) | curses.A_BOLD
|
|
190
|
-
|
|
193
|
+
try:
|
|
194
|
+
stdscr.addstr(0, 2 + c_idx, ch, attr)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
191
197
|
main_start_y = 2
|
|
192
198
|
|
|
193
199
|
# Right side stats: Real Live Network Speeds (↓ Down / ↑ Up) + Bitrate + Rec
|
package/src/lush/ui_helpers.py
CHANGED
|
@@ -23,9 +23,21 @@ def trim(text: str, width: int) -> str:
|
|
|
23
23
|
if width <= 3: return text[:width]
|
|
24
24
|
return text[:width-3] + "..."
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
_CURRENT_THEME = None
|
|
27
|
+
|
|
28
|
+
def apply_theme(theme_name: str, themes: dict, force: bool = False):
|
|
29
|
+
global _CURRENT_THEME
|
|
27
30
|
if not curses.has_colors():
|
|
28
31
|
return
|
|
32
|
+
if not force and _CURRENT_THEME == theme_name:
|
|
33
|
+
return
|
|
34
|
+
_CURRENT_THEME = theme_name
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
curses.use_default_colors()
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
29
41
|
t = themes.get(theme_name, themes.get("Plur1bus", list(themes.values())[0]))
|
|
30
42
|
is_256 = curses.COLORS >= 256
|
|
31
43
|
|