Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions custom_components/beatify/game/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The MediaPlayerProtocol defined in custom_components/beatify/game/protocols.py is missing the seek_forward method. Since _media_player_service is typed as MediaPlayerProtocol, this call will fail static type checking and violates the service contract. Please update the protocol definition to include this new method.

return False

async def play_deferred_song(self, song: dict) -> bool:
"""Play a song that was deferred for intro splash (#321).

Expand Down
12 changes: 12 additions & 0 deletions custom_components/beatify/server/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is safer to validate that seconds is an integer before passing it to the game state, similar to how the year is validated in the submit handler. This prevents potential type errors if a client sends a non-integer value.

Suggested change
seconds = data.get("seconds", 10)
seconds = data.get("seconds", 10)
if not isinstance(seconds, int):
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:
Expand Down
35 changes: 35 additions & 0 deletions custom_components/beatify/services/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Comment on lines +564 to +574
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Home Assistant, the media_position attribute is a snapshot of the position at the time recorded in media_position_updated_at. If the player is currently playing, simply adding seconds to the stale media_position will result in an inaccurate seek (it will effectively seek forward from where the song was when the state last updated, not from the current real-time position). You should calculate the current position by accounting for the time elapsed since the last update.

            current_pos = state.attributes.get("media_position", 0) or 0
            if state.state == "playing":
                updated_at = state.attributes.get("media_position_updated_at")
                if updated_at:
                    import homeassistant.util.dt as dt_util
                    current_pos += (dt_util.utcnow() - updated_at).total_seconds()
            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.
Expand Down
7 changes: 4 additions & 3 deletions custom_components/beatify/www/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -514,9 +514,10 @@ <h2 class="section-header"><span class="section-icon" aria-hidden="true">▶️<
<button id="admin-stop-song" class="btn btn-secondary btn-compact">
<span aria-hidden="true">⏹</span> Stop
</button>
<button id="admin-vol-down" class="btn btn-secondary btn-compact">🔉</button>
<button id="admin-vol-up" class="btn btn-secondary btn-compact">🔊</button>
<button id="admin-end-game-playing" class="btn btn-danger btn-compact">End Game</button>
<button id="admin-seek-forward" class="btn btn-secondary btn-compact" title="Seek forward 10 seconds">⏩ +10s</button>
<button id="admin-vol-down" class="btn btn-secondary btn-compact" title="Volume down">🔉</button>
<button id="admin-vol-up" class="btn btn-secondary btn-compact" title="Volume up">🔊</button>
<button id="admin-end-game-playing" class="btn btn-danger btn-compact" title="End game">End Game</button>
Comment on lines +517 to +520
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new title attributes should use the project's internationalization system (e.g., data-i18n-title) instead of hardcoded English strings to remain consistent with the rest of the UI. Additionally, the Stop button is missing a tooltip, which contradicts the PR summary stating tooltips were added to all buttons.

</div>

<!-- Player game UI (only shown if admin joined as player) -->
Expand Down
7 changes: 7 additions & 0 deletions custom_components/beatify/www/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ document.addEventListener('DOMContentLoaded', async () => {

// Issue #477: Wire game phase control buttons
document.getElementById('admin-stop-song')?.addEventListener('click', adminStopSong);
document.getElementById('admin-seek-forward')?.addEventListener('click', adminSeekForward);
document.getElementById('admin-vol-down')?.addEventListener('click', adminVolumeDown);
document.getElementById('admin-vol-up')?.addEventListener('click', adminVolumeUp);
document.getElementById('admin-end-game-playing')?.addEventListener('click', endGame);
Expand Down Expand Up @@ -3025,6 +3026,12 @@ function adminStopSong() {
}
}

function adminSeekForward() {
if (adminWs && adminWs.readyState === WebSocket.OPEN) {
adminWs.send(JSON.stringify({ type: 'admin', action: 'seek_forward', seconds: 10 }));
}
}

function adminVolumeUp() {
if (adminWs && adminWs.readyState === WebSocket.OPEN) {
adminWs.send(JSON.stringify({ type: 'admin', action: 'set_volume', direction: 'up' }));
Expand Down
Loading