Files
musicmouse/python-backend/musicmouse/services/web/hub.py
2026-08-27 12:32:20 +02:00

105 lines
3.6 KiB
Python

"""Getting state out to every open browser tab.
State events only fire on *change*, so a tab that connects halfway through a track
would otherwise sit there knowing nothing. The fix is the one
:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting
broker: send a full snapshot on connect, then deltas.
Position is the exception to "everything goes on the bus". A progress bar wants it
twice a second; an event at that rate would flood the queue, the MQTT service and the
log to serve one front-end. So the hub reads it straight off the player on its own
timer, and only while something is playing.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import Any
from fastapi import WebSocket
from musicmouse.app import App
from musicmouse.events import Event, StateEvent
from musicmouse.services.web.state import snapshot
_log = logging.getLogger(__name__)
__all__ = ["StateHub"]
#: Twice a second: smooth enough once the client interpolates between frames, cheap
#: enough to leave running.
POSITION_INTERVAL = 0.5
class StateHub:
def __init__(self, app: App) -> None:
self.app = app
self._clients: set[WebSocket] = set()
self._unsubscribe: Any = None
# ------------------------------------------------------------------- lifecycle
def start(self) -> None:
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state)
def stop(self) -> None:
if self._unsubscribe is not None:
self._unsubscribe()
self._unsubscribe = None
async def run(self) -> None:
"""Push the playback position while anything is playing."""
while True:
await asyncio.sleep(POSITION_INTERVAL)
if not self._clients or not self.app.player.is_playing:
continue
await self.broadcast(
{
"type": "position",
"position": self.app.player.position,
"duration": self.app.player.duration,
}
)
# --------------------------------------------------------------------- clients
async def connect(self, socket: WebSocket) -> None:
await socket.accept()
self._clients.add(socket)
await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()})
def disconnect(self, socket: WebSocket) -> None:
self._clients.discard(socket)
# ------------------------------------------------------------------ publishing
async def broadcast(self, message: dict[str, Any]) -> None:
if not self._clients:
return
payload = json.dumps(message)
for socket in list(self._clients):
try:
await socket.send_text(payload)
except Exception:
# A tab that closed mid-send is normal, not an error worth logging loudly.
_log.debug("Dropping a websocket client that went away")
self._clients.discard(socket)
async def broadcast_state(self) -> None:
await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()})
async def broadcast_library(self) -> None:
await self.broadcast({"type": "library"})
# ------------------------------------------------------------------ internals
async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None:
with contextlib.suppress(Exception):
await socket.send_text(json.dumps(message))
async def _on_state(self, _event: Event) -> None:
await self.broadcast_state()