Implement Video Phase State Machine for HLS synchronization

- Add video phase tracking in RTSPStreamer (idle, intro, match, result)
- Implement HLS delay compensation for overlay template synchronization
- Fix results template not showing after match video ends
- Add phase transition handling with configurable delay compensation
- Update overlay API endpoint to include video phase state
- Add phaseTransitionTimer for delayed template changes

This ensures overlay templates are synchronized with HLS video playback
by compensating for FFmpeg encoding and web player buffering delays.
parent 02284075
......@@ -277,6 +277,15 @@ class RTSPStreamer(ThreadedComponent):
self.current_fixture_id = None
self.current_result = None
# Video Phase State Machine for HLS synchronization
# Phases: 'idle', 'intro', 'match', 'result'
self.video_phase = 'idle'
self.phase_start_time = 0.0
self.phase_video_duration = 0.0
self.hls_delay_compensation = 5.0 # Default 5 seconds delay compensation
self.result_data = None # Store result data for overlay
self.pending_phase_change = None # Pending phase change after delay
# Video playback state management
self.current_video_type = 'black_screen'
self.video_loop_enabled = True
......@@ -958,7 +967,7 @@ class RTSPStreamer(ThreadedComponent):
self._switching_video = True
# Update video type
# Update video type and phase
if video_type:
self.current_video_type = video_type
elif 'intro' in video_path.lower() or 'INTRO' in video_path:
......@@ -966,6 +975,16 @@ class RTSPStreamer(ThreadedComponent):
else:
self.current_video_type = 'video'
# Update video phase state machine
if self.current_video_type == 'intro':
self.video_phase = 'intro'
elif self.current_video_type == 'match':
self.video_phase = 'match'
elif self.current_video_type == 'result':
self.video_phase = 'result'
else:
self.video_phase = 'video'
# Cancel any existing video end timer
if self.video_end_timer:
self.video_end_timer.cancel()
......@@ -979,6 +998,10 @@ class RTSPStreamer(ThreadedComponent):
duration = self._get_video_duration(video_path)
self.current_video_duration = duration
self.video_start_time = time.time()
# Update phase tracking for HLS synchronization
self.phase_start_time = time.time()
self.phase_video_duration = duration
# Tell video feeder to play the video
if self.video_feeder:
......@@ -992,7 +1015,7 @@ class RTSPStreamer(ThreadedComponent):
self.video_end_timer.daemon = True
self.video_end_timer.start()
logger.info("Video switch completed")
logger.info(f"Video switch completed - phase: {self.video_phase}, duration: {duration:.2f}s")
self._switching_video = False
return True
......@@ -1008,6 +1031,11 @@ class RTSPStreamer(ThreadedComponent):
self.current_video_type = 'black_screen'
self.current_video_looping = True
# Update phase to idle when showing black screen
self.video_phase = 'idle'
self.phase_start_time = time.time()
self.phase_video_duration = 0
# Stop video feeder - it will output black frames
if self.video_feeder:
......@@ -1018,7 +1046,7 @@ class RTSPStreamer(ThreadedComponent):
self.video_end_timer.cancel()
self.video_end_timer = None
logger.info("Switched to black screen")
logger.info("Switched to black screen - phase: idle")
return True
except Exception as e:
......@@ -1085,6 +1113,13 @@ class RTSPStreamer(ThreadedComponent):
def _update_overlay(self, data: Dict[str, Any]):
"""Update overlay data"""
self.overlay_data.update(data)
# Add video phase state to overlay data for web player synchronization
self.overlay_data['video_phase'] = self.video_phase
self.overlay_data['phase_start_time'] = self.phase_start_time
self.overlay_data['phase_video_duration'] = self.phase_video_duration
self.overlay_data['hls_delay_compensation'] = self.hls_delay_compensation
logger.debug(f"Overlay updated: {self.overlay_data}")
def _process_message(self, message: Message):
......@@ -1373,7 +1408,7 @@ class RTSPStreamer(ThreadedComponent):
def _handle_video_completion(self):
"""Handle video completion"""
try:
logger.info(f"Handling video completion for type: {self.current_video_type}")
logger.info(f"Handling video completion for type: {self.current_video_type}, phase: {self.video_phase}")
if self.current_video_type == 'match':
if self.current_match_id and self.current_match_video_filename:
......@@ -1386,6 +1421,14 @@ class RTSPStreamer(ThreadedComponent):
)
self.message_bus.publish(done_message, broadcast=True)
self.is_playing_match_video = False
# Store result data for overlay before transitioning
self.result_data = {
'outcome': self.current_result,
'result': self.current_result,
'match_id': self.current_match_id,
'fixture_id': self.current_fixture_id
}
elif self.current_video_type == 'result':
if self.current_match_id and self.current_result:
......@@ -1397,6 +1440,9 @@ class RTSPStreamer(ThreadedComponent):
result=self.current_result
)
self.message_bus.publish(done_message, broadcast=True)
# Clear result data after result video completes
self.result_data = None
elif self.current_video_type == 'intro':
logger.warning("Intro video ended (should loop)")
......
......@@ -10686,6 +10686,12 @@ def get_overlay_data():
Equivalent to Qt WebChannel getCurrentData() method.
Returns current overlay data including title, subtitle, match info, etc.
Video Phase State Machine:
- video_phase: Current phase ('idle', 'intro', 'match', 'result')
- phase_start_time: Server timestamp when phase started
- phase_video_duration: Expected duration of current video
- hls_delay_compensation: Configurable delay for HLS synchronization
"""
try:
# Get main application from Flask g context
......@@ -10706,6 +10712,18 @@ def get_overlay_data():
overlay_data['current_match_video_filename'] = headless_player.current_match_video_filename
if hasattr(headless_player, 'current_match_id'):
overlay_data['current_match_id'] = headless_player.current_match_id
# Add video phase state machine data for HLS synchronization
if hasattr(headless_player, 'video_phase'):
overlay_data['video_phase'] = headless_player.video_phase
if hasattr(headless_player, 'phase_start_time'):
overlay_data['phase_start_time'] = headless_player.phase_start_time
if hasattr(headless_player, 'phase_video_duration'):
overlay_data['phase_video_duration'] = headless_player.phase_video_duration
if hasattr(headless_player, 'hls_delay_compensation'):
overlay_data['hls_delay_compensation'] = headless_player.hls_delay_compensation
if hasattr(headless_player, 'result_data'):
overlay_data['result_data'] = headless_player.result_data
# Add some default data if not available
if not overlay_data:
......@@ -10713,7 +10731,11 @@ def get_overlay_data():
'title': 'Townships Combat League',
'subtitle': 'Live Stream',
'stream_status': 'live',
'is_playing_match_video': False
'is_playing_match_video': False,
'video_phase': 'idle',
'phase_start_time': 0,
'phase_video_duration': 0,
'hls_delay_compensation': 5.0
}
return jsonify(overlay_data)
......
......@@ -48,6 +48,15 @@ class WebOverlayController {
this.licenseText = '';
this.isPaused = false;
// Video Phase State Machine for HLS synchronization
this.videoPhase = 'idle'; // 'idle', 'intro', 'match', 'result'
this.phaseStartTime = 0;
this.phaseVideoDuration = 0;
this.hlsDelayCompensation = 5.0; // Default 5 seconds delay compensation
this.lastKnownPhase = 'idle';
this.phaseTransitionPending = false;
this.phaseTransitionTimer = null; // Timer for delayed phase transitions
// Template rotation
this.templateSequence = [];
this.rotatingTime = 15; // Default 15 seconds
......@@ -555,6 +564,7 @@ class WebOverlayController {
/**
* Fetch overlay data from REST API
* Uses Video Phase State Machine for HLS delay compensation
*/
async fetchOverlayData() {
try {
......@@ -562,34 +572,54 @@ class WebOverlayController {
if (response) {
const hasChanges = JSON.stringify(this.overlayData) !== JSON.stringify(response);
// Extract video phase state from response
const newPhase = response.video_phase || 'idle';
const newPhaseStartTime = response.phase_start_time || 0;
const newPhaseVideoDuration = response.phase_video_duration || 0;
const newHlsDelayCompensation = response.hls_delay_compensation || 5.0;
// Update HLS delay compensation if changed
if (newHlsDelayCompensation !== this.hlsDelayCompensation) {
this.hlsDelayCompensation = newHlsDelayCompensation;
this.log(`HLS delay compensation updated to ${this.hlsDelayCompensation}s`);
}
// Store previous phase for transition detection
const previousPhase = this.videoPhase;
// Update phase state
this.videoPhase = newPhase;
this.phaseStartTime = newPhaseStartTime;
this.phaseVideoDuration = newPhaseVideoDuration;
// Handle phase transitions with delay compensation
if (newPhase !== previousPhase) {
this.log(`Video phase changed: ${previousPhase} -> ${newPhase}`);
this.handlePhaseTransition(previousPhase, newPhase, response);
}
// Legacy support: Also check is_playing_match_video flag
const wasPlayingMatchVideo = this.overlayData.is_playing_match_video;
const isNowPlayingMatchVideo = response.is_playing_match_video;
this.overlayData = response;
// Handle match video state changes
if (isNowPlayingMatchVideo && !wasPlayingMatchVideo) {
// Handle match video state changes (fallback for older API)
if (isNowPlayingMatchVideo && !wasPlayingMatchVideo && newPhase === 'match') {
// Match video just started - stop rotation and load match_video template
this.log('Match video started - stopping template rotation');
this.stopTemplateRotation();
this.loadTemplate('match_video');
} else if (!isNowPlayingMatchVideo && wasPlayingMatchVideo) {
// Match video just ended - restart rotation with results template first
this.log('Match video ended - restarting template rotation with results');
this.loadTemplate('results');
// Restart rotation after a delay
setTimeout(() => {
this.startTemplateRotation();
}, 5000); // Show results for 5 seconds before resuming rotation
}
this.overlayData = response;
if (hasChanges) {
this.sendMessageToOverlay('dataUpdated', response);
}
}
// Also fetch timer state on each poll (only if not playing match video)
if (!this.overlayData.is_playing_match_video) {
// Also fetch timer state on each poll (only if not in match or result phase)
if (this.videoPhase === 'idle' || this.videoPhase === 'intro') {
const timerState = await this.fetchTimerState();
if (timerState && (timerState.running || this.timerState.running)) {
// Send timer update if timer is running or was running
......@@ -604,6 +634,85 @@ class WebOverlayController {
}
}
/**
* Handle video phase transitions with HLS delay compensation
* @param {string} previousPhase - Previous video phase
* @param {string} newPhase - New video phase
* @param {Object} data - Overlay data from API
*/
handlePhaseTransition(previousPhase, newPhase, data) {
// Calculate delay-compensated transition time
// We wait for HLS delay before switching templates to sync with actual video
const delayMs = this.hlsDelayCompensation * 1000;
this.log(`Scheduling phase transition from ${previousPhase} to ${newPhase} with ${delayMs}ms delay`);
// Clear any pending phase transition
if (this.phaseTransitionTimer) {
clearTimeout(this.phaseTransitionTimer);
this.phaseTransitionTimer = null;
}
// Schedule the template change with delay compensation
this.phaseTransitionTimer = setTimeout(() => {
this.executePhaseTransition(newPhase, data);
this.phaseTransitionTimer = null;
}, delayMs);
}
/**
* Execute the actual phase transition (template change)
* @param {string} phase - The new video phase
* @param {Object} data - Overlay data from API
*/
executePhaseTransition(phase, data) {
this.log(`Executing phase transition to: ${phase}`);
switch (phase) {
case 'intro':
// Intro phase - start template rotation
this.log('Intro phase - starting template rotation');
if (!this.rotationEnabled) {
this.startTemplateRotation();
}
break;
case 'match':
// Match phase - stop rotation and show match template
this.log('Match phase - stopping rotation, loading match_video template');
this.stopTemplateRotation();
this.loadTemplate('match_video');
break;
case 'result':
// Result phase - stop rotation and show results template
this.log('Result phase - stopping rotation, loading results template');
this.stopTemplateRotation();
// Send result data to template before loading
this.sendMessageToOverlay('resultData', {
outcome: data.outcome || data.result,
result: data.result,
match_id: data.match_id,
fixture_id: data.fixture_id,
winningOutcomes: data.winningOutcomes || []
});
this.loadTemplate('results');
break;
case 'idle':
// Idle phase - return to intro rotation after result completes
this.log('Idle phase - will restart template rotation after delay');
// Don't immediately start rotation - wait for next intro phase
break;
default:
this.log(`Unknown phase: ${phase}`);
}
// Emit phase change event
this.emit('phaseChanged', { phase, data });
}
/**
* Fetch fixture data from REST API
* Equivalent to Qt WebChannel getFixtureData()
......@@ -1180,6 +1289,12 @@ class WebOverlayController {
// Stop polling
this.stopPolling();
// Clear phase transition timer
if (this.phaseTransitionTimer) {
clearTimeout(this.phaseTransitionTimer);
this.phaseTransitionTimer = null;
}
// Close WebSocket
if (this.websocket) {
this.websocket.close();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment