mopidy-nowplaying 0.1.0__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.
- mopidy_nowplaying/__init__.py +34 -0
- mopidy_nowplaying/ext.conf +7 -0
- mopidy_nowplaying/frontend.py +504 -0
- mopidy_nowplaying/lyrics.py +301 -0
- mopidy_nowplaying/tests/__init__.py +0 -0
- mopidy_nowplaying/tests/test_extension.py +66 -0
- mopidy_nowplaying/tests/test_lyrics.py +89 -0
- mopidy_nowplaying/web.py +288 -0
- mopidy_nowplaying-0.1.0.dist-info/METADATA +123 -0
- mopidy_nowplaying-0.1.0.dist-info/RECORD +14 -0
- mopidy_nowplaying-0.1.0.dist-info/WHEEL +5 -0
- mopidy_nowplaying-0.1.0.dist-info/entry_points.txt +2 -0
- mopidy_nowplaying-0.1.0.dist-info/licenses/LICENSE +202 -0
- mopidy_nowplaying-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pathlib
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from mopidy import config, ext
|
|
6
|
+
|
|
7
|
+
__version__ = version("mopidy-nowplaying")
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Extension(ext.Extension):
|
|
13
|
+
dist_name = "mopidy-nowplaying"
|
|
14
|
+
ext_name = "nowplaying"
|
|
15
|
+
version = __version__
|
|
16
|
+
|
|
17
|
+
def get_default_config(self):
|
|
18
|
+
return config.read(pathlib.Path(__file__).parent / "ext.conf")
|
|
19
|
+
|
|
20
|
+
def get_config_schema(self):
|
|
21
|
+
schema = super().get_config_schema()
|
|
22
|
+
schema["lyrics_enabled"] = config.Boolean(optional=True)
|
|
23
|
+
schema["lyrics_offset_ms"] = config.Integer(optional=True)
|
|
24
|
+
schema["lyrics_font"] = config.String(optional=True)
|
|
25
|
+
return schema
|
|
26
|
+
|
|
27
|
+
def setup(self, registry):
|
|
28
|
+
from .frontend import NowPlayingFrontend
|
|
29
|
+
from .web import make_nowplaying_app
|
|
30
|
+
|
|
31
|
+
registry.add("frontend", NowPlayingFrontend)
|
|
32
|
+
|
|
33
|
+
if "http:app" in registry:
|
|
34
|
+
registry.add("http:app", {"name": "nowplaying", "factory": make_nowplaying_app})
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
|
|
8
|
+
import pygame
|
|
9
|
+
import pykka
|
|
10
|
+
import requests
|
|
11
|
+
from mopidy import core, httpclient
|
|
12
|
+
|
|
13
|
+
from . import Extension
|
|
14
|
+
from .lyrics import Lyrics, LyricsProvider, PositionClock, TrackInfo
|
|
15
|
+
from .web import get_control_state, status_snapshot, update_nowplaying_state
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
BLACK = (0, 0, 0)
|
|
20
|
+
LYRIC_CURRENT = (255, 255, 255)
|
|
21
|
+
LYRIC_NEIGHBOUR = (120, 120, 120)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NowPlayingFrontend(pykka.ThreadingActor, core.CoreListener):
|
|
25
|
+
"""Draws album art, and synced lyrics around it, to the framebuffer.
|
|
26
|
+
|
|
27
|
+
Threads:
|
|
28
|
+
* the Pykka actor thread handles Mopidy events and must never block on
|
|
29
|
+
the network, so art and lyrics are loaded on a small worker pool;
|
|
30
|
+
* a ticker thread sleeps until the next lyric line is due;
|
|
31
|
+
* a display thread waits for the framebuffer to exist.
|
|
32
|
+
``self._lock`` guards everything that ends up on screen. ``_generation``
|
|
33
|
+
is bumped on every track change so late results from a previous track are
|
|
34
|
+
discarded instead of being drawn.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config, core):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.core = core
|
|
40
|
+
self.config = config
|
|
41
|
+
|
|
42
|
+
settings = config.get("nowplaying") or {}
|
|
43
|
+
self.lyrics_enabled = settings.get("lyrics_enabled") is not False
|
|
44
|
+
# Audio-output latency compensation: positive = show lyrics later.
|
|
45
|
+
self.lyrics_offset_ms = settings.get("lyrics_offset_ms") or 0
|
|
46
|
+
self.lyrics_font = settings.get("lyrics_font") or None
|
|
47
|
+
|
|
48
|
+
# relies on mopidy-http to be configured and running
|
|
49
|
+
http = config.get("http") or {}
|
|
50
|
+
host = http.get("hostname") or "127.0.0.1"
|
|
51
|
+
if host in ("::", "0.0.0.0"): # listen-all addresses aren't connectable
|
|
52
|
+
host = "127.0.0.1"
|
|
53
|
+
elif ":" in host: # bare IPv6 literal
|
|
54
|
+
host = f"[{host}]"
|
|
55
|
+
self.base_url = f"http://{host}:{http.get('port', 6680)}"
|
|
56
|
+
|
|
57
|
+
self.session = requests.Session()
|
|
58
|
+
self.session.headers.update({"User-Agent": httpclient.format_user_agent()})
|
|
59
|
+
|
|
60
|
+
# move hardcoded to config
|
|
61
|
+
self.fill_percent = 0.7
|
|
62
|
+
self.framebuffer_number = 0
|
|
63
|
+
self.framebuffer = f"fb{self.framebuffer_number}"
|
|
64
|
+
self.fb_device = f"/dev/{self.framebuffer}"
|
|
65
|
+
|
|
66
|
+
self.surfaceSize = None
|
|
67
|
+
self.scaledSize = None
|
|
68
|
+
self.screen = None
|
|
69
|
+
self._art_layer = None
|
|
70
|
+
self._art_rect = None
|
|
71
|
+
self._font_file = None
|
|
72
|
+
self._fonts = {}
|
|
73
|
+
|
|
74
|
+
self._lock = threading.RLock()
|
|
75
|
+
self._generation = 0
|
|
76
|
+
self._lyrics = None # Lyrics | None
|
|
77
|
+
self._line_index = None # index drawn as current; None = not computed yet
|
|
78
|
+
self._clock = PositionClock()
|
|
79
|
+
self._wake = threading.Event()
|
|
80
|
+
self._shutdown = threading.Event()
|
|
81
|
+
self._provider = None
|
|
82
|
+
self._executor = None
|
|
83
|
+
|
|
84
|
+
# -- lifecycle ------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def on_start(self):
|
|
87
|
+
# 3 workers: art, lyrics, and the next track's lyrics prefetch.
|
|
88
|
+
self._executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="nowplaying")
|
|
89
|
+
if self.lyrics_enabled:
|
|
90
|
+
try:
|
|
91
|
+
self._provider = LyricsProvider(self.session, Extension.get_cache_dir(self.config))
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.exception("mopidy-nowplaying: lyrics disabled, cache directory unavailable")
|
|
94
|
+
threading.Thread(target=self._run_ticker, name="nowplaying-lyrics", daemon=True).start()
|
|
95
|
+
# Spawn a background thread so Mopidy can continue loading
|
|
96
|
+
self.init_thread = threading.Thread(target=self._bg_wait_for_display, daemon=True)
|
|
97
|
+
self.init_thread.start()
|
|
98
|
+
|
|
99
|
+
def on_stop(self):
|
|
100
|
+
self._shutdown.set()
|
|
101
|
+
self._wake.set()
|
|
102
|
+
if self._executor:
|
|
103
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
104
|
+
self._blank()
|
|
105
|
+
|
|
106
|
+
def _bg_wait_for_display(self):
|
|
107
|
+
# Indefinite loop: Check every 5 seconds if fb0 exists
|
|
108
|
+
while not os.path.exists(self.fb_device):
|
|
109
|
+
logger.debug(f"mopidy-nowplaying: {self.fb_device} not found. Waiting...")
|
|
110
|
+
if self._shutdown.wait(5):
|
|
111
|
+
return
|
|
112
|
+
logger.info(f"mopidy-nowplaying: {self.fb_device} detected! Initializing display...")
|
|
113
|
+
|
|
114
|
+
# Once found, perform the actual display initialization
|
|
115
|
+
try:
|
|
116
|
+
with open(f"/sys/class/graphics/{self.framebuffer}/virtual_size") as f:
|
|
117
|
+
width, height = (int(v) for v in f.read().strip().split(","))
|
|
118
|
+
pygame.font.init()
|
|
119
|
+
self._font_file = self._find_font()
|
|
120
|
+
with self._lock:
|
|
121
|
+
self.surfaceSize = (width, height)
|
|
122
|
+
self.scaledSize = (int(width * self.fill_percent), int(height * self.fill_percent))
|
|
123
|
+
self._art_layer = pygame.Surface(self.surfaceSize)
|
|
124
|
+
self._art_layer.fill(BLACK)
|
|
125
|
+
self._art_rect = self._default_art_rect()
|
|
126
|
+
self.screen = pygame.Surface(self.surfaceSize)
|
|
127
|
+
self.render()
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(f"mopidy-nowplaying: Failed to initialize display after {self.fb_device} appeared: {e}")
|
|
130
|
+
|
|
131
|
+
def _find_font(self):
|
|
132
|
+
if self.lyrics_font:
|
|
133
|
+
if os.path.isfile(self.lyrics_font):
|
|
134
|
+
return self.lyrics_font
|
|
135
|
+
logger.warning(f"mopidy-nowplaying: lyrics_font {self.lyrics_font!r} not found")
|
|
136
|
+
# Falls back to pygame's bundled font (Latin only) if nothing matches.
|
|
137
|
+
return pygame.font.match_font("notosans,dejavusans,liberationsans")
|
|
138
|
+
|
|
139
|
+
# -- Mopidy events (actor thread: never block here) ------------------
|
|
140
|
+
|
|
141
|
+
def track_playback_started(self, tl_track):
|
|
142
|
+
track = tl_track.track
|
|
143
|
+
gen = self._begin_track()
|
|
144
|
+
self._clock.set_position(0)
|
|
145
|
+
self._clock.set_running(True)
|
|
146
|
+
self._executor.submit(self._load_art, gen, track)
|
|
147
|
+
info = TrackInfo.from_track(track)
|
|
148
|
+
if info and self._provider:
|
|
149
|
+
self._executor.submit(self._load_lyrics, gen, info, tl_track)
|
|
150
|
+
self._wake.set()
|
|
151
|
+
|
|
152
|
+
def track_playback_paused(self, tl_track, time_position):
|
|
153
|
+
self._clock.set_position(time_position)
|
|
154
|
+
self._clock.set_running(False)
|
|
155
|
+
self._wake.set()
|
|
156
|
+
|
|
157
|
+
def track_playback_resumed(self, tl_track, time_position):
|
|
158
|
+
self._clock.set_position(time_position)
|
|
159
|
+
self._clock.set_running(True)
|
|
160
|
+
self._wake.set()
|
|
161
|
+
|
|
162
|
+
def seeked(self, time_position):
|
|
163
|
+
self._clock.set_position(time_position)
|
|
164
|
+
self._wake.set()
|
|
165
|
+
|
|
166
|
+
def playback_state_changed(self, old_state, new_state):
|
|
167
|
+
state = getattr(new_state, "value", new_state) # str or enum
|
|
168
|
+
if state == "stopped":
|
|
169
|
+
self._begin_track()
|
|
170
|
+
self._clock.set_running(False)
|
|
171
|
+
self._clock.set_position(0)
|
|
172
|
+
self._blank()
|
|
173
|
+
else:
|
|
174
|
+
self._clock.set_running(state == "playing")
|
|
175
|
+
self._wake.set()
|
|
176
|
+
|
|
177
|
+
def _begin_track(self):
|
|
178
|
+
"""Invalidate in-flight loads and drop the previous track's lyrics."""
|
|
179
|
+
with self._lock:
|
|
180
|
+
self._generation += 1
|
|
181
|
+
had_lyrics = self._lyrics is not None
|
|
182
|
+
self._lyrics = None
|
|
183
|
+
self._line_index = None
|
|
184
|
+
gen = self._generation
|
|
185
|
+
if had_lyrics:
|
|
186
|
+
self.render()
|
|
187
|
+
return gen
|
|
188
|
+
|
|
189
|
+
# -- loaders (worker threads) ----------------------------------------
|
|
190
|
+
|
|
191
|
+
def _load_art(self, gen, track):
|
|
192
|
+
data = None
|
|
193
|
+
try:
|
|
194
|
+
url = self._art_url(track)
|
|
195
|
+
if url:
|
|
196
|
+
response = self.session.get(url, timeout=10)
|
|
197
|
+
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
|
|
198
|
+
data = response.content
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.warning(f"mopidy-nowplaying: could not fetch album art: {e}")
|
|
201
|
+
# No art (or a failed fetch) clears the previous album's art on purpose.
|
|
202
|
+
self._set_art(gen, data)
|
|
203
|
+
|
|
204
|
+
def _art_url(self, track):
|
|
205
|
+
images = self.core.library.get_images([track.uri]).get().get(track.uri) or ()
|
|
206
|
+
images = list(images)
|
|
207
|
+
art = None
|
|
208
|
+
if len(images) == 1:
|
|
209
|
+
art = images[0].uri
|
|
210
|
+
else:
|
|
211
|
+
for image in images:
|
|
212
|
+
if image.width is None or image.height is None:
|
|
213
|
+
continue
|
|
214
|
+
if image.height >= 240 and image.width >= 240:
|
|
215
|
+
art = image.uri
|
|
216
|
+
if art is None:
|
|
217
|
+
return None
|
|
218
|
+
if art.startswith("/"): # served by mopidy-http (/local/, /youtube/, ...)
|
|
219
|
+
art = f"{self.base_url}{art}"
|
|
220
|
+
if art.startswith(("http://", "https://")):
|
|
221
|
+
return art
|
|
222
|
+
# TODO: What if it isn't a url? What if it's a file?
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def _load_lyrics(self, gen, info, tl_track):
|
|
226
|
+
lines = self._provider.get(info)
|
|
227
|
+
with self._lock:
|
|
228
|
+
if gen != self._generation:
|
|
229
|
+
return
|
|
230
|
+
if lines:
|
|
231
|
+
self._lyrics = Lyrics(lines)
|
|
232
|
+
self._line_index = None
|
|
233
|
+
if lines:
|
|
234
|
+
self._wake.set()
|
|
235
|
+
else:
|
|
236
|
+
logger.info(f"mopidy-nowplaying: no synced lyrics for {info.title!r}")
|
|
237
|
+
self._prefetch_next(gen, tl_track)
|
|
238
|
+
|
|
239
|
+
def _prefetch_next(self, gen, tl_track):
|
|
240
|
+
"""Warm the on-disk cache for the next track; LRCLIB can be slow."""
|
|
241
|
+
if gen != self._generation:
|
|
242
|
+
return
|
|
243
|
+
try:
|
|
244
|
+
next_tl = self.core.tracklist.next_track(tl_track).get()
|
|
245
|
+
info = TrackInfo.from_track(next_tl.track if next_tl else None)
|
|
246
|
+
if info:
|
|
247
|
+
self._provider.get(info)
|
|
248
|
+
except Exception:
|
|
249
|
+
logger.debug("mopidy-nowplaying: lyrics prefetch skipped", exc_info=True)
|
|
250
|
+
|
|
251
|
+
# -- ticker: redraw only when the current line changes -----------------
|
|
252
|
+
|
|
253
|
+
def _run_ticker(self):
|
|
254
|
+
while not self._shutdown.is_set():
|
|
255
|
+
self._wake.clear()
|
|
256
|
+
timeout = None
|
|
257
|
+
try:
|
|
258
|
+
with self._lock:
|
|
259
|
+
lyrics = self._lyrics
|
|
260
|
+
if lyrics and self._clock.running:
|
|
261
|
+
position = self._clock.now_ms() - self.lyrics_offset_ms
|
|
262
|
+
index = lyrics.index_at(position, lead_ms=500, min_word_dwell_ms=1000)
|
|
263
|
+
with self._lock:
|
|
264
|
+
changed = lyrics is self._lyrics and index != self._line_index
|
|
265
|
+
if changed:
|
|
266
|
+
self._line_index = index
|
|
267
|
+
if changed:
|
|
268
|
+
self.render()
|
|
269
|
+
next_time = lyrics.time_at(index + 1)
|
|
270
|
+
if next_time is not None:
|
|
271
|
+
timeout = max(0.01, (next_time - position) / 1000)
|
|
272
|
+
except Exception:
|
|
273
|
+
logger.exception("mopidy-nowplaying: lyrics ticker error")
|
|
274
|
+
timeout = 1.0
|
|
275
|
+
# Paused / no lyrics: sleep until an event wakes us.
|
|
276
|
+
self._wake.wait(timeout)
|
|
277
|
+
|
|
278
|
+
# -- drawing ------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def write_framebuffer(self):
|
|
281
|
+
if not get_control_state().get("framebuffer", True):
|
|
282
|
+
return
|
|
283
|
+
try:
|
|
284
|
+
with open(self.fb_device, "wb") as f:
|
|
285
|
+
f.write(self.screen.get_buffer())
|
|
286
|
+
logger.debug(f"mopidy-nowplaying: Wrote to {self.fb_device}")
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error(f"mopidy-nowplaying: Failed to write to {self.fb_device}: {e}")
|
|
289
|
+
|
|
290
|
+
def _web_snapshot(self):
|
|
291
|
+
if self.screen is None:
|
|
292
|
+
return
|
|
293
|
+
try:
|
|
294
|
+
png_bytes = self._encode_surface_frame(".png")
|
|
295
|
+
jpeg_bytes = self._encode_surface_frame(".jpg")
|
|
296
|
+
update_nowplaying_state(png_bytes, self._web_status(), jpeg_bytes)
|
|
297
|
+
except Exception:
|
|
298
|
+
logger.exception("mopidy-nowplaying: web snapshot failed")
|
|
299
|
+
|
|
300
|
+
def _encode_surface_frame(self, suffix: str) -> bytes:
|
|
301
|
+
with tempfile.NamedTemporaryFile(suffix=suffix) as tmp:
|
|
302
|
+
pygame.image.save(self.screen, tmp.name)
|
|
303
|
+
tmp.flush()
|
|
304
|
+
tmp.seek(0)
|
|
305
|
+
return tmp.read()
|
|
306
|
+
|
|
307
|
+
def _web_status(self):
|
|
308
|
+
try:
|
|
309
|
+
track = self.core.playback.get_current_track().get()
|
|
310
|
+
state = self.core.playback.get_state().get()
|
|
311
|
+
position = self.core.playback.get_time_position().get()
|
|
312
|
+
except Exception:
|
|
313
|
+
return status_snapshot({"track": None, "state": "stopped", "position_ms": 0})
|
|
314
|
+
|
|
315
|
+
payload = {
|
|
316
|
+
"track": track,
|
|
317
|
+
"state": state,
|
|
318
|
+
"position_ms": position,
|
|
319
|
+
"lyrics": self._lyrics.lines if self._lyrics else [],
|
|
320
|
+
}
|
|
321
|
+
return status_snapshot(payload)
|
|
322
|
+
|
|
323
|
+
def render(self):
|
|
324
|
+
"""Compose art layer + lyrics and push to the framebuffer."""
|
|
325
|
+
with self._lock:
|
|
326
|
+
if self.screen is None:
|
|
327
|
+
return
|
|
328
|
+
try:
|
|
329
|
+
self.screen.blit(self._art_layer, (0, 0))
|
|
330
|
+
if get_control_state().get("lyrics", True):
|
|
331
|
+
self._draw_lyrics()
|
|
332
|
+
except Exception:
|
|
333
|
+
logger.exception("mopidy-nowplaying: render failed")
|
|
334
|
+
self.write_framebuffer()
|
|
335
|
+
self._web_snapshot()
|
|
336
|
+
|
|
337
|
+
def _blank(self):
|
|
338
|
+
with self._lock:
|
|
339
|
+
if self.screen is None:
|
|
340
|
+
return
|
|
341
|
+
self._art_layer.fill(BLACK)
|
|
342
|
+
self._art_rect = self._default_art_rect()
|
|
343
|
+
self.screen.fill(BLACK)
|
|
344
|
+
self.write_framebuffer()
|
|
345
|
+
self._web_snapshot()
|
|
346
|
+
|
|
347
|
+
def _default_art_rect(self):
|
|
348
|
+
rect = pygame.Rect(0, 0, *self.scaledSize)
|
|
349
|
+
rect.center = (self.surfaceSize[0] // 2, self.surfaceSize[1] // 2)
|
|
350
|
+
return rect
|
|
351
|
+
|
|
352
|
+
def _set_art(self, gen, data):
|
|
353
|
+
if self.screen is None:
|
|
354
|
+
return
|
|
355
|
+
# Decode and scale outside the lock; it's the slow part.
|
|
356
|
+
layer = pygame.Surface(self.surfaceSize)
|
|
357
|
+
layer.fill(BLACK)
|
|
358
|
+
rect = self._default_art_rect()
|
|
359
|
+
if data:
|
|
360
|
+
try:
|
|
361
|
+
image, rect = self.transformScaleKeepRatio(
|
|
362
|
+
pygame.image.load(io.BytesIO(data)),
|
|
363
|
+
self.scaledSize,
|
|
364
|
+
)
|
|
365
|
+
# make near-blacks, black for TV display.
|
|
366
|
+
# I think it looks better like that on a screen.
|
|
367
|
+
pygame.transform.threshold(
|
|
368
|
+
image,
|
|
369
|
+
image,
|
|
370
|
+
search_color=(0, 0, 0),
|
|
371
|
+
threshold=(25, 25, 25),
|
|
372
|
+
set_color=(0, 0, 0),
|
|
373
|
+
set_behavior=1,
|
|
374
|
+
inverse_set=1,
|
|
375
|
+
)
|
|
376
|
+
layer.blit(image, rect)
|
|
377
|
+
except Exception as e:
|
|
378
|
+
logger.error(f"Failed to update image: {e}")
|
|
379
|
+
rect = self._default_art_rect()
|
|
380
|
+
with self._lock:
|
|
381
|
+
if gen != self._generation:
|
|
382
|
+
return
|
|
383
|
+
self._art_layer, self._art_rect = layer, rect
|
|
384
|
+
self.render()
|
|
385
|
+
|
|
386
|
+
def transformScaleKeepRatio(self, image, size):
|
|
387
|
+
iwidth, iheight = image.get_size()
|
|
388
|
+
scale = min(size[0] / iwidth, size[1] / iheight)
|
|
389
|
+
new_size = (round(iwidth * scale), round(iheight * scale))
|
|
390
|
+
scaled_image = pygame.transform.smoothscale(image, new_size)
|
|
391
|
+
image_rect = scaled_image.get_rect(center=(self.surfaceSize[0] // 2, self.surfaceSize[1] // 2))
|
|
392
|
+
return scaled_image, image_rect
|
|
393
|
+
|
|
394
|
+
# Lyrics layout: the art is letterboxed in the middle of the screen, so the
|
|
395
|
+
# free bands above and below it hold the text. Previous line (dim) goes in
|
|
396
|
+
# the top band; current (bright) and next (dim) go in the bottom band.
|
|
397
|
+
|
|
398
|
+
def _draw_lyrics(self):
|
|
399
|
+
if not get_control_state().get("lyrics", True):
|
|
400
|
+
return
|
|
401
|
+
lyrics, index = self._lyrics, self._line_index
|
|
402
|
+
if not (self.lyrics_enabled and lyrics) or index is None:
|
|
403
|
+
return
|
|
404
|
+
width, height = self.surfaceSize
|
|
405
|
+
max_w = int(width * 0.9)
|
|
406
|
+
|
|
407
|
+
top_h = max(self._art_rect.top, int(height * 0.1))
|
|
408
|
+
bottom_h = max(height - self._art_rect.bottom, int(height * 0.15))
|
|
409
|
+
|
|
410
|
+
top = self._fit([(lyrics.text(index - 1), 0.032, LYRIC_NEIGHBOUR, 2, False)], max_w, top_h)
|
|
411
|
+
bottom = self._fit(
|
|
412
|
+
[
|
|
413
|
+
(lyrics.text(index), 0.05, LYRIC_CURRENT, 2, True),
|
|
414
|
+
(lyrics.text(index + 1), 0.032, LYRIC_NEIGHBOUR, 1, False),
|
|
415
|
+
],
|
|
416
|
+
max_w,
|
|
417
|
+
bottom_h,
|
|
418
|
+
)
|
|
419
|
+
self._blit_stack(top, 0, top_h)
|
|
420
|
+
self._blit_stack(bottom, height - bottom_h, bottom_h)
|
|
421
|
+
|
|
422
|
+
def _fit(self, items, max_w, max_h):
|
|
423
|
+
"""Render (text, relative_size, colour, max_lines, bold) items to fit max_h."""
|
|
424
|
+
height = self.surfaceSize[1]
|
|
425
|
+
gap = int(height * 0.012)
|
|
426
|
+
scale = 1.0
|
|
427
|
+
while True:
|
|
428
|
+
blocks = [
|
|
429
|
+
self._text_block(
|
|
430
|
+
text,
|
|
431
|
+
max(12, int(height * rel * scale)),
|
|
432
|
+
colour,
|
|
433
|
+
max_w,
|
|
434
|
+
max_lines,
|
|
435
|
+
bold,
|
|
436
|
+
)
|
|
437
|
+
for text, rel, colour, max_lines, bold in items
|
|
438
|
+
if text
|
|
439
|
+
]
|
|
440
|
+
total = sum(b.get_height() for b in blocks) + gap * max(0, len(blocks) - 1)
|
|
441
|
+
if total <= max_h or scale < 0.4:
|
|
442
|
+
return blocks
|
|
443
|
+
scale *= 0.85
|
|
444
|
+
|
|
445
|
+
def _blit_stack(self, blocks, y, band_h):
|
|
446
|
+
if not blocks:
|
|
447
|
+
return
|
|
448
|
+
gap = int(self.surfaceSize[1] * 0.012)
|
|
449
|
+
total = sum(b.get_height() for b in blocks) + gap * (len(blocks) - 1)
|
|
450
|
+
y += max(0, (band_h - total) // 2)
|
|
451
|
+
for block in blocks:
|
|
452
|
+
self.screen.blit(block, ((self.surfaceSize[0] - block.get_width()) // 2, y))
|
|
453
|
+
y += block.get_height() + gap
|
|
454
|
+
|
|
455
|
+
def _text_block(self, text, size, colour, max_w, max_lines, bold=False):
|
|
456
|
+
"""Word-wrapped, centred text as one surface; shrinks to fit max_lines."""
|
|
457
|
+
while True:
|
|
458
|
+
font = self._font(size, bold=bold)
|
|
459
|
+
lines = self._wrap(font, text, max_w)
|
|
460
|
+
if len(lines) <= max_lines or size <= 14:
|
|
461
|
+
break
|
|
462
|
+
size = int(size * 0.9)
|
|
463
|
+
rendered = [font.render(line, True, colour) for line in lines]
|
|
464
|
+
block = pygame.Surface(
|
|
465
|
+
(max(s.get_width() for s in rendered), sum(s.get_height() for s in rendered)),
|
|
466
|
+
pygame.SRCALPHA,
|
|
467
|
+
)
|
|
468
|
+
y = 0
|
|
469
|
+
for surface in rendered:
|
|
470
|
+
block.blit(surface, ((block.get_width() - surface.get_width()) // 2, y))
|
|
471
|
+
y += surface.get_height()
|
|
472
|
+
return block
|
|
473
|
+
|
|
474
|
+
def _font(self, size, bold=False):
|
|
475
|
+
key = (size, bold)
|
|
476
|
+
font = self._fonts.get(key)
|
|
477
|
+
if font is None:
|
|
478
|
+
font = pygame.font.Font(self._font_file, size)
|
|
479
|
+
font.set_bold(bold)
|
|
480
|
+
self._fonts[key] = font
|
|
481
|
+
return font
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _wrap(font, text, max_w):
|
|
485
|
+
lines, current = [], ""
|
|
486
|
+
for word in text.split():
|
|
487
|
+
trial = f"{current} {word}".strip()
|
|
488
|
+
if font.size(trial)[0] <= max_w:
|
|
489
|
+
current = trial
|
|
490
|
+
continue
|
|
491
|
+
if current:
|
|
492
|
+
lines.append(current)
|
|
493
|
+
current = ""
|
|
494
|
+
# A single word wider than the screen (or unspaced CJK): split by character.
|
|
495
|
+
while font.size(word)[0] > max_w and len(word) > 1:
|
|
496
|
+
cut = len(word)
|
|
497
|
+
while cut > 1 and font.size(word[:cut])[0] > max_w:
|
|
498
|
+
cut -= 1
|
|
499
|
+
lines.append(word[:cut])
|
|
500
|
+
word = word[cut:]
|
|
501
|
+
current = word
|
|
502
|
+
if current:
|
|
503
|
+
lines.append(current)
|
|
504
|
+
return lines or [text]
|