headless-music 1.1.1__py3-none-any.whl
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.
- headless_music/__init__.py +2 -0
- headless_music/config.py +123 -0
- headless_music/fetchers/__init__.py +4 -0
- headless_music/fetchers/spotify.py +166 -0
- headless_music/fetchers/youtube.py +59 -0
- headless_music/headless_music.py +818 -0
- headless_music/main.py +279 -0
- headless_music/player.py +75 -0
- headless_music/ui/__init__.py +17 -0
- headless_music/ui/art.py +95 -0
- headless_music/ui/layout.py +19 -0
- headless_music/ui/panels.py +103 -0
- headless_music/utils/__init__.py +10 -0
- headless_music/utils/cache.py +23 -0
- headless_music/utils/helpers.py +15 -0
- headless_music-1.1.1.dist-info/METADATA +115 -0
- headless_music-1.1.1.dist-info/RECORD +21 -0
- headless_music-1.1.1.dist-info/WHEEL +5 -0
- headless_music-1.1.1.dist-info/entry_points.txt +2 -0
- headless_music-1.1.1.dist-info/licenses/LICENSE +21 -0
- headless_music-1.1.1.dist-info/top_level.txt +1 -0
headless_music/main.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import select
|
|
4
|
+
import threading
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.live import Live
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
# Import our modules
|
|
10
|
+
from config import (
|
|
11
|
+
setup_logging, load_config, validate_config,
|
|
12
|
+
setup_wizard, CONFIG_FILE
|
|
13
|
+
)
|
|
14
|
+
from player import MusicPlayer
|
|
15
|
+
from fetchers.spotify import SpotifyFetcher
|
|
16
|
+
from fetchers.youtube import YouTubeFetcher
|
|
17
|
+
from ui.layout import create_layout
|
|
18
|
+
from ui.panels import (
|
|
19
|
+
create_controls_panel, create_now_playing_panel,
|
|
20
|
+
create_queue_panel, create_progress_panel
|
|
21
|
+
)
|
|
22
|
+
from ui.art import create_ascii_art_panel
|
|
23
|
+
from utils.cache import panel_cache
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def input_thread(player):
|
|
29
|
+
"""Handle keyboard input with adaptive polling."""
|
|
30
|
+
poll_interval = 0.5
|
|
31
|
+
|
|
32
|
+
while player.is_running:
|
|
33
|
+
try:
|
|
34
|
+
readable, _, _ = select.select([sys.stdin], [], [], poll_interval)
|
|
35
|
+
if readable:
|
|
36
|
+
char = sys.stdin.read(1)
|
|
37
|
+
poll_interval = 0.1 # Fast response after input
|
|
38
|
+
|
|
39
|
+
if char == 'n':
|
|
40
|
+
player.command_queue.put("next")
|
|
41
|
+
elif char == 'p':
|
|
42
|
+
player.command_queue.put("prev")
|
|
43
|
+
elif char == ' ':
|
|
44
|
+
player.command_queue.put("pause")
|
|
45
|
+
elif char == 'q':
|
|
46
|
+
player.command_queue.put("quit")
|
|
47
|
+
elif char == 'c':
|
|
48
|
+
player.command_queue.put("config")
|
|
49
|
+
else:
|
|
50
|
+
poll_interval = min(poll_interval * 1.5, 0.5)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logging.error(f"Input thread error: {e}")
|
|
53
|
+
player.is_running = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def refresh_queue(player, spotify_fetcher, youtube_fetcher):
|
|
57
|
+
"""Refresh queue with new recommendations."""
|
|
58
|
+
if not player.needs_queue_refresh():
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
seed_tracks = player.playlist[
|
|
62
|
+
max(0, player.current_index - 10):player.current_index + 1
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
new_tracks = []
|
|
66
|
+
|
|
67
|
+
if spotify_fetcher:
|
|
68
|
+
new_tracks = spotify_fetcher.get_recommendations(seed_tracks, limit=15)
|
|
69
|
+
|
|
70
|
+
if len(new_tracks) < 5 and youtube_fetcher:
|
|
71
|
+
yt_tracks = youtube_fetcher.search_similar_tracks(seed_tracks, limit=10)
|
|
72
|
+
new_tracks.extend(yt_tracks)
|
|
73
|
+
|
|
74
|
+
if new_tracks:
|
|
75
|
+
player.extend_playlist(new_tracks)
|
|
76
|
+
else:
|
|
77
|
+
# Loop playlist if no new tracks found
|
|
78
|
+
player.extend_playlist(player.playlist[:20])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
"""Main application loop."""
|
|
83
|
+
setup_logging()
|
|
84
|
+
|
|
85
|
+
config = load_config()
|
|
86
|
+
if not validate_config(config):
|
|
87
|
+
config = setup_wizard()
|
|
88
|
+
if not config:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
console.print("🎵 Initialising headless_music...", style="bold cyan")
|
|
92
|
+
console.print(f"📡 Fetching {config['PLAYLIST_SOURCE'].title()} playlist...",
|
|
93
|
+
style="bold green")
|
|
94
|
+
|
|
95
|
+
# Initialize fetchers
|
|
96
|
+
spotify_fetcher = SpotifyFetcher(
|
|
97
|
+
config['SPOTIFY_CLIENT_ID'],
|
|
98
|
+
config['SPOTIFY_CLIENT_SECRET']
|
|
99
|
+
)
|
|
100
|
+
youtube_fetcher = YouTubeFetcher()
|
|
101
|
+
|
|
102
|
+
# Fetch initial playlist
|
|
103
|
+
if config['PLAYLIST_SOURCE'] == 'spotify':
|
|
104
|
+
if not spotify_fetcher.client:
|
|
105
|
+
console.print("[red]Failed to initialize Spotify. Exiting.[/red]")
|
|
106
|
+
return
|
|
107
|
+
initial_tracks = spotify_fetcher.get_playlist_tracks(config['PLAYLIST_URL'])
|
|
108
|
+
else:
|
|
109
|
+
initial_tracks = youtube_fetcher.get_playlist_titles(config['PLAYLIST_URL'])
|
|
110
|
+
|
|
111
|
+
if not initial_tracks:
|
|
112
|
+
console.print("[red]Failed to fetch playlist. Exiting.[/red]")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
console.print(f"✓ Found {len(initial_tracks)} tracks", style="green")
|
|
116
|
+
console.print("🎧 Fetching additional tracks...", style="bold green")
|
|
117
|
+
|
|
118
|
+
# Fetch additional recommendations
|
|
119
|
+
additional_tracks = []
|
|
120
|
+
if spotify_fetcher.client:
|
|
121
|
+
additional_tracks = spotify_fetcher.get_recommendations(initial_tracks, limit=30)
|
|
122
|
+
if additional_tracks:
|
|
123
|
+
console.print(f"✓ Added {len(additional_tracks)} tracks", style="green")
|
|
124
|
+
|
|
125
|
+
# Initialize player
|
|
126
|
+
player = MusicPlayer()
|
|
127
|
+
player.playlist = initial_tracks + additional_tracks
|
|
128
|
+
|
|
129
|
+
if not player.playlist:
|
|
130
|
+
console.print("[red]No tracks available. Exiting.[/red]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Setup terminal
|
|
134
|
+
try:
|
|
135
|
+
import tty
|
|
136
|
+
import termios
|
|
137
|
+
old_settings = termios.tcgetattr(sys.stdin)
|
|
138
|
+
tty.setcbreak(sys.stdin.fileno())
|
|
139
|
+
except Exception:
|
|
140
|
+
old_settings = None
|
|
141
|
+
|
|
142
|
+
# Start input thread
|
|
143
|
+
input_handler = threading.Thread(
|
|
144
|
+
target=input_thread,
|
|
145
|
+
args=(player,),
|
|
146
|
+
daemon=True
|
|
147
|
+
)
|
|
148
|
+
input_handler.start()
|
|
149
|
+
|
|
150
|
+
console.print("\n✨ Starting playback...\n", style="bold magenta")
|
|
151
|
+
time.sleep(0.5)
|
|
152
|
+
|
|
153
|
+
# Create UI layout
|
|
154
|
+
layout = create_layout()
|
|
155
|
+
last_progress_update = time.time()
|
|
156
|
+
force_refresh_counter = 0
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
with Live(layout, console=console, screen=True, refresh_per_second=0.5) as live:
|
|
160
|
+
# Initialize UI
|
|
161
|
+
player.play_track(0)
|
|
162
|
+
layout["footer"].update(create_controls_panel())
|
|
163
|
+
|
|
164
|
+
# Initial UI update
|
|
165
|
+
track = player.get_current_track()
|
|
166
|
+
if track:
|
|
167
|
+
title, artist, source, image_url = track
|
|
168
|
+
available_height = max(1, console.height - 11)
|
|
169
|
+
|
|
170
|
+
layout["art"].update(
|
|
171
|
+
create_ascii_art_panel(image_url, available_height, panel_cache)
|
|
172
|
+
)
|
|
173
|
+
layout["now_playing"].update(
|
|
174
|
+
create_now_playing_panel(title, artist, source)
|
|
175
|
+
)
|
|
176
|
+
layout["sidebar"].update(
|
|
177
|
+
create_queue_panel(player.playlist, player.current_index)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Main loop
|
|
181
|
+
while player.is_running:
|
|
182
|
+
try:
|
|
183
|
+
cmd = player.command_queue.get(timeout=2.0)
|
|
184
|
+
except:
|
|
185
|
+
cmd = None
|
|
186
|
+
|
|
187
|
+
if cmd == "next":
|
|
188
|
+
refresh_queue(player, spotify_fetcher, youtube_fetcher)
|
|
189
|
+
if player.next_track():
|
|
190
|
+
track = player.get_current_track()
|
|
191
|
+
if track:
|
|
192
|
+
title, artist, source, image_url = track
|
|
193
|
+
available_height = max(1, console.height - 11)
|
|
194
|
+
|
|
195
|
+
layout["art"].update(
|
|
196
|
+
create_ascii_art_panel(image_url, available_height, panel_cache)
|
|
197
|
+
)
|
|
198
|
+
layout["now_playing"].update(
|
|
199
|
+
create_now_playing_panel(title, artist, source)
|
|
200
|
+
)
|
|
201
|
+
layout["sidebar"].update(
|
|
202
|
+
create_queue_panel(player.playlist, player.current_index)
|
|
203
|
+
)
|
|
204
|
+
force_refresh_counter = 0
|
|
205
|
+
|
|
206
|
+
elif cmd == "prev":
|
|
207
|
+
if player.prev_track():
|
|
208
|
+
track = player.get_current_track()
|
|
209
|
+
if track:
|
|
210
|
+
title, artist, source, image_url = track
|
|
211
|
+
available_height = max(1, console.height - 11)
|
|
212
|
+
|
|
213
|
+
layout["art"].update(
|
|
214
|
+
create_ascii_art_panel(image_url, available_height, panel_cache)
|
|
215
|
+
)
|
|
216
|
+
layout["now_playing"].update(
|
|
217
|
+
create_now_playing_panel(title, artist, source)
|
|
218
|
+
)
|
|
219
|
+
layout["sidebar"].update(
|
|
220
|
+
create_queue_panel(player.playlist, player.current_index)
|
|
221
|
+
)
|
|
222
|
+
force_refresh_counter = 0
|
|
223
|
+
|
|
224
|
+
elif cmd == "pause":
|
|
225
|
+
player.toggle_pause()
|
|
226
|
+
force_refresh_counter = 0
|
|
227
|
+
|
|
228
|
+
elif cmd == "config":
|
|
229
|
+
player.is_running = False
|
|
230
|
+
player.mpv.pause = True
|
|
231
|
+
live.stop()
|
|
232
|
+
console.clear()
|
|
233
|
+
new_config = setup_wizard()
|
|
234
|
+
if new_config:
|
|
235
|
+
console.print("\n[yellow]Please restart headless_music to apply new settings.[/yellow]")
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
elif cmd == "quit":
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
# Update progress every 2 seconds
|
|
242
|
+
current_time = time.time()
|
|
243
|
+
if current_time - last_progress_update >= 2.0:
|
|
244
|
+
try:
|
|
245
|
+
layout["progress"].update(create_progress_panel(player.mpv))
|
|
246
|
+
last_progress_update = current_time
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
# Periodic forced refresh
|
|
251
|
+
force_refresh_counter += 1
|
|
252
|
+
if force_refresh_counter >= 10:
|
|
253
|
+
live.refresh()
|
|
254
|
+
force_refresh_counter = 0
|
|
255
|
+
|
|
256
|
+
# Sleep when idle
|
|
257
|
+
if cmd is None:
|
|
258
|
+
time.sleep(0.2)
|
|
259
|
+
|
|
260
|
+
except KeyboardInterrupt:
|
|
261
|
+
player.is_running = False
|
|
262
|
+
|
|
263
|
+
finally:
|
|
264
|
+
player.quit()
|
|
265
|
+
|
|
266
|
+
# Restore terminal
|
|
267
|
+
if old_settings:
|
|
268
|
+
try:
|
|
269
|
+
import termios
|
|
270
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
console.clear()
|
|
275
|
+
console.print("\nheadless_music stopped. goodbye! 👋 \n", style="bold yellow")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
main()
|
headless_music/player.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import queue
|
|
3
|
+
import threading
|
|
4
|
+
from mpv import MPV
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MusicPlayer:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.mpv = MPV(
|
|
10
|
+
ytdl=True,
|
|
11
|
+
video=False,
|
|
12
|
+
keep_open=False,
|
|
13
|
+
keep_open_pause=False,
|
|
14
|
+
cache=True,
|
|
15
|
+
demuxer_max_bytes='50M',
|
|
16
|
+
cache_secs=10
|
|
17
|
+
)
|
|
18
|
+
self.current_index = 0
|
|
19
|
+
self.playlist = []
|
|
20
|
+
self.command_queue = queue.Queue()
|
|
21
|
+
self.is_running = True
|
|
22
|
+
self._setup_observers()
|
|
23
|
+
|
|
24
|
+
def _setup_observers(self):
|
|
25
|
+
@self.mpv.property_observer('idle-active')
|
|
26
|
+
def handle_song_end(_name, value):
|
|
27
|
+
if value and self.is_running:
|
|
28
|
+
self.command_queue.put("next")
|
|
29
|
+
|
|
30
|
+
def play_track(self, index):
|
|
31
|
+
if index < 0 or index >= len(self.playlist):
|
|
32
|
+
if index >= len(self.playlist) and len(self.playlist) > 0:
|
|
33
|
+
index = 0
|
|
34
|
+
else:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
self.current_index = index
|
|
38
|
+
title, artist, _, _ = self.playlist[self.current_index]
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
query = f"{title} {artist} audio"
|
|
42
|
+
self.mpv.play(f"ytdl://ytsearch1:{query}")
|
|
43
|
+
self.mpv.pause = False
|
|
44
|
+
return True
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logging.error(f"Error playing track: {e}")
|
|
47
|
+
self.command_queue.put("next")
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def next_track(self):
|
|
51
|
+
return self.play_track(self.current_index + 1)
|
|
52
|
+
|
|
53
|
+
def prev_track(self):
|
|
54
|
+
return self.play_track(max(0, self.current_index - 1))
|
|
55
|
+
|
|
56
|
+
def toggle_pause(self):
|
|
57
|
+
self.mpv.pause = not self.mpv.pause
|
|
58
|
+
|
|
59
|
+
def get_current_track(self):
|
|
60
|
+
if 0 <= self.current_index < len(self.playlist):
|
|
61
|
+
return self.playlist[self.current_index]
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def needs_queue_refresh(self, threshold=5):
|
|
65
|
+
return len(self.playlist) - self.current_index < threshold
|
|
66
|
+
|
|
67
|
+
def extend_playlist(self, tracks):
|
|
68
|
+
self.playlist.extend(tracks)
|
|
69
|
+
|
|
70
|
+
def quit(self):
|
|
71
|
+
self.is_running = False
|
|
72
|
+
try:
|
|
73
|
+
self.mpv.quit()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .layout import create_layout
|
|
2
|
+
from .panels import (
|
|
3
|
+
create_controls_panel,
|
|
4
|
+
create_now_playing_panel,
|
|
5
|
+
create_queue_panel,
|
|
6
|
+
create_progress_panel
|
|
7
|
+
)
|
|
8
|
+
from .art import create_ascii_art_panel
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'create_layout',
|
|
12
|
+
'create_controls_panel',
|
|
13
|
+
'create_now_playing_panel',
|
|
14
|
+
'create_queue_panel',
|
|
15
|
+
'create_progress_panel',
|
|
16
|
+
'create_ascii_art_panel'
|
|
17
|
+
]
|
headless_music/ui/art.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import requests
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from PIL import Image
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=32)
|
|
11
|
+
def generate_ascii_art(image_url, height):
|
|
12
|
+
try:
|
|
13
|
+
height = max(1, min(height, 30))
|
|
14
|
+
width = height * 2
|
|
15
|
+
|
|
16
|
+
response = requests.get(image_url, timeout=3)
|
|
17
|
+
response.raise_for_status()
|
|
18
|
+
|
|
19
|
+
with Image.open(io.BytesIO(response.content)) as img:
|
|
20
|
+
img = img.resize((width, height), Image.Resampling.LANCZOS)
|
|
21
|
+
img = img.convert("RGB")
|
|
22
|
+
|
|
23
|
+
ascii_lines = [None] * height
|
|
24
|
+
|
|
25
|
+
for y in range(height):
|
|
26
|
+
line_parts = []
|
|
27
|
+
for x in range(width):
|
|
28
|
+
r, g, b = img.getpixel((x, y))
|
|
29
|
+
line_parts.append(f"[rgb({r},{g},{b}) on rgb({r},{g},{b})]▀[/]")
|
|
30
|
+
ascii_lines[y] = ''.join(line_parts)
|
|
31
|
+
|
|
32
|
+
return Text.from_markup("\n".join(ascii_lines), justify="center")
|
|
33
|
+
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logging.warning(f"Failed to generate ASCII art: {e}")
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
_PLACEHOLDER_CACHE = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_placeholder_art(height=8):
|
|
42
|
+
if height in _PLACEHOLDER_CACHE:
|
|
43
|
+
return _PLACEHOLDER_CACHE[height]
|
|
44
|
+
|
|
45
|
+
base_art = [
|
|
46
|
+
"╔══════════════╗",
|
|
47
|
+
"║ ║",
|
|
48
|
+
"║ [bold]🎵[/bold] ║",
|
|
49
|
+
"║ ║",
|
|
50
|
+
"║ [dim]headless[/dim] ║",
|
|
51
|
+
"║ [dim]_music[/dim] ║",
|
|
52
|
+
"║ ║",
|
|
53
|
+
"╚══════════════╝",
|
|
54
|
+
]
|
|
55
|
+
base_height = len(base_art)
|
|
56
|
+
scaled_art_lines = []
|
|
57
|
+
|
|
58
|
+
if height <= 0:
|
|
59
|
+
result = Text("")
|
|
60
|
+
elif height < base_height:
|
|
61
|
+
start_index = (base_height - height) // 2
|
|
62
|
+
for i in range(height):
|
|
63
|
+
scaled_art_lines.append(f" [green]{base_art[start_index + i]}[/green]")
|
|
64
|
+
result = Text.from_markup("\n".join(scaled_art_lines), justify="center")
|
|
65
|
+
else:
|
|
66
|
+
top_padding = (height - base_height) // 2
|
|
67
|
+
bottom_padding = height - base_height - top_padding
|
|
68
|
+
scaled_art_lines.extend([""] * top_padding)
|
|
69
|
+
for line in base_art:
|
|
70
|
+
scaled_art_lines.append(f" [green]{line}[/green]")
|
|
71
|
+
scaled_art_lines.extend([""] * bottom_padding)
|
|
72
|
+
result = Text.from_markup("\n".join(scaled_art_lines), justify="center")
|
|
73
|
+
|
|
74
|
+
_PLACEHOLDER_CACHE[height] = result
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_ascii_art_panel(image_url, height, cache):
|
|
79
|
+
cache_key = f"{image_url}_{height}"
|
|
80
|
+
cached = cache.get(cache_key)
|
|
81
|
+
if cached:
|
|
82
|
+
return cached
|
|
83
|
+
|
|
84
|
+
if image_url:
|
|
85
|
+
try:
|
|
86
|
+
art = generate_ascii_art(image_url, height)
|
|
87
|
+
if art:
|
|
88
|
+
panel = Panel(art, border_style="dim", padding=(0, 0))
|
|
89
|
+
cache.set(cache_key, panel)
|
|
90
|
+
return panel
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
panel = Panel(get_placeholder_art(height), border_style="dim")
|
|
95
|
+
return panel
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from rich.layout import Layout
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def create_layout():
|
|
5
|
+
layout = Layout()
|
|
6
|
+
|
|
7
|
+
layout.split_row(
|
|
8
|
+
Layout(name="sidebar", size=40),
|
|
9
|
+
Layout(name="main", ratio=1)
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
layout["main"].split_column(
|
|
13
|
+
Layout(name="art", ratio=1),
|
|
14
|
+
Layout(name="now_playing", size=5),
|
|
15
|
+
Layout(name="progress", size=1),
|
|
16
|
+
Layout(name="footer", size=3)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return layout
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from rich.panel import Panel
|
|
2
|
+
from rich.text import Text
|
|
3
|
+
from rich.progress_bar import ProgressBar
|
|
4
|
+
from rich.layout import Layout
|
|
5
|
+
from utils.helpers import format_time, truncate_string
|
|
6
|
+
|
|
7
|
+
_CONTROLS_PANEL = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_controls_panel():
|
|
11
|
+
global _CONTROLS_PANEL
|
|
12
|
+
if _CONTROLS_PANEL is None:
|
|
13
|
+
_CONTROLS_PANEL = Panel(
|
|
14
|
+
Text.from_markup(
|
|
15
|
+
"[bold cyan]n[/bold cyan] next • [bold cyan]p[/bold cyan] prev • "
|
|
16
|
+
"[bold cyan]space[/bold cyan] pause/play • [bold cyan]q[/bold cyan] quit • "
|
|
17
|
+
"[bold cyan]c[/bold cyan] config",
|
|
18
|
+
justify="center"
|
|
19
|
+
),
|
|
20
|
+
border_style="dim"
|
|
21
|
+
)
|
|
22
|
+
return _CONTROLS_PANEL
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_now_playing_panel(title, artist, source):
|
|
26
|
+
source_color = "red" if source == "YouTube" else "green"
|
|
27
|
+
|
|
28
|
+
title = truncate_string(title, 60)
|
|
29
|
+
artist = truncate_string(artist, 40)
|
|
30
|
+
|
|
31
|
+
display_text = Text.from_markup(
|
|
32
|
+
f"\n[bold]{title}[/bold]\n[dim]{artist}[/dim]\n"
|
|
33
|
+
f"Source: [{source_color}]{source}[/{source_color}]\n"
|
|
34
|
+
)
|
|
35
|
+
return Panel(display_text, title="Now Playing",
|
|
36
|
+
padding=(0, 2, 0, 2), border_style="dim")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_queue_panel(master_playlist, current_index):
|
|
40
|
+
lines = []
|
|
41
|
+
|
|
42
|
+
if 0 <= current_index < len(master_playlist):
|
|
43
|
+
title, artist, _, _ = master_playlist[current_index]
|
|
44
|
+
title = truncate_string(title, 30)
|
|
45
|
+
artist = truncate_string(artist, 30)
|
|
46
|
+
|
|
47
|
+
lines.append(Text.from_markup(f"[bold green] > {title}[/bold green]"))
|
|
48
|
+
lines.append(Text.from_markup(f" [dim]{artist}[/dim]"))
|
|
49
|
+
lines.append(Text.from_markup("---"))
|
|
50
|
+
|
|
51
|
+
end_idx = min(current_index + 11, len(master_playlist))
|
|
52
|
+
|
|
53
|
+
if end_idx == current_index + 1 and len(master_playlist) > 0:
|
|
54
|
+
lines.append(Text.from_markup(" [dim]Fetching more tracks...[/dim]"))
|
|
55
|
+
else:
|
|
56
|
+
for i in range(current_index + 1, end_idx):
|
|
57
|
+
title, artist, _, _ = master_playlist[i]
|
|
58
|
+
title = truncate_string(title, 30)
|
|
59
|
+
artist = truncate_string(artist, 30)
|
|
60
|
+
|
|
61
|
+
lines.append(Text.from_markup(f" {i - current_index}. {title}"))
|
|
62
|
+
lines.append(Text.from_markup(f" [dim]{artist}[/dim]"))
|
|
63
|
+
|
|
64
|
+
if not lines:
|
|
65
|
+
return Panel(Text(" [dim]Loading queue...[/dim]"),
|
|
66
|
+
title="Queue", border_style="dim", padding=(1,1))
|
|
67
|
+
|
|
68
|
+
return Panel(Text("\n").join(lines), title="Queue",
|
|
69
|
+
border_style="dim", padding=(1,1))
|
|
70
|
+
|
|
71
|
+
_progress_layout = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_progress_panel(player):
|
|
75
|
+
global _progress_layout
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
time_pos = player.time_pos or 0
|
|
79
|
+
duration = player.duration or 0
|
|
80
|
+
percent = (time_pos / duration * 100) if duration else 0
|
|
81
|
+
|
|
82
|
+
bar = ProgressBar(total=100, completed=percent, width=None,
|
|
83
|
+
complete_style="green", pulse=duration == 0)
|
|
84
|
+
time_display = Text(f"{format_time(time_pos)} / {format_time(duration)}",
|
|
85
|
+
justify="right")
|
|
86
|
+
icon = "⏸" if player.pause else "▶"
|
|
87
|
+
|
|
88
|
+
if _progress_layout is None:
|
|
89
|
+
_progress_layout = Layout()
|
|
90
|
+
_progress_layout.split_row(
|
|
91
|
+
Layout(name="icon", size=3),
|
|
92
|
+
Layout(name="bar"),
|
|
93
|
+
Layout(name="time", size=15)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_progress_layout["icon"].update(Text(f" {icon} "))
|
|
97
|
+
_progress_layout["bar"].update(bar)
|
|
98
|
+
_progress_layout["time"].update(time_display)
|
|
99
|
+
|
|
100
|
+
return _progress_layout
|
|
101
|
+
except Exception:
|
|
102
|
+
return Layout(Text(""))
|
|
103
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SimpleCache:
|
|
6
|
+
def __init__(self, max_size=100):
|
|
7
|
+
self._cache = {}
|
|
8
|
+
self._max_size = max_size
|
|
9
|
+
|
|
10
|
+
def get(self, key):
|
|
11
|
+
return self._cache.get(key)
|
|
12
|
+
|
|
13
|
+
def set(self, key, value):
|
|
14
|
+
if len(self._cache) >= self._max_size:
|
|
15
|
+
first_key = next(iter(self._cache))
|
|
16
|
+
del self._cache[first_key]
|
|
17
|
+
self._cache[key] = value
|
|
18
|
+
|
|
19
|
+
def clear(self):
|
|
20
|
+
self._cache.clear()
|
|
21
|
+
|
|
22
|
+
panel_cache = SimpleCache(max_size=50)
|
|
23
|
+
art_cache = SimpleCache(max_size=32)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@lru_cache(maxsize=128)
|
|
5
|
+
def format_time(seconds):
|
|
6
|
+
if seconds is None or seconds < 0:
|
|
7
|
+
return "--:--"
|
|
8
|
+
m, s = divmod(int(seconds), 60)
|
|
9
|
+
return f"{m:02}:{s:02}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def truncate_string(text, max_length, suffix="..."):
|
|
13
|
+
if len(text) <= max_length:
|
|
14
|
+
return text
|
|
15
|
+
return text[:max_length] + suffix
|