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 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.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.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" }]
@@ -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
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="lushapp",
5
- version="2.0.0",
5
+ version="2.0.3",
6
6
  package_dir={"": "src"},
7
7
  packages=find_packages(where="src"),
8
8
  include_package_data=True,
@@ -1,7 +1,8 @@
1
1
  import os
2
+ import sys
2
3
  os.environ["ESCDELAY"] = "25"
3
4
 
4
- __version__ = "2.0.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
- print("On Windows, install it via: \033[1;96mpip install windows-curses\033[0m")
16
- return
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.volume = volume
18
+ self._volume = volume
19
19
  self.user_agent = user_agent
20
20
  self.proc = None
21
- self.pause = False
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.pause = False
72
+ self._pause = False
28
73
 
29
74
  if shutil.which("mpv"):
30
- cmd = ["mpv", "--no-video", "--no-terminal", f"--volume={self.volume}", f"--user-agent={self.user_agent}", url]
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.volume)), url]
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.volume / 100.0), url]
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=subprocess.DEVNULL,
97
+ stdin=stdin_mode,
42
98
  stdout=subprocess.DEVNULL,
43
99
  stderr=subprocess.DEVNULL
44
100
  )
@@ -2,8 +2,8 @@ import re
2
2
  # LUSH - Constants and Presets
3
3
 
4
4
  LOGO_LINES = [
5
- " █ █ █ █▀▀ █ █ █▀▀█",
6
- " █▄▄ █▄█ ▄██ █▀█ ██ ██"
5
+ " █ █ █ █▀▀ █ █ █▀▀▀█",
6
+ " █▄▄ █▄█ ▄██ █▀█ ██ ██"
7
7
  ]
8
8
 
9
9
  COMPACT_LOGO = "LUSH ♫"
@@ -139,16 +139,30 @@ class NotificationManager:
139
139
 
140
140
  @staticmethod
141
141
  def _dispatch_windows(title: str, body: str):
142
- safe_title = title.replace('"', '`"')
143
- safe_body = body.replace('"', '`"')
144
- ps_script = f'''
145
- [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
146
- $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
147
- $textNodes = $template.GetElementsByTagName("text")
148
- $textNodes.Item(0).AppendChild($template.CreateTextNode("{safe_title} - Now Playing")) > $null
149
- $textNodes.Item(1).AppendChild($template.CreateTextNode("{safe_body}")) > $null
150
- $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("LUSH")
151
- $notification = [Windows.UI.Notifications.ToastNotification]::new($template)
152
- $notifier.Show($notification)
153
- '''
154
- subprocess.run(["powershell", "-NoProfile", "-Command", ps_script], capture_output=True, timeout=3.0)
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
- stdscr.addstr(1 + row_i, 2 + c_idx, ch, attr)
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
- stdscr.addstr(0, 2 + c_idx, ch, attr)
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
@@ -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
- def apply_theme(theme_name: str, themes: dict):
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