Skip to content
Closed
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
10 changes: 10 additions & 0 deletions custom_components/beatify/game/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1833,6 +1833,16 @@ async def stop_media(self) -> None:
if self._media_player_service:
await self._media_player_service.stop()

async def seek_forward_on_player(self, seconds: float = 10.0) -> bool:
"""Seek forward on the media player.

Returns:
True if successful, False if failed or no media player.
"""
if self._media_player_service:
return await self._media_player_service.seek_forward(seconds)
return False

async def set_volume_on_player(self, level: float) -> bool:
"""Apply volume level to the media player (#321).

Expand Down
35 changes: 35 additions & 0 deletions custom_components/beatify/server/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ async def _handle_admin(
"confirm_intro_splash": self._admin_confirm_intro_splash,
"set_party_lights": self._admin_set_party_lights,
"toggle_party_lights": self._admin_toggle_party_lights,
"seek_forward": self._admin_seek_forward,
}
handler = admin_handlers.get(action)
if handler:
Expand Down Expand Up @@ -581,6 +582,40 @@ 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."""
if game_state.phase not in (GamePhase.PLAYING, GamePhase.REVEAL):
await ws.send_json(
{
"type": "error",
"code": ERR_INVALID_ACTION,
"message": "Can only seek during playback",
}
)
return

try:
seconds = float(data.get("seconds", 10.0))
except (ValueError, TypeError):
await ws.send_json(
{
"type": "error",
"code": ERR_INVALID_ACTION,
"message": "Invalid value for 'seconds'",
}
)
return

success = await game_state.seek_forward_on_player(seconds)
if not success:
_LOGGER.warning("Failed to seek forward")

_LOGGER.info("Admin seeked forward %.0fs", seconds)

await ws.send_json({"type": "seek_performed", "seconds": seconds})

async def _admin_end_game(
self, ws: web.WebSocketResponse, data: dict, game_state: GameState
) -> None:
Expand Down
59 changes: 59 additions & 0 deletions custom_components/beatify/services/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,65 @@ 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: float = 10.0) -> bool:
"""
Seek forward by a number of seconds from the current position.

Args:
seconds: Number of seconds to skip forward (default 10)

Returns:
True if successful, False otherwise

"""
try:
state = self._hass.states.get(self._entity_id)
if not state:
_LOGGER.warning("Cannot seek: media player not found")
return False

snapshot_position = state.attributes.get("media_position")
if snapshot_position is None:
_LOGGER.warning("Cannot seek: no media_position available")
return False

# HA's media_position is a snapshot from media_position_updated_at.
# The actual position = snapshot + elapsed time since that update.
elapsed = 0.0
if state.state == "playing":
updated_at = state.attributes.get("media_position_updated_at")
if updated_at:
from datetime import datetime, timezone # noqa: PLC0415

try:
if isinstance(updated_at, str):
updated_dt = datetime.fromisoformat(updated_at)
else:
updated_dt = updated_at
now = datetime.now(timezone.utc)
elapsed = max(0.0, (now - updated_dt).total_seconds())
except (ValueError, TypeError):
elapsed = 0.0

current_position = float(snapshot_position) + elapsed
new_position = current_position + seconds
await self._hass.services.async_call(
"media_player",
"media_seek",
{
"entity_id": self._entity_id,
"seek_position": new_position,
},
)
_LOGGER.info(
"Seeked forward %.0fs to position %.1f", seconds, new_position
)
return True # noqa: TRY300
except Exception as err: # noqa: BLE001
_LOGGER.error("Failed to seek forward: %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
9 changes: 9 additions & 0 deletions custom_components/beatify/www/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,18 @@
"startNewGame": "Neues Spiel starten",
"stop": "Stop",
"stopSong": "Stop",
"stopTooltip": "Aktuellen Song stoppen",
"next": "Weiter",
"nextRound": "Nächste Runde",
"nextTooltip": "Nächste Runde / Überspringen",
"end": "Ende",
"endTooltip": "Spiel beenden",
"volumeDown": "Leiser",
"volumeDownTooltip": "Leiser",
"volumeUp": "Lauter",
"volumeUpTooltip": "Lauter",
"seekForward": "+10s",
"seekForwardTooltip": "10 Sekunden vorspulen",
"volume": "Lautstärke",
"controls": "Spielsteuerung",
"status": "Status",
Expand Down
9 changes: 9 additions & 0 deletions custom_components/beatify/www/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,18 @@
"startNewGame": "Start New Game",
"stop": "Stop",
"stopSong": "Stop",
"stopTooltip": "Stop current song",
"next": "Next",
"nextRound": "Next Round",
"nextTooltip": "Next round / Skip",
"end": "End",
"endTooltip": "End game",
"volumeDown": "Quieter",
"volumeDownTooltip": "Volume down",
"volumeUp": "Louder",
"volumeUpTooltip": "Volume up",
"seekForward": "+10s",
"seekForwardTooltip": "Skip 10 seconds forward",
"volume": "Volume",
"controls": "Game Controls",
"status": "Status",
Expand Down
9 changes: 9 additions & 0 deletions custom_components/beatify/www/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,18 @@
"startNewGame": "Iniciar nuevo juego",
"stop": "Detener",
"stopSong": "Detener",
"stopTooltip": "Detener canción actual",
"next": "Siguiente",
"nextRound": "Siguiente ronda",
"nextTooltip": "Siguiente ronda / Saltar",
"end": "Fin",
"endTooltip": "Terminar juego",
"volumeDown": "Menos",
"volumeDownTooltip": "Bajar volumen",
"volumeUp": "Más",
"volumeUpTooltip": "Subir volumen",
"seekForward": "+10s",
"seekForwardTooltip": "Saltar 10 segundos adelante",
"volume": "Volumen",
"controls": "Controles del juego",
"status": "Estado",
Expand Down
9 changes: 9 additions & 0 deletions custom_components/beatify/www/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,18 @@
"startNewGame": "Lancer une nouvelle partie",
"stop": "Arrêter",
"stopSong": "Arrêter",
"stopTooltip": "Arrêter la chanson en cours",
"next": "Suivant",
"nextRound": "Manche suivante",
"nextTooltip": "Manche suivante / Passer",
"end": "Fin",
"endTooltip": "Terminer la partie",
"volumeDown": "Moins",
"volumeDownTooltip": "Baisser le volume",
"volumeUp": "Plus",
"volumeUpTooltip": "Augmenter le volume",
"seekForward": "+10s",
"seekForwardTooltip": "Avancer de 10 secondes",
"volume": "Volume",
"controls": "Contrôles du jeu",
"status": "Statut",
Expand Down
9 changes: 9 additions & 0 deletions custom_components/beatify/www/i18n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,18 @@
"startNewGame": "Nieuw spel starten",
"stop": "Stop",
"stopSong": "Stop",
"stopTooltip": "Huidig nummer stoppen",
"next": "Volgende",
"nextRound": "Volgende ronde",
"nextTooltip": "Volgende ronde / Overslaan",
"end": "Beëindigen",
"endTooltip": "Spel beëindigen",
"volumeDown": "Zachter",
"volumeDownTooltip": "Zachter",
"volumeUp": "Harder",
"volumeUpTooltip": "Harder",
"seekForward": "+10s",
"seekForwardTooltip": "10 seconden vooruitspoelen",
"volume": "Volume",
"controls": "Spelbediening",
"status": "Status",
Expand Down
29 changes: 29 additions & 0 deletions custom_components/beatify/www/js/player-game.js
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,7 @@ export function showFloatingReaction(senderName, emoji) {
*/
export function updateControlBarState(phase) {
var stopBtn = document.getElementById('stop-song-btn');
var seekBtn = document.getElementById('seek-forward-btn');
var nextBtn = document.getElementById('next-round-admin-btn');

if (phase === 'PLAYING') {
Expand All @@ -1526,6 +1527,10 @@ export function updateControlBarState(phase) {
stopBtn.classList.remove('is-disabled');
stopBtn.disabled = false;
}
if (seekBtn) {
seekBtn.classList.remove('is-disabled', 'hidden');
seekBtn.disabled = false;
}
if (nextBtn) {
nextBtn.classList.remove('is-disabled');
nextBtn.disabled = false;
Expand All @@ -1537,13 +1542,21 @@ export function updateControlBarState(phase) {
stopBtn.classList.remove('is-disabled');
stopBtn.disabled = false;
}
if (seekBtn) {
seekBtn.classList.remove('hidden', 'is-disabled');
seekBtn.disabled = false;
}
if (nextBtn) {
nextBtn.classList.remove('is-disabled');
nextBtn.disabled = false;
var labelEl = nextBtn.querySelector('.control-label');
if (labelEl) labelEl.textContent = utils.t('game.next');
}
} else {
if (seekBtn) {
seekBtn.classList.add('hidden');
seekBtn.disabled = true;
}
if (nextBtn) {
nextBtn.classList.add('is-disabled');
nextBtn.disabled = true;
Expand Down Expand Up @@ -1598,6 +1611,20 @@ function handleVolumeUp() {
}));
}

/**
* Handle Seek Forward button (+10s)
*/
function handleSeekForward() {
if (!debounceAdminAction()) return;
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;

state.ws.send(JSON.stringify({
type: 'admin',
action: 'seek_forward',
seconds: 10
}));
}

/**
* Handle Volume Down button
*/
Expand Down Expand Up @@ -1721,12 +1748,14 @@ export function setupAdminControlBar() {
var stopBtn = document.getElementById('stop-song-btn');
var volUpBtn = document.getElementById('volume-up-btn');
var volDownBtn = document.getElementById('volume-down-btn');
var seekBtn = document.getElementById('seek-forward-btn');
var nextBtn = document.getElementById('next-round-admin-btn');
var endBtn = document.getElementById('end-game-btn');

if (stopBtn) stopBtn.addEventListener('click', handleStopSong);
if (volUpBtn) volUpBtn.addEventListener('click', handleVolumeUp);
if (volDownBtn) volDownBtn.addEventListener('click', handleVolumeDown);
if (seekBtn) seekBtn.addEventListener('click', handleSeekForward);
if (nextBtn) nextBtn.addEventListener('click', handleNextRoundFromBar);
if (endBtn) endBtn.addEventListener('click', handleEndGame);
}
Expand Down
6 changes: 3 additions & 3 deletions custom_components/beatify/www/js/player.bundle.min.js

Large diffs are not rendered by default.

16 changes: 11 additions & 5 deletions custom_components/beatify/www/player.html
Original file line number Diff line number Diff line change
Expand Up @@ -591,21 +591,27 @@ <h1 class="end-title" data-i18n="leaderboard.gameOver">Game Over!</h1>
<div id="admin-control-bar" class="admin-control-bar hidden">
<!-- Volume feedback indicator (Story 6.4) -->
<div id="volume-indicator" class="volume-indicator hidden">🔊 50%</div>
<button id="stop-song-btn" class="control-btn" type="button">
<button id="stop-song-btn" class="control-btn" type="button" data-i18n-title="admin.stopTooltip" title="Stop current song">
<span class="control-icon">⏹️</span>
<span class="control-label" data-i18n="admin.stop">Stop</span>
</button>
<button id="volume-down-btn" class="control-btn" type="button">
<button id="volume-down-btn" class="control-btn" type="button" data-i18n-title="admin.volumeDownTooltip" title="Volume down">
<span class="control-icon">🔉</span>
<span class="control-label" data-i18n="admin.volumeDown">Quieter</span>
</button>
<button id="volume-up-btn" class="control-btn" type="button">
<button id="volume-up-btn" class="control-btn" type="button" data-i18n-title="admin.volumeUpTooltip" title="Volume up">
<span class="control-icon">🔊</span>
<span class="control-label" data-i18n="admin.volumeUp">Louder</span>
</button>
<button id="next-round-admin-btn" class="control-btn" type="button">
<button id="seek-forward-btn" class="control-btn" type="button" data-i18n-title="admin.seekForwardTooltip" title="Skip 10 seconds forward">
<span class="control-icon">⏩</span>
<span class="control-label" data-i18n="admin.seekForward">+10s</span>
</button>
<button id="next-round-admin-btn" class="control-btn" type="button" data-i18n-title="admin.nextTooltip" title="Next round / Skip">
<span class="control-icon">⏭️</span>
<span class="control-label" data-i18n="admin.next">Next</span>
</button>
<button id="end-game-btn" class="control-btn control-btn--danger" type="button">
<button id="end-game-btn" class="control-btn control-btn--danger" type="button" data-i18n-title="admin.endTooltip" title="End game">
<span class="control-icon">🛑</span>
<span class="control-label" data-i18n="admin.end">End</span>
</button>
Expand Down
Loading
Loading