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): ...@@ -277,6 +277,15 @@ class RTSPStreamer(ThreadedComponent):
self.current_fixture_id = None self.current_fixture_id = None
self.current_result = 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 # Video playback state management
self.current_video_type = 'black_screen' self.current_video_type = 'black_screen'
self.video_loop_enabled = True self.video_loop_enabled = True
...@@ -958,7 +967,7 @@ class RTSPStreamer(ThreadedComponent): ...@@ -958,7 +967,7 @@ class RTSPStreamer(ThreadedComponent):
self._switching_video = True self._switching_video = True
# Update video type # Update video type and phase
if video_type: if video_type:
self.current_video_type = video_type self.current_video_type = video_type
elif 'intro' in video_path.lower() or 'INTRO' in video_path: elif 'intro' in video_path.lower() or 'INTRO' in video_path:
...@@ -966,6 +975,16 @@ class RTSPStreamer(ThreadedComponent): ...@@ -966,6 +975,16 @@ class RTSPStreamer(ThreadedComponent):
else: else:
self.current_video_type = 'video' 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 # Cancel any existing video end timer
if self.video_end_timer: if self.video_end_timer:
self.video_end_timer.cancel() self.video_end_timer.cancel()
...@@ -980,6 +999,10 @@ class RTSPStreamer(ThreadedComponent): ...@@ -980,6 +999,10 @@ class RTSPStreamer(ThreadedComponent):
self.current_video_duration = duration self.current_video_duration = duration
self.video_start_time = time.time() 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 # Tell video feeder to play the video
if self.video_feeder: if self.video_feeder:
self.video_feeder.play(video_path, loop=loop) self.video_feeder.play(video_path, loop=loop)
...@@ -992,7 +1015,7 @@ class RTSPStreamer(ThreadedComponent): ...@@ -992,7 +1015,7 @@ class RTSPStreamer(ThreadedComponent):
self.video_end_timer.daemon = True self.video_end_timer.daemon = True
self.video_end_timer.start() 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 self._switching_video = False
return True return True
...@@ -1009,6 +1032,11 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1009,6 +1032,11 @@ class RTSPStreamer(ThreadedComponent):
self.current_video_type = 'black_screen' self.current_video_type = 'black_screen'
self.current_video_looping = True 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 # Stop video feeder - it will output black frames
if self.video_feeder: if self.video_feeder:
self.video_feeder.stop() self.video_feeder.stop()
...@@ -1018,7 +1046,7 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1018,7 +1046,7 @@ class RTSPStreamer(ThreadedComponent):
self.video_end_timer.cancel() self.video_end_timer.cancel()
self.video_end_timer = None self.video_end_timer = None
logger.info("Switched to black screen") logger.info("Switched to black screen - phase: idle")
return True return True
except Exception as e: except Exception as e:
...@@ -1085,6 +1113,13 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1085,6 +1113,13 @@ class RTSPStreamer(ThreadedComponent):
def _update_overlay(self, data: Dict[str, Any]): def _update_overlay(self, data: Dict[str, Any]):
"""Update overlay data""" """Update overlay data"""
self.overlay_data.update(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}") logger.debug(f"Overlay updated: {self.overlay_data}")
def _process_message(self, message: Message): def _process_message(self, message: Message):
...@@ -1373,7 +1408,7 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1373,7 +1408,7 @@ class RTSPStreamer(ThreadedComponent):
def _handle_video_completion(self): def _handle_video_completion(self):
"""Handle video completion""" """Handle video completion"""
try: 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_video_type == 'match':
if self.current_match_id and self.current_match_video_filename: if self.current_match_id and self.current_match_video_filename:
...@@ -1387,6 +1422,14 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1387,6 +1422,14 @@ class RTSPStreamer(ThreadedComponent):
self.message_bus.publish(done_message, broadcast=True) self.message_bus.publish(done_message, broadcast=True)
self.is_playing_match_video = False 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': elif self.current_video_type == 'result':
if self.current_match_id and self.current_result: if self.current_match_id and self.current_result:
logger.info(f"Sending PLAY_VIDEO_RESULT_DONE") logger.info(f"Sending PLAY_VIDEO_RESULT_DONE")
...@@ -1398,6 +1441,9 @@ class RTSPStreamer(ThreadedComponent): ...@@ -1398,6 +1441,9 @@ class RTSPStreamer(ThreadedComponent):
) )
self.message_bus.publish(done_message, broadcast=True) 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': elif self.current_video_type == 'intro':
logger.warning("Intro video ended (should loop)") logger.warning("Intro video ended (should loop)")
if self.intro_video_path: if self.intro_video_path:
......
...@@ -10686,6 +10686,12 @@ def get_overlay_data(): ...@@ -10686,6 +10686,12 @@ def get_overlay_data():
Equivalent to Qt WebChannel getCurrentData() method. Equivalent to Qt WebChannel getCurrentData() method.
Returns current overlay data including title, subtitle, match info, etc. 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: try:
# Get main application from Flask g context # Get main application from Flask g context
...@@ -10707,13 +10713,29 @@ def get_overlay_data(): ...@@ -10707,13 +10713,29 @@ def get_overlay_data():
if hasattr(headless_player, 'current_match_id'): if hasattr(headless_player, 'current_match_id'):
overlay_data['current_match_id'] = 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 # Add some default data if not available
if not overlay_data: if not overlay_data:
overlay_data = { overlay_data = {
'title': 'Townships Combat League', 'title': 'Townships Combat League',
'subtitle': 'Live Stream', 'subtitle': 'Live Stream',
'stream_status': 'live', '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) return jsonify(overlay_data)
......
...@@ -48,6 +48,15 @@ class WebOverlayController { ...@@ -48,6 +48,15 @@ class WebOverlayController {
this.licenseText = ''; this.licenseText = '';
this.isPaused = false; 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 // Template rotation
this.templateSequence = []; this.templateSequence = [];
this.rotatingTime = 15; // Default 15 seconds this.rotatingTime = 15; // Default 15 seconds
...@@ -555,6 +564,7 @@ class WebOverlayController { ...@@ -555,6 +564,7 @@ class WebOverlayController {
/** /**
* Fetch overlay data from REST API * Fetch overlay data from REST API
* Uses Video Phase State Machine for HLS delay compensation
*/ */
async fetchOverlayData() { async fetchOverlayData() {
try { try {
...@@ -562,34 +572,54 @@ class WebOverlayController { ...@@ -562,34 +572,54 @@ class WebOverlayController {
if (response) { if (response) {
const hasChanges = JSON.stringify(this.overlayData) !== JSON.stringify(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 wasPlayingMatchVideo = this.overlayData.is_playing_match_video;
const isNowPlayingMatchVideo = response.is_playing_match_video; const isNowPlayingMatchVideo = response.is_playing_match_video;
this.overlayData = response; // Handle match video state changes (fallback for older API)
if (isNowPlayingMatchVideo && !wasPlayingMatchVideo && newPhase === 'match') {
// Handle match video state changes
if (isNowPlayingMatchVideo && !wasPlayingMatchVideo) {
// Match video just started - stop rotation and load match_video template // Match video just started - stop rotation and load match_video template
this.log('Match video started - stopping template rotation'); this.log('Match video started - stopping template rotation');
this.stopTemplateRotation(); this.stopTemplateRotation();
this.loadTemplate('match_video'); 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) { if (hasChanges) {
this.sendMessageToOverlay('dataUpdated', response); this.sendMessageToOverlay('dataUpdated', response);
} }
} }
// Also fetch timer state on each poll (only if not playing match video) // Also fetch timer state on each poll (only if not in match or result phase)
if (!this.overlayData.is_playing_match_video) { if (this.videoPhase === 'idle' || this.videoPhase === 'intro') {
const timerState = await this.fetchTimerState(); const timerState = await this.fetchTimerState();
if (timerState && (timerState.running || this.timerState.running)) { if (timerState && (timerState.running || this.timerState.running)) {
// Send timer update if timer is running or was running // Send timer update if timer is running or was running
...@@ -604,6 +634,85 @@ class WebOverlayController { ...@@ -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 * Fetch fixture data from REST API
* Equivalent to Qt WebChannel getFixtureData() * Equivalent to Qt WebChannel getFixtureData()
...@@ -1180,6 +1289,12 @@ class WebOverlayController { ...@@ -1180,6 +1289,12 @@ class WebOverlayController {
// Stop polling // Stop polling
this.stopPolling(); this.stopPolling();
// Clear phase transition timer
if (this.phaseTransitionTimer) {
clearTimeout(this.phaseTransitionTimer);
this.phaseTransitionTimer = null;
}
// Close WebSocket // Close WebSocket
if (this.websocket) { if (this.websocket) {
this.websocket.close(); 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