0b4f739d12
- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking, notifications (progress + alerts), telemetry, and system tray .exe build - Test suite covering notifications, audio mixer, media, telemetry, and API - E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures) - Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin) - Design decision records in .pi/docs/design/ - PyInstaller build script for server .exe
167 lines
5.9 KiB
Python
167 lines
5.9 KiB
Python
"""Windows Media playback tracking via GSMTC (Global System Media Transport Controls).
|
|
|
|
Uses the winsdk library to access the Windows Media API without polling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import io
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("paperdash.media")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data Models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class MediaState:
|
|
"""Current media playback state."""
|
|
|
|
title: str = ""
|
|
artist: str = ""
|
|
album: str = ""
|
|
artwork_base64: str = ""
|
|
playback_position: float = 0.0 # seconds
|
|
playback_duration: float = 0.0 # seconds
|
|
playback_percentage: float = 0.0 # 0.0 - 1.0
|
|
is_playing: bool = False
|
|
is_paused: bool = False
|
|
is_stopped: bool = True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Media Manager
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MediaManager:
|
|
"""Tracks Windows media playback via GSMTC."""
|
|
|
|
def __init__(self) -> None:
|
|
self._state = MediaState()
|
|
self._poll_interval: float = 2.0
|
|
|
|
async def run(self) -> None:
|
|
"""Periodically poll media state from GSMTC."""
|
|
while True:
|
|
try:
|
|
self._state = await self._poll_gsmtc()
|
|
except Exception as e:
|
|
logger.error(f"Media poll error: {e}")
|
|
await asyncio.sleep(self._poll_interval)
|
|
|
|
async def _poll_gsmtc(self) -> MediaState:
|
|
"""Read current media state from Windows GSMTC."""
|
|
try:
|
|
from winsdk.windows.media.control import ( # type: ignore
|
|
GlobalSystemMediaTransportControlsSessionManager as Manager,
|
|
)
|
|
|
|
session_manager = await Manager.request_async()
|
|
session = session_manager.get_current_session()
|
|
|
|
if session is None:
|
|
return MediaState()
|
|
|
|
info = await session.try_get_media_properties_async()
|
|
|
|
# Convert artwork to base64
|
|
artwork_b64 = ""
|
|
if info.thumbnail:
|
|
try:
|
|
stream = await info.thumbnail.open_async()
|
|
reader = await asyncio.to_thread(
|
|
__import__("Windows.Storage.Streams").DataTypes.DataReader,
|
|
stream,
|
|
)
|
|
buffer = await asyncio.to_thread(reader.load_async, stream.size)
|
|
data = bytearray(buffer)
|
|
buf = io.BytesIO(data)
|
|
artwork_b64 = base64.b64encode(buf.read()).decode("ascii")
|
|
except Exception:
|
|
artwork_b64 = ""
|
|
|
|
# Compute percentage
|
|
props = session.get_playback_info()
|
|
pos = props.position.total_seconds() if props.position else 0.0
|
|
duration = info.duration.total_seconds() if info.duration else 0.0
|
|
percentage = (pos / duration * 100) if duration > 0 else 0.0
|
|
|
|
return MediaState(
|
|
title=info.title or "",
|
|
artist=info.artist or "",
|
|
album=info.album or "",
|
|
artwork_base64=artwork_b64,
|
|
playback_position=pos,
|
|
playback_duration=duration,
|
|
playback_percentage=percentage,
|
|
is_playing=session.playback_status.value == 0, # Playing
|
|
is_paused=session.playback_status.value == 1, # Paused
|
|
is_stopped=session.playback_status.value == 2, # Stopped
|
|
)
|
|
except ImportError:
|
|
logger.warning("winsdk not installed — media tracking unavailable")
|
|
return MediaState()
|
|
except Exception as e:
|
|
logger.error(f"GSMTC error: {e}")
|
|
return MediaState()
|
|
|
|
def get_state(self) -> dict[str, Any]:
|
|
"""Return current media state as a serializable dict."""
|
|
return {
|
|
"title": self._state.title,
|
|
"artist": self._state.artist,
|
|
"album": self._state.album,
|
|
"artwork_base64": self._state.artwork_base64,
|
|
"playback_position": self._state.playback_position,
|
|
"playback_duration": self._state.playback_duration,
|
|
"playback_percentage": self._state.playback_percentage,
|
|
"is_playing": self._state.is_playing,
|
|
"is_paused": self._state.is_paused,
|
|
"is_stopped": self._state.is_stopped,
|
|
}
|
|
|
|
async def play(self) -> None:
|
|
"""Send play command to current media session."""
|
|
await self._send_command("play")
|
|
|
|
async def pause(self) -> None:
|
|
"""Send pause command to current media session."""
|
|
await self._send_command("pause")
|
|
|
|
async def skip(self) -> None:
|
|
"""Send next track command."""
|
|
await self._send_command("next")
|
|
|
|
async def previous(self) -> None:
|
|
"""Send previous track command."""
|
|
await self._send_command("previous")
|
|
|
|
async def _send_command(self, command: str) -> None:
|
|
"""Send a media transport command via GSMTC."""
|
|
try:
|
|
from winsdk.windows.media.control import ( # type: ignore
|
|
GlobalSystemMediaTransportControlsSessionManager as Manager,
|
|
)
|
|
|
|
session_manager = await Manager.request_async()
|
|
session = session_manager.get_current_session()
|
|
if session:
|
|
if command == "play":
|
|
await session.try_play_async()
|
|
elif command == "pause":
|
|
await session.try_pause_async()
|
|
elif command == "next":
|
|
await session.try_skip_next_async()
|
|
elif command == "previous":
|
|
await session.try_skip_previous_async()
|
|
except Exception as e:
|
|
logger.error(f"Media command '{command}' failed: {e}")
|