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.
@@ -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,7 @@
1
+ [nowplaying]
2
+ enabled = true
3
+ lyrics_enabled = true
4
+ lyrics_offset_ms = 0
5
+ lyrics_font =
6
+ # TODO: Add additional config values and their default values here, or remove
7
+ # this comment entirely.
@@ -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]