Source code for vista.widgets.core.playback_controls
"""PlaybackControls widget for controlling imagery playback"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QSlider, QLabel, QSpinBox, QCheckBox, QDial, QApplication
)
from PyQt6.QtCore import Qt, QTimer, QElapsedTimer
from PyQt6.QtWidgets import QStyle
[docs]
class PlaybackControls(QWidget):
"""Widget for playback controls"""
[docs]
def __init__(self, parent=None):
super().__init__(parent)
self.is_playing = False
self.min_frame = 0 # Minimum frame number
self.max_frame = 0 # Maximum frame number
self.current_frame = 0 # Current frame number
self.fps = 10 # frames per second
self.playback_direction = 1 # 1 for forward, -1 for reverse
self.bounce_mode = False
self.bounce_start = 0
self.bounce_end = 0
# Elapsed time tracking for responsive playback
self.elapsed_timer = QElapsedTimer()
self.last_frame_time = 0 # Time in ms when last frame was advanced
# Actual FPS tracking
self.frame_times = [] # Store recent frame timestamps for FPS calculation
self.max_frame_history = 30 # Use last 30 frames for FPS calculation
# Callback to get current time from imagery
self.get_current_time = None # Function that returns current datetime or None
self.init_ui()
# Timer for playback - fires frequently to check if it's time to advance
self.timer = QTimer()
self.timer.timeout.connect(self.on_timer_tick)
[docs]
def init_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0) # left, top, right, bottom
#layout.setSpacing(3) # Reduce spacing between slider_section and button_layout
# Frame slider and info layout
slider_section = QVBoxLayout()
slider_section.setContentsMargins(0, 0, 0, 0)
slider_section.setSpacing(10)
# Slider on top
self.frame_slider = QSlider(Qt.Orientation.Horizontal)
self.frame_slider.setMinimum(0)
self.frame_slider.setMaximum(0)
self.frame_slider.valueChanged.connect(self.on_slider_changed)
slider_section.addWidget(self.frame_slider)
# Frame and time info underneath
info_layout = QHBoxLayout()
info_layout.setContentsMargins(0, 0, 0, 0)
self.frame_label = QLabel("Frame: 0 / 0")
self.time_label = QLabel("") # Will show ISO time when available
info_layout.addStretch()
info_layout.addWidget(self.frame_label)
info_layout.addWidget(self.time_label)
info_layout.addStretch()
slider_section.addLayout(info_layout)
# Playback buttons row
button_layout = QHBoxLayout()
button_layout.setContentsMargins(0, 0, 0, 0)
# Get standard icons from the application style
style = QApplication.style()
self.play_button = QPushButton()
self.play_icon = style.standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
self.pause_icon = style.standardIcon(QStyle.StandardPixmap.SP_MediaPause)
self.play_button.setIcon(self.play_icon)
self.play_button.setToolTip("Play")
self.play_button.clicked.connect(self.toggle_play)
self.reverse_button = QPushButton()
self.reverse_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_MediaSeekBackward))
self.reverse_button.setToolTip("Reverse")
self.reverse_button.clicked.connect(self.toggle_reverse)
self.prev_button = QPushButton()
self.prev_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_MediaSkipBackward))
self.prev_button.setToolTip("Previous Frame")
self.prev_button.clicked.connect(self.prev_frame)
self.next_button = QPushButton()
self.next_button.setIcon(style.standardIcon(QStyle.StandardPixmap.SP_MediaSkipForward))
self.next_button.setToolTip("Next Frame")
self.next_button.clicked.connect(self.next_frame)
button_layout.addStretch()
button_layout.addWidget(self.play_button)
button_layout.addWidget(self.reverse_button)
button_layout.addWidget(self.prev_button)
button_layout.addWidget(self.next_button)
# Bounce mode controls row
bounce_layout = QHBoxLayout()
bounce_layout.setContentsMargins(0, 0, 0, 0)
bounce_layout.setSpacing(5)
self.bounce_checkbox = QCheckBox("Bounce Mode")
self.bounce_checkbox.stateChanged.connect(self.on_bounce_toggled)
self.bounce_start_label = QLabel("Start:")
self.bounce_start_spinbox = QSpinBox()
self.bounce_start_spinbox.setMinimum(0)
self.bounce_start_spinbox.setMaximum(0)
self.bounce_start_spinbox.setValue(0)
self.bounce_start_spinbox.setEnabled(False)
self.bounce_start_spinbox.valueChanged.connect(self.on_bounce_range_changed)
self.bounce_end_label = QLabel("End:")
self.bounce_end_spinbox = QSpinBox()
self.bounce_end_spinbox.setMinimum(0)
self.bounce_end_spinbox.setMaximum(0)
self.bounce_end_spinbox.setValue(0)
self.bounce_end_spinbox.setEnabled(False)
self.bounce_end_spinbox.valueChanged.connect(self.on_bounce_range_changed)
bounce_layout.addWidget(self.bounce_checkbox)
bounce_layout.addWidget(self.bounce_start_label)
bounce_layout.addWidget(self.bounce_start_spinbox)
bounce_layout.addWidget(self.bounce_end_label)
bounce_layout.addWidget(self.bounce_end_spinbox)
button_layout.addLayout(bounce_layout)
# FPS controls
self.fps_label = QLabel("FPS:")
self.fps_spinbox = QSpinBox()
self.fps_spinbox.setMinimum(-100)
self.fps_spinbox.setMaximum(100)
self.fps_spinbox.setValue(self.fps)
self.fps_spinbox.setMaximumWidth(60)
self.fps_spinbox.valueChanged.connect(self.on_fps_spinbox_changed)
self.fps_dial = QDial()
self.fps_dial.setMinimum(-100)
self.fps_dial.setMaximum(100)
self.fps_dial.setValue(self.fps)
self.fps_dial.setMaximumWidth(80)
self.fps_dial.setMaximumHeight(80)
self.fps_dial.setNotchesVisible(True)
self.fps_dial.setWrapping(False)
self.fps_dial.valueChanged.connect(self.on_fps_dial_changed)
button_layout.addWidget(self.fps_label)
button_layout.addWidget(self.fps_spinbox)
button_layout.addWidget(self.fps_dial)
# Actual FPS display
self.actual_fps_label = QLabel("Actual: -- FPS")
self.actual_fps_label.setMinimumWidth(100)
self.actual_fps_label.setStyleSheet("QLabel { color: #666; font-style: italic; }")
button_layout.addWidget(self.actual_fps_label)
button_layout.addStretch()
layout.addLayout(slider_section)
layout.addLayout(button_layout)
self.setLayout(layout)
[docs]
def set_frame_range(self, min_frame: int, max_frame: int):
"""Set the range of frame numbers without changing current frame"""
self.min_frame = min_frame
self.max_frame = max_frame
# Block signals to prevent triggering frame changes while updating range
self.frame_slider.blockSignals(True)
# Update slider range
self.frame_slider.setMinimum(min_frame)
self.frame_slider.setMaximum(max_frame)
# Don't set value here - let set_frame() handle it
# Unblock signals
self.frame_slider.blockSignals(False)
# Update bounce spinboxes
self.bounce_start_spinbox.setMinimum(min_frame)
self.bounce_start_spinbox.setMaximum(max_frame)
self.bounce_start_spinbox.setValue(min_frame)
self.bounce_end_spinbox.setMinimum(min_frame)
self.bounce_end_spinbox.setMaximum(max_frame)
self.bounce_end_spinbox.setValue(max_frame)
self.bounce_start = min_frame
self.bounce_end = max_frame
self.update_label()
[docs]
def set_frame(self, frame_number: int):
"""Set current frame by frame number"""
self.current_frame = frame_number
self.frame_slider.setValue(frame_number)
self.update_label()
[docs]
def update_label(self):
"""Update frame label and time display"""
self.frame_label.setText(f"Frame: {self.current_frame} / {self.max_frame}")
# Update time display if callback is available
if self.get_current_time is not None:
current_time = self.get_current_time()
if current_time is not None:
# Convert numpy.datetime64 to ISO format string
time_str = str(current_time)
self.time_label.setText(f"Time: {time_str}")
else:
self.time_label.setText("")
else:
self.time_label.setText("")
[docs]
def on_slider_changed(self, value):
"""Handle slider value change"""
self.current_frame = value
self.update_label()
self.frame_changed(value)
[docs]
def on_fps_dial_changed(self, value):
"""Handle FPS dial change"""
if value == 0:
# Don't allow 0 FPS, skip to 1 or -1
return
self.fps = abs(value)
# Set playback direction based on sign
if value < 0:
self.playback_direction = -1
else:
self.playback_direction = 1
# Update spinbox
self.fps_spinbox.blockSignals(True)
self.fps_spinbox.setValue(value)
self.fps_spinbox.blockSignals(False)
# No need to update timer interval - on_timer_tick uses self.fps directly
[docs]
def on_fps_spinbox_changed(self, value):
"""Handle FPS spinbox change"""
if value == 0:
# Don't allow 0 FPS, skip to 1 or -1
if self.fps_spinbox.value() == 0:
self.fps_spinbox.setValue(1 if self.playback_direction > 0 else -1)
return
self.fps = abs(value)
# Set playback direction based on sign
if value < 0:
self.playback_direction = -1
else:
self.playback_direction = 1
# Update dial
self.fps_dial.blockSignals(True)
self.fps_dial.setValue(value)
self.fps_dial.blockSignals(False)
# No need to update timer interval - on_timer_tick uses self.fps directly
[docs]
def toggle_play(self):
"""Toggle playback"""
if self.is_playing:
self.pause()
else:
self.play()
[docs]
def toggle_reverse(self):
"""Toggle reverse playback"""
self.playback_direction *= -1
# Update dial and spinbox to reflect direction
new_value = self.fps * self.playback_direction
self.fps_dial.blockSignals(True)
self.fps_dial.setValue(new_value)
self.fps_dial.blockSignals(False)
self.fps_spinbox.blockSignals(True)
self.fps_spinbox.setValue(new_value)
self.fps_spinbox.blockSignals(False)
[docs]
def play(self):
"""Start playback"""
self.is_playing = True
self.play_button.setIcon(self.pause_icon)
self.play_button.setToolTip("Pause")
# Start elapsed timer and reset frame time
self.elapsed_timer.start()
self.last_frame_time = 0
# Reset FPS tracking
self.frame_times.clear()
# Timer fires every 10ms to check if we should advance frame (responsive)
self.timer.start(10)
[docs]
def pause(self):
"""Pause playback"""
self.is_playing = False
self.play_button.setIcon(self.play_icon)
self.play_button.setToolTip("Play")
self.timer.stop()
# Reset actual FPS display
self.actual_fps_label.setText("Actual: -- FPS")
[docs]
def on_timer_tick(self):
"""Called by timer frequently - checks if enough time has passed to advance frame"""
if not self.is_playing:
return
# Calculate frame period in milliseconds
frame_period_ms = 1000.0 / self.fps if self.fps > 0 else 1000.0
# Get elapsed time since last frame
elapsed_ms = self.elapsed_timer.elapsed()
time_since_last_frame = elapsed_ms - self.last_frame_time
# Only advance if enough time has passed
if time_since_last_frame >= frame_period_ms:
self.last_frame_time = elapsed_ms
self.advance_frame()
# Track frame time for actual FPS calculation
self.frame_times.append(elapsed_ms)
if len(self.frame_times) > self.max_frame_history:
self.frame_times.pop(0)
# Calculate and update actual FPS
self.update_actual_fps()
# Process pending events to ensure UI remains responsive
# This allows pause/stop actions to take effect immediately
QApplication.processEvents()
[docs]
def advance_frame(self):
"""Advance frame based on playback direction and bounce mode"""
if self.bounce_mode:
# Bounce between start and end frames
next_frame = self.current_frame + self.playback_direction
if next_frame > self.bounce_end:
# Hit the end, reverse direction
self.playback_direction = -1
next_frame = self.bounce_end - 1
elif next_frame < self.bounce_start:
# Hit the start, reverse direction
self.playback_direction = 1
next_frame = self.bounce_start + 1
self.set_frame(next_frame)
else:
# Normal playback with looping
if self.playback_direction == 1:
self.next_frame()
else:
self.prev_frame_reverse()
[docs]
def next_frame(self):
"""Go to next frame"""
if self.current_frame < self.max_frame:
self.set_frame(self.current_frame + 1)
else:
# Loop back to beginning
self.set_frame(self.min_frame)
[docs]
def prev_frame(self):
"""Go to previous frame"""
if self.current_frame > self.min_frame:
self.set_frame(self.current_frame - 1)
[docs]
def prev_frame_reverse(self):
"""Go to previous frame (for reverse playback with looping)"""
if self.current_frame > self.min_frame:
self.set_frame(self.current_frame - 1)
else:
# Loop to end
self.set_frame(self.max_frame)
[docs]
def update_actual_fps(self):
"""Calculate and update the actual achieved FPS display"""
if len(self.frame_times) < 2:
self.actual_fps_label.setText("Actual: -- FPS")
return
# Calculate FPS from frame times
time_span_ms = self.frame_times[-1] - self.frame_times[0]
if time_span_ms > 0:
num_frames = len(self.frame_times) - 1
actual_fps = (num_frames * 1000.0) / time_span_ms
self.actual_fps_label.setText(f"Actual: {actual_fps:.1f} FPS")
else:
self.actual_fps_label.setText("Actual: -- FPS")
[docs]
def on_bounce_toggled(self, state):
"""Handle bounce mode toggle"""
self.bounce_mode = state == Qt.CheckState.Checked.value
self.bounce_start_spinbox.setEnabled(self.bounce_mode)
self.bounce_end_spinbox.setEnabled(self.bounce_mode)
[docs]
def on_bounce_range_changed(self):
"""Handle bounce range change"""
self.bounce_start = self.bounce_start_spinbox.value()
self.bounce_end = self.bounce_end_spinbox.value()
# Ensure start < end
if self.bounce_start >= self.bounce_end:
self.bounce_start_spinbox.blockSignals(True)
self.bounce_start = max(0, self.bounce_end - 1)
self.bounce_start_spinbox.setValue(self.bounce_start)
self.bounce_start_spinbox.blockSignals(False)
[docs]
def frame_changed(self, frame_index):
"""Override this method to handle frame changes"""
pass