105 lines
3.6 KiB
Python
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()
|