diff --git a/custom_components/beatify/game/state.py b/custom_components/beatify/game/state.py index 5de6242..b29665b 100644 --- a/custom_components/beatify/game/state.py +++ b/custom_components/beatify/game/state.py @@ -1849,6 +1849,12 @@ async def set_volume_on_player(self, level: float) -> bool: return await self._media_player_service.set_volume(level) return False + async def seek_forward(self, seconds: int) -> bool: + """Seek media player forward by given seconds (#498).""" + if self._media_player_service: + return await self._media_player_service.seek_forward(seconds) + return False + async def play_deferred_song(self, song: dict) -> bool: """Play a song that was deferred for intro splash (#321). diff --git a/custom_components/beatify/server/websocket.py b/custom_components/beatify/server/websocket.py index 4aec423..54029a1 100644 --- a/custom_components/beatify/server/websocket.py +++ b/custom_components/beatify/server/websocket.py @@ -421,6 +421,7 @@ async def _handle_admin( "next_round": self._admin_next_round, "stop_song": self._admin_stop_song, "set_volume": self._admin_set_volume, + "seek_forward": self._admin_seek_forward, "end_game": self._admin_end_game, "dismiss_game": self._admin_dismiss_game, "rematch_game": self._admin_rematch_game, @@ -646,6 +647,17 @@ async def _admin_set_volume( } ) + async def _admin_seek_forward( + self, ws: web.WebSocketResponse, data: dict, game_state: GameState + ) -> None: + """Handle admin seek_forward action (#498).""" + if game_state.phase not in (GamePhase.PLAYING, GamePhase.REVEAL): + return + seconds = data.get("seconds", 10) + success = await game_state.seek_forward(seconds) + if success: + _LOGGER.info("Media seeked forward %ds", seconds) + async def _admin_end_game( self, ws: web.WebSocketResponse, data: dict, game_state: GameState ) -> None: diff --git a/custom_components/beatify/services/media_player.py b/custom_components/beatify/services/media_player.py index 4effc94..9326452 100644 --- a/custom_components/beatify/services/media_player.py +++ b/custom_components/beatify/services/media_player.py @@ -5,6 +5,7 @@ import asyncio import logging import sys +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any if sys.version_info >= (3, 11): @@ -551,6 +552,40 @@ async def set_volume(self, level: float) -> bool: self._record_error("MEDIA_PLAYER_ERROR", f"Failed to set volume: {err}") return False + async def seek_forward(self, seconds: int) -> bool: + """Seek media forward by given seconds (#498). + + Reads current position from HA state and seeks to position + seconds. + """ + try: + state = self._hass.states.get(self._entity_id) + if not state: + return False + current_pos = state.attributes.get("media_position", 0) or 0 + # Adjust for stale cached position — HA only updates + # media_position at media_position_updated_at + updated_at = state.attributes.get("media_position_updated_at") + if updated_at: + if isinstance(updated_at, str): + updated_at = datetime.fromisoformat(updated_at) + elapsed = (datetime.now(timezone.utc) - updated_at).total_seconds() + if elapsed > 0: + current_pos += elapsed + new_pos = current_pos + seconds + await self._hass.services.async_call( + "media_player", + "media_seek", + { + "entity_id": self._entity_id, + "seek_position": new_pos, + }, + ) + return True # noqa: TRY300 + except Exception as err: # noqa: BLE001 + _LOGGER.error("Failed to seek media: %s", err) # noqa: TRY400 + self._record_error("MEDIA_PLAYER_ERROR", f"Failed to seek: {err}") + return False + def is_available(self) -> bool: """ Check if media player is available. diff --git a/custom_components/beatify/www/admin.html b/custom_components/beatify/www/admin.html index 1ef6347..3b0eb65 100644 --- a/custom_components/beatify/www/admin.html +++ b/custom_components/beatify/www/admin.html @@ -514,9 +514,10 @@