v2 Phase 5: Now Playing — Windows media session on the watch

The daemon reads the Windows "now playing" media session (WinRT SMTC) and
adds np/nt/na to the BLE payload; the watch shows a play/pause glyph, a
scrolling title, artist and status. Best-effort and token-independent, so
it works even when the rate-limit data is unavailable.

Cyrillic titles: Styrene B has no Cyrillic glyphs, so the title/artist use
new composite fonts (font_styrene_cyr_{28,20}) — Latin from Styrene B,
Cyrillic + typographic punctuation from Montserrat, merged by lv_font_conv.
Latin titles stay on-brand; only Cyrillic falls back to Montserrat.

- daemon: read_now_playing() via winrt-Windows.Media.Control (lazy import,
  never raises; PLAYING->1, PAUSED->2, else 0; title/artist truncated,
  empty fields omitted)
- firmware: UsageData np_state/np_title/np_artist + parser; the real Now
  Playing screen replaces the shared stub (SCREEN_NOWPLAYING)
- fonts: assets/Montserrat-Medium.ttf, font_styrene_cyr_{28,20}.c
- tools: patch_lvgl9_font.py (automates the 4 LVGL 9 font patches),
  screenshot_win.py (Windows serial screenshot via pyserial + Pillow)
- README: document Cyrillic composite font generation

Verified on hardware (waveshare_amoled_206): idle state, a live Latin
title (scrolling), and Cyrillic (Prohozhdenie / Dunduk) all render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-06-21 00:41:15 +03:00
co-authored by Claude Opus 4.8
parent e682b43735
commit b8d4daa03f
10 changed files with 9293 additions and 1 deletions
+59
View File
@@ -527,6 +527,58 @@ async def refresh_token_if_needed(force: bool = False) -> bool:
return True
# --- Now Playing (Phase 5) ----------------------------------------------------
# Best-effort read of the Windows "now playing" media session (System Media
# Transport Controls) so the watch can show the current track. Pure local WinRT —
# no network, no token — so it works even when the rate-limit data is unavailable.
# Any failure degrades to {"np": 0} (nothing playing) and never disturbs the loop.
NP_MAX_LEN = 60 # truncate title/artist so the BLE payload stays comfortably small
async def read_now_playing() -> dict:
"""Return compact now-playing fields for the BLE payload::
{"np": 0|1|2, "nt": <title>, "na": <artist>}
np: 0 = nothing playing, 1 = playing, 2 = paused. nt/na are omitted when
empty. Never raises — the daemon must keep polling even if WinRT hiccups
(and the winrt media package may simply be absent on some installs).
"""
try:
from winrt.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PB,
)
except ImportError:
return {"np": 0}
try:
mgr = await MediaManager.request_async()
sess = mgr.get_current_session()
if sess is None:
return {"np": 0}
status = sess.get_playback_info().playback_status
if status == PB.PLAYING:
np = 1
elif status == PB.PAUSED:
np = 2
else:
np = 0 # stopped / closed / changing -> treat as nothing playing
out: dict = {"np": np}
if np:
info = await sess.try_get_media_properties_async()
title = (info.title or "").strip()
artist = (info.artist or "").strip()
if title:
out["nt"] = title[:NP_MAX_LEN]
if artist:
out["na"] = artist[:NP_MAX_LEN]
return out
except Exception as e: # WinRT can surface assorted OSError/RuntimeError types
log(f"Now-playing read failed: {e!r}")
return {"np": 0}
async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
"""Return when any of `events` is set, or after `timeout` seconds.
@@ -655,6 +707,13 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
else:
payload["ok"] = False # rate-limit unknown -> watch usage view goes idle
# Now Playing (Phase 5) — best-effort Windows media session. Local
# only, so it's attached regardless of the rate-limit "ok" above.
try:
payload.update(await read_now_playing())
except Exception as e: # never let media reading break the poll loop
log(f"Now-playing skipped: {e!r}")
if await session.write_payload(payload):
last_poll = time.time()
used_successfully = True