diff --git a/server/realtime-server.ts b/server/realtime-server.ts index 3a55833..efe5ac0 100644 --- a/server/realtime-server.ts +++ b/server/realtime-server.ts @@ -162,26 +162,46 @@ server.on('upgrade', (req, socket, head) => { ) ws.on('message', (buffer) => { - let parsed: { type?: string; state?: SessionState } | null = null + let parsed: { type?: string; state?: SessionState; teamId?: string } | null = null try { - parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState } + parsed = JSON.parse(buffer.toString()) as { + type?: string + state?: SessionState + teamId?: string + } } catch { return } - if (!parsed || parsed.type !== 'state:update' || !parsed.state) return - const targetSession = ensureSession(gameId) - targetSession.state = parsed.state - targetSession.updatedAt = Date.now() + if (!parsed) return - broadcast( - gameId, - { - type: 'state:update', + if (parsed.type === 'state:update' && parsed.state) { + const targetSession = ensureSession(gameId) + targetSession.state = parsed.state + targetSession.updatedAt = Date.now() + + broadcast( gameId, - state: targetSession.state - }, - ws - ) + { + type: 'state:update', + gameId, + state: targetSession.state + }, + ws + ) + return + } + + if (parsed.type === 'guess:request' && parsed.teamId) { + broadcast( + gameId, + { + type: 'guess:request', + gameId, + teamId: parsed.teamId + }, + ws + ) + } }) ws.on('close', () => { diff --git a/src/App.vue b/src/App.vue index 390fe75..5e8a3f9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -266,6 +266,9 @@ +
@@ -331,13 +334,12 @@ type RealtimeState = { currentSelectorId: string | null lastAwardedTeamId: string | null isAnswerClip: boolean - playbackPosition: number - playbackCapturedAt: number } type RealtimeMessage = { - type: 'session:init' | 'state:update' - state: RealtimeState | null + type: 'session:init' | 'state:update' | 'guess:request' + state?: RealtimeState | null + teamId?: string } const REALTIME_BASE = '/realtime' @@ -395,7 +397,6 @@ export default { isApplyingRemote: false, queuedRemoteState: null as RealtimeState | null, syncTimer: 0, - playbackSyncInterval: 0, audioUnlocked: true, latestRemoteState: null as RealtimeState | null, viewerTeamId: '' @@ -433,6 +434,14 @@ export default { showEnableAudio() { return !this.canControlGame && !!this.currentClipUrl && !this.audioUnlocked }, + canViewerGuess() { + return ( + !this.canControlGame && + this.audioUnlocked && + !!this.viewerTeamId && + this.getCurrentTileStatus() === 'playing' + ) + }, canControlGame() { return !this.gameId || this.isHost }, @@ -562,7 +571,6 @@ export default { this.audioUnlocked = true this.latestRemoteState = null this.viewerTeamId = '' - this.stopHostPlaybackSync() this.setGameInUrl('') }, async connectSession() { @@ -585,6 +593,10 @@ export default { if (!message) return if (message.type === 'session:init' || message.type === 'state:update') { if (message.state) await this.applyRemoteState(message.state) + return + } + if (message.type === 'guess:request') { + await this.handleRemoteGuessRequest(message.teamId || '') } } @@ -611,7 +623,6 @@ export default { }).catch(() => {}) }, buildRealtimeState(): RealtimeState { - const player = this.getPlayer() return { step: this.step, teams: this.teams.map((team) => ({ ...team })), @@ -621,9 +632,7 @@ export default { currentClipUrl: this.currentClipUrl, currentSelectorId: this.currentSelectorId, lastAwardedTeamId: this.lastAwardedTeamId, - isAnswerClip: this.isAnswerClip, - playbackPosition: player?.currentTime || 0, - playbackCapturedAt: Date.now() + isAnswerClip: this.isAnswerClip } }, publishState() { @@ -673,36 +682,62 @@ export default { this.isApplyingRemote = false } await nextTick() - this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl, state) + this.syncRemotePlayback(previousClipUrl !== this.currentClipUrl) }, async enableViewerAudio() { if (this.canControlGame) return this.audioUnlocked = true this.ensureAudioContext() await nextTick() - if (this.latestRemoteState) { - this.syncRemotePlayback(false, this.latestRemoteState) + this.syncRemotePlayback(false) + }, + requestGuessStop() { + if (this.canControlGame) return + if (!this.socketConnected || !this.socket) return + if (!this.viewerTeamId) return + if (this.getCurrentTileStatus() !== 'playing') return + this.socket.send( + JSON.stringify({ + type: 'guess:request', + teamId: this.viewerTeamId + }) + ) + }, + async handleRemoteGuessRequest(teamId: string) { + if (!this.canControlGame || !this.isHost) return + if (!teamId) return + if (!this.currentTileKey || !this.selectedGame) return + const status = this.getCurrentTileStatus() + if (status !== 'playing') return + const key = this.currentTileKey + const [cIndex, qIndex] = key.split('-').map(Number) + const clue = this.selectedGame.categories[cIndex].clues[qIndex] + this.tiles[key].status = 'guessed' + this.lastAwardedTeamId = null + if (clue.answer) { + this.currentClipUrl = encodeURI(clue.answer) + this.isAnswerClip = true + await nextTick() + const player = this.getPlayer() + if (player) { + this.ensureAudioContext() + player.currentTime = 0 + player.load() + player.play().catch(() => {}) + } + } else { + const player = this.getPlayer() + player?.pause() + this.currentClipUrl = '' + this.isAnswerClip = false } + this.queueStateSync() }, getCurrentTileStatus() { if (!this.currentTileKey) return 'available' return this.tiles[this.currentTileKey]?.status || 'available' }, - setPlayerCurrentTime(player: HTMLAudioElement, targetTime: number) { - const seek = () => { - try { - player.currentTime = targetTime - } catch { - // ignore seek failures on unsupported ranges - } - } - if (player.readyState > 0) { - seek() - return - } - player.addEventListener('loadedmetadata', seek, { once: true }) - }, - syncRemotePlayback(forceReload: boolean, state: RealtimeState) { + syncRemotePlayback(forceReload: boolean) { if (this.canControlGame) return const player = this.getPlayer() if (!player) return @@ -713,23 +748,11 @@ export default { return } - const elapsedSeconds = Math.max(0, (Date.now() - (state.playbackCapturedAt || Date.now())) / 1000) - let targetTime = state.playbackPosition || 0 - if (tileStatus === 'playing' || tileStatus === 'guessed') { - targetTime += elapsedSeconds - } - if (Number.isFinite(player.duration) && player.duration > 0) { - targetTime = Math.min(targetTime, Math.max(0, player.duration - 0.05)) - } - if (forceReload) { + player.currentTime = 0 player.load() } - if (Math.abs((player.currentTime || 0) - targetTime) > 0.2) { - this.setPlayerCurrentTime(player, targetTime) - } - if (tileStatus === 'paused') { player.pause() return @@ -794,7 +817,6 @@ export default { this.tentacleLevels = this.tentacleLevels.map(() => 0) }, teardownAudio() { - this.stopHostPlaybackSync() if (this.rafId) { cancelAnimationFrame(this.rafId) this.rafId = 0 @@ -873,25 +895,10 @@ export default { if (this.rafId) cancelAnimationFrame(this.rafId) this.rafId = requestAnimationFrame(tick) }, - startHostPlaybackSync() { - if (!this.isHost || !this.socketConnected || !this.socket) return - this.stopHostPlaybackSync() - this.playbackSyncInterval = window.setInterval(() => { - this.queueStateSync() - }, 400) - }, - stopHostPlaybackSync() { - if (!this.playbackSyncInterval) return - window.clearInterval(this.playbackSyncInterval) - this.playbackSyncInterval = 0 - }, handlePlayerPlay() { this.isPlaying = true this.startPulse() - if (this.isHost) { - this.startHostPlaybackSync() - this.queueStateSync() - } + if (this.isHost) this.queueStateSync() }, handlePlayerPause() { this.isPlaying = false @@ -900,10 +907,7 @@ export default { this.rafId = 0 } this.pulseLevel = 0 - if (this.isHost) { - this.stopHostPlaybackSync() - this.queueStateSync() - } + if (this.isHost) this.queueStateSync() }, togglePlayback() { const player = this.getPlayer() @@ -1125,7 +1129,6 @@ export default { this.teardownAudio() this.closeSocket() if (this.syncTimer) window.clearTimeout(this.syncTimer) - if (this.playbackSyncInterval) window.clearInterval(this.playbackSyncInterval) } } diff --git a/src/styles.css b/src/styles.css index 25dc3ff..ff0446a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -560,6 +560,12 @@ audio.hidden-audio { z-index: 3; } +.viewer-guess { + position: absolute; + bottom: 20px; + z-index: 3; +} + .end-panel { text-align: center; }