I'm creating a video player, below is an MRE implementation of the VideoWidget
and a widget to display the video controls, called ControlBar
:
import os
from PySide6 import QtMultimedia as qtm
from PySide6 import QtMultimediaWidgets as qtmw
from PySide6 import QtWidgets as qtw
from PySide6 import QtCore as qtc
from PySide6 import QtGui as qtg
class VideoWidget(qtmw.QVideoWidget):
load_finished_signal = qtc.Signal()
_playback_state_changed_signal = qtc.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setFocusPolicy(qtc.Qt.FocusPolicy.NoFocus)
self.setMouseTracking(True)
# In Qt6, a private internal class is used for rendering the video, so
# add `vw.children()[0].setMouseTracking(True)`
self.children()[0].setMouseTracking(True)
self._seeking = False
self._audio_output = qtm.QAudioOutput(qtm.QMediaDevices.defaultAudioOutput())
self._media_player = qtm.QMediaPlayer(self)
self._control_bar = ControlBar(self)
self._hide_control_bar_timer = qtc.QTimer()
self._media_player.setVideoOutput(self)
self._media_player.setAudioOutput(self._audio_output)
self._media_player.mediaStatusChanged.connect(self._video_loaded)
self._media_player.durationChanged.connect(
self._control_bar.update_total_duration
)
self._media_player.positionChanged.connect(self._control_bar.update_progress)
self.videoSink().videoFrameChanged.connect(self._seeked)
self._control_bar.seek_requested_signal.connect(self._seek)
self._control_bar.pause_play_requested_signal.connect(self._change_playback_state)
self._playback_state_changed_signal.connect(self._control_bar.playback_state_changed)
self._hide_control_bar_timer.setSingleShot(True)
self._hide_control_bar_timer.setInterval(1000)
self._hide_control_bar_timer.timeout.connect(self._hide_control_bar)
def _hide_control_bar(self) -> None:
if not self._control_bar.underMouse():
self._control_bar.setVisible(False)
cursor = self.cursor()
cursor.setShape(qtc.Qt.CursorShape.BlankCursor)
self.setCursor(cursor)
def load(self, location: str) -> None:
self._media_player.setSource(qtc.QUrl.fromLocalFile(location))
def _video_loaded(self, location: str) -> None:
self._control_bar.set_filename(
os.path.basename(self._media_player.source().toString())
)
if self._media_player.source():
self._media_player.play()
self.load_finished_signal.emit()
def close_item(self) -> None:
self._media_player.stop()
self._media_player.setSource(qtc.QUrl())
def _screenshot(self, video_frame: qtm.QVideoFrame):
image = video_frame.toImage()
image.save(filename)
def _update_conrol_bar_position(self) -> None:
geometry = self.geometry()
top_left_global = geometry.topLeft()
self._control_bar.update_geometry_to_width(self.width())
self._control_bar.move(
top_left_global.x(),
top_left_global.y() + geometry.height() - self._control_bar.height(),
)
def _change_playback_state(self) -> None:
if (
self._media_player.playbackState()
== qtm.QMediaPlayer.PlaybackState.PlayingState
):
self._media_player.pause()
elif (
self._media_player.playbackState()
== qtm.QMediaPlayer.PlaybackState.PausedState
or self._media_player.playbackState()
== qtm.QMediaPlayer.PlaybackState.StoppedState
):
self._media_player.play()
self._playback_state_changed_signal.emit()
def mouseMoveEvent(self, event: qtg.QMouseEvent) -> None:
if not self.cursor().shape() & qtc.Qt.CursorShape.ArrowCursor:
cursor = self.cursor()
cursor.setShape(qtc.Qt.CursorShape.ArrowCursor)
self.setCursor(cursor)
if (
not self.width() == self._control_bar.width()
or not self.geometry().contains(self._control_bar.geometry())
):
self._update_conrol_bar_position()
if not self._control_bar.isVisible():
self._control_bar.setVisible(True)
self._hide_control_bar_timer.start()
def mousePressEvent(self, event: qtg.QMouseEvent) -> None:
self._change_playback_state()
event.accept()
return
def mouseDoubleClickEvent(self, event: qtg.QMouseEvent) -> None:
F_event = qtg.QKeyEvent(
qtc.QEvent.KeyPress,
qtc.Qt.Key.Key_F,
qtc.Qt.KeyboardModifier.NoModifier,
"F",
)
qtc.QCoreApplication.sendEvent(self, F_event)
super().mouseDoubleClickEvent(event)
def resizeEvent(self, event: qtg.QResizeEvent) -> None:
super().resizeEvent(event)
self._update_conrol_bar_position()
def contextMenuEvent(self, event: qtg.QContextMenuEvent):
""""""
def keyPressEvent(self, event: qtg.QKeyEvent) -> None:
key = event.key()
if (
key == qtc.Qt.Key.Key_Left
or key == qtc.Qt.Key.Key_Right
or key == qtc.Qt.Key.Key_Less
or key == qtc.Qt.Key.Key_Greater
):
factor = (
1
if event.modifiers() == qtc.Qt.KeyboardModifier.ShiftModifier
else 60
if event.modifiers() == qtc.Qt.KeyboardModifier.ControlModifier
else 5
)
if key == qtc.Qt.Key.Key_Left:
self._seek(max(self._media_player.position() - (1000 * factor), 0))
elif key == qtc.Qt.Key.Key_Right:
self._seek(
min(
self._media_player.position() + (1000 * factor),
self._media_player.duration(),
)
)
elif key == qtc.Qt.Key.Key_F5:
self._screenshot(self.videoSink().videoFrame())
elif key == qtc.Qt.Key.Key_Space:
self._change_playback_state()
elif key == qtc.Qt.Key.Key_Down:
self._media_player.audioOutput().setVolume(
max(self._media_player.audioOutput().volume() - 0.1, 0)
)
elif key == qtc.Qt.Key.Key_Up:
self._media_player.audioOutput().setVolume(
min(self._media_player.audioOutput().volume() + 0.1, 1)
)
elif key == qtc.Qt.Key.Key_M:
self._media_player.audioOutput().setMuted(
not self._media_player.audioOutput().isMuted()
)
else:
super().keyPressEvent(event)
return None
event.accept()
return None
"""
- next in directory
- previous in directory
"""
def _seek(self, position: int) -> None:
if self._seeking:
return
self._seeking = True
if self._media_player.isSeekable():
self._media_player.setPosition(position)
else:
self._seeked()
def _seeked(self):
self._seeking = False
def wheelEvent(self, event: qtg.QWheelEvent) -> None:
"volume"
class ControlBar(qtw.QWidget):
seek_requested_signal = qtc.Signal(int)
pause_play_requested_signal = qtc.Signal()
_WRT_HEIGHT = 100
_MAX_FONT_POINT_F = 20
_WRT_WIDTH = 1920 / 2
# Height should be _WRT_HEIGHT if width is _WRT_WIDTH.
_CONTROL_BAR_WIDTH_TO_HEIGHT_RATIO = _WRT_HEIGHT / _WRT_WIDTH
_FONT_POINT_F_RATIO = _MAX_FONT_POINT_F / _WRT_HEIGHT
_WIDGETS_SIZE_RATIOS = {
"filename": (_WRT_WIDTH / _WRT_WIDTH, 40 / _WRT_HEIGHT),
"pause_play": (30 / _WRT_WIDTH, 35 / _WRT_HEIGHT),
"pause": (10 / _WRT_WIDTH, 35 / _WRT_HEIGHT),
"play": (25 / _WRT_WIDTH, 35 / _WRT_HEIGHT),
"left_duration": (0 / _WRT_WIDTH, 40 / _WRT_HEIGHT),
"right_duration": (0 / _WRT_WIDTH, 40 / _WRT_HEIGHT),
"total_progress_bar": (578 / _WRT_WIDTH, 40 / _WRT_HEIGHT),
"completed_progress_bar": (0 / _WRT_WIDTH, 40 / _WRT_HEIGHT),
}
_PADDINGS_RATIOS = {"v_pad": 5 / _WRT_HEIGHT, "h_pad": 10 / _WRT_WIDTH}
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setWindowFlag(qtc.Qt.WindowType.ToolTip)
self._total_duration_ms = 0
self._completed_duration_ms = 0
self._widget_shapes = {
"filename_rect": qtc.QRect(),
"pause_play_button_rect": qtc.QRect(),
"pause_path": qtg.QPainterPath(),
"play_triangle": qtg.QPolygon(),
"left_duration_rect": qtc.QRect(),
"progress_bar_rect": qtc.QRect(),
"right_duration_rect": qtc.QRect(),
}
self._show_milliseconds = False
self._filename = ""
self._paused = False
def playback_state_changed(self) -> None:
self._paused = not self._paused
def paintEvent(self, event: qtg.QPaintEvent) -> None:
painter = qtg.QPainter(self)
painter.setRenderHint(qtg.QPainter.RenderHint.Antialiasing)
painter.fillRect(0, 0, self.width(), self.height(), qtc.Qt.GlobalColor.black)
pen = qtg.QPen()
pen.setColor(qtc.Qt.GlobalColor.white)
painter.setPen(pen)
painter.setFont(self._get_font())
painter.drawText(
self._widget_shapes["filename_rect"],
qtc.Qt.AlignmentFlag.AlignLeft,
self._filename,
)
if self._paused:
path = qtg.QPainterPath()
path.addPolygon(self._widget_shapes["play_triangle"])
painter.fillPath(path, qtc.Qt.GlobalColor.white)
else:
painter.fillPath(self._widget_shapes["pause_path"], qtc.Qt.GlobalColor.white)
painter.drawText(
self._widget_shapes["left_duration_rect"],
qtc.Qt.AlignmentFlag.AlignCenter,
_format_milliseconds(self._completed_duration_ms, self._show_milliseconds),
)
painter.fillRect(
self._widget_shapes["progress_bar_rect"], qtc.Qt.GlobalColor.gray
)
painter.fillRect(
self._get_completed_progress_bar_rect(), qtc.Qt.GlobalColor.white
)
painter.drawText(
self._widget_shapes["right_duration_rect"],
qtc.Qt.AlignmentFlag.AlignCenter,
_format_milliseconds(self._total_duration_ms, self._show_milliseconds),
)
def set_filename(self, filename: str) -> None:
self._filename = filename
def update_total_duration(self, duration: int) -> None:
self._total_duration_ms = duration
self.update()
def update_progress(self, progress: int) -> None:
self._completed_duration_ms = progress
self.update()
def update_geometry_to_width(self, width: int) -> None:
new_control_bar_height = int(self._CONTROL_BAR_WIDTH_TO_HEIGHT_RATIO * width)
self.setFixedSize(width, min(new_control_bar_height, self._WRT_HEIGHT))
self._update_widgets_geometry()
def _update_widgets_geometry(self) -> None:
total_height = 0
row_1_total_width = 0
row_2_total_width = 0
total_height += self.height() * self._PADDINGS_RATIOS["v_pad"]
row_1_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
row_2_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
font_metric = qtg.QFontMetrics(self._get_font())
horizontal_advance = font_metric.horizontalAdvance("A")
self._widget_shapes["filename_rect"].setX(row_1_total_width)
self._widget_shapes["filename_rect"].setY(total_height)
self._widget_shapes["filename_rect"].setWidth(
horizontal_advance * len(self._filename)
)
self._widget_shapes["filename_rect"].setHeight(
self.height() * self._WIDGETS_SIZE_RATIOS["filename"][1]
)
total_height += self._widget_shapes["filename_rect"].height()
row_1_total_width += self._widget_shapes["filename_rect"].width()
total_height += self.height() * self._PADDINGS_RATIOS["v_pad"]
row_1_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
self._widget_shapes["pause_play_button_rect"].setX(row_2_total_width)
self._widget_shapes["pause_play_button_rect"].setY(total_height)
self._widget_shapes["pause_play_button_rect"].setWidth(
min(30, self.width() * self._WIDGETS_SIZE_RATIOS["pause_play"][0])
)
self._widget_shapes["pause_play_button_rect"].setHeight(
self.height() * self._WIDGETS_SIZE_RATIOS["pause_play"][1]
)
self._widget_shapes["pause_path"] = qtg.QPainterPath()
pause_1 = qtc.QRect(
self._widget_shapes["pause_play_button_rect"].x(),
self._widget_shapes["pause_play_button_rect"].y(),
min(10, self.width() * self._WIDGETS_SIZE_RATIOS["pause"][0]),
self.height() * self._WIDGETS_SIZE_RATIOS["pause"][1],
)
self._widget_shapes["pause_path"].addRect(pause_1)
pause_2 = qtc.QRect(
self._widget_shapes["pause_play_button_rect"].topRight().x(),
self._widget_shapes["pause_play_button_rect"].y(),
-min(10, self.width() * self._WIDGETS_SIZE_RATIOS["pause"][0]),
self.height() * self._WIDGETS_SIZE_RATIOS["pause"][1],
)
self._widget_shapes["pause_path"].addRect(pause_2)
self._widget_shapes["play_triangle"] = qtg.QPolygon()
right = self._widget_shapes["pause_play_button_rect"].center()
right.setX(self._widget_shapes["pause_play_button_rect"].right())
self._widget_shapes["play_triangle"].append(
[
self._widget_shapes["pause_play_button_rect"].topLeft(),
self._widget_shapes["pause_play_button_rect"].bottomLeft(),
right,
]
)
row_2_total_width += self._widget_shapes["pause_play_button_rect"].width()
row_2_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
self._widget_shapes["left_duration_rect"].setX(row_2_total_width)
self._widget_shapes["left_duration_rect"].setY(total_height)
self._widget_shapes["left_duration_rect"].setWidth(
horizontal_advance
* len(
_format_milliseconds(
self._completed_duration_ms, self._show_milliseconds
)
)
)
self._widget_shapes["left_duration_rect"].setHeight(
self.height() * self._WIDGETS_SIZE_RATIOS["left_duration"][1]
)
row_2_total_width += self._widget_shapes["left_duration_rect"].width()
row_2_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
self._widget_shapes["progress_bar_rect"].setX(row_2_total_width)
self._widget_shapes["progress_bar_rect"].setY(total_height)
self._widget_shapes["progress_bar_rect"].setWidth(
self.width() * self._WIDGETS_SIZE_RATIOS["total_progress_bar"][0]
)
self._widget_shapes["progress_bar_rect"].setHeight(
self.height() * self._WIDGETS_SIZE_RATIOS["total_progress_bar"][1]
)
row_2_total_width += self._widget_shapes["progress_bar_rect"].width()
row_2_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
# To adjust the size of the progress bar according to the remaining width
# left after adding all remaining widget's widths.
theoritical_width = row_2_total_width
theoritical_width += horizontal_advance * len(
_format_milliseconds(self._total_duration_ms, self._show_milliseconds)
)
theoritical_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
remaining_width = self.width() - theoritical_width
self._widget_shapes["progress_bar_rect"].setWidth(
self._widget_shapes["progress_bar_rect"].width() + remaining_width
)
row_2_total_width += remaining_width
self._widget_shapes["right_duration_rect"].setX(row_2_total_width)
self._widget_shapes["right_duration_rect"].setY(total_height)
self._widget_shapes["right_duration_rect"].setWidth(
horizontal_advance
* len(
_format_milliseconds(self._total_duration_ms, self._show_milliseconds)
)
)
self._widget_shapes["right_duration_rect"].setHeight(
self.height() * self._WIDGETS_SIZE_RATIOS["right_duration"][1]
)
row_2_total_width += self._widget_shapes["right_duration_rect"].width()
row_2_total_width += self.width() * self._PADDINGS_RATIOS["h_pad"]
def _get_font(self) -> qtg.QFont:
font = qtg.QFont()
font.setPointSizeF(self.height() * self._FONT_POINT_F_RATIO)
return font
def _get_completed_progress_bar_rect(self) -> qtc.QRect:
completed_width = int(
(self._completed_duration_ms / (self._total_duration_ms or 1))
* self._widget_shapes["progress_bar_rect"].width()
)
completed_rect = qtc.QRect(self._widget_shapes["progress_bar_rect"])
completed_rect.setWidth(completed_width)
return completed_rect
def _get_time_from_mouse_press(self, point: qtc.QPoint) -> None:
return int(
(point.x() / self._widget_shapes["progress_bar_rect"].width())
* self._total_duration_ms
)
def mousePressEvent(self, event: qtg.QMouseEvent) -> None:
point = event.pos()
if self._widget_shapes["left_duration_rect"].contains(point):
self._show_milliseconds = not self._show_milliseconds
self._update_widgets_geometry()
elif self._widget_shapes["progress_bar_rect"].contains(point):
self.seek_requested_signal.emit(
self._get_time_from_mouse_press(
point - self._widget_shapes["progress_bar_rect"].topLeft()
)
)
elif self._widget_shapes["pause_play_button_rect"].contains(point):
self.pause_play_requested_signal.emit()
self.update()
event.accept()
return
def mouseMoveEvent(self, event: qtg.QMouseEvent) -> None:
if self._widget_shapes["progress_bar_rect"].contains(event.pos()):
self.seek_requested_signal.emit(
self._get_time_from_mouse_press(
event.pos() - self._widget_shapes["progress_bar_rect"].topLeft()
)
)
event.accept()
return
def _format_milliseconds(milliseconds: int, show_milliseconds: bool = False) -> str:
seconds, milliseconds = divmod(milliseconds, 1000)
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return f"{hours:02}:{minutes:02}:{seconds:02}" + (
f".{milliseconds:03}" if show_milliseconds else ""
)
app = qtw.QApplication()
vw = VideoWidget()
vw.show()
vw.load("video_file")
app.exec()
While this works as expected for the most part, if I alt-tab into another window (effectively hiding the program) with the ControlBar
displayed, the bar is visible on top of the other window as well. I'm guessing such is the nature of the Qt.ToolTip
window flag. How do I have it "show" or "hide" with the main program?
Also, the _update_widgets_geometry()
function's implementation is rather tedious, is there a better way to do this? "This" being the way I layout (set the QRect
s for) the shapes in the ControlBar
.
EDIT:
The code should work "out of the box", just pass a valid video file path to the vw.load()
at the end of the code. A screenshot.