How to Handle Arrow Key Press and Release in PyQt6 Without Focus or Auto-Repeat Glitches
Why Handling Arrow Keys in PyQt Can Be Surprisingly Tricky
Building intuitive playback controls for a custom media player in PyQt or PySide often sounds deceptively simple. If a user holds down the right arrow key, you want to pause playback, scrub forward continuously, and resume playback as soon as the key is released. However, many developers encounter two frustrating roadblocks when implementing this behavior:
- Focus Traversal Hijacking: Arrow keys (
Key_Left,Key_Right,Key_Up,Key_Down) are reserved by Qt's accessibility and navigation framework for switching focus between controls (like buttons and sliders), preventing the parent window from receivingKeyPressevents. - Ghost Releases from Auto-Repeat: When holding down a key on most operating systems, the system repeatedly fires a sequence of simulated
KeyPressandKeyReleaseevents. If you don't filter out auto-repeated release events, your application will mistakenly think the user let go of the key repeatedly.
Let’s walk through how to solve both issues cleanly using standard PyQt6 APIs.
Issue 1: Arrow Keys Never Reach the Parent Widget
In Qt, widgets like QPushButton and QSlider have a default focus policy that accepts arrow key navigation. When a slider or button has focus, it absorbs arrow key events to either adjust its value or pass focus to a sibling widget. Because events travel upward from the focused child to the parent (and not downward), your main window never sees the initial KeyPress event.
The Fix: Update Focus Policies
If your media player controls don't need keyboard navigation via arrow keys, set their focus policy to QtCore.Qt.FocusPolicy.NoFocus. This guarantees that your main container window retains active focus and receives all keyboard input:
self.play_pause_btn.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
self.seek_bar.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
self.setFocus()Issue 2: KeyRelease Fires on Every Auto-Repeat
When you hold a key down, Qt fires alternating KeyPress and KeyRelease events. Qt provides the event.isAutoRepeat() method to distinguish physical key presses and releases from OS-generated repeats.
A common bug is checking if not event.isAutoRepeat() only on the KeyPress event while forgetting to check it on KeyRelease. If you omit this check on release, your application will resume playback after every single repeat tick, creating a stuttering loop.
Correcting the Event Filter
Always inspect event.isAutoRepeat() on both key down and key up events:
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.Type.KeyPress:
if delta := self.whichKey(event):
if not event.isAutoRepeat():
self.key_pressed.emit() # Only fires on the initial physical press
self.skip.emit(delta)
return True
elif event.type() == QtCore.QEvent.Type.KeyRelease:
if self.whichKey(event):
if not event.isAutoRepeat():
self.key_released.emit() # Only fires when physically released
return True
return super().eventFilter(obj, event)Complete Working Solution
Below is a refactored, robust implementation. It sets focus policies properly, ignores synthetic auto-repeats, and debounces playback position updates so scrubbing remains fast and fluid:
import os
import datetime
from PyQt6 import QtCore, QtGui, QtWidgets, QtMultimedia, QtMultimediaWidgets
class VideoKeyFilter(QtCore.QObject):
keyPressed = QtCore.pyqtSignal()
keyReleased = QtCore.pyqtSignal()
skipStep = QtCore.pyqtSignal(int)
def eventFilter(self, obj, event):
event_type = event.type()
if event_type == QtCore.QEvent.Type.KeyPress:
delta = self._get_skip_delta(event.key())
if delta != 0:
if not event.isAutoRepeat():
self.keyPressed.emit()
self.skipStep.emit(delta)
return True
elif event_type == QtCore.QEvent.Type.KeyRelease:
if self._get_skip_delta(event.key()) != 0:
if not event.isAutoRepeat():
self.keyReleased.emit()
return True
return super().eventFilter(obj, event)
@staticmethod
def _get_skip_delta(key):
if key == QtCore.Qt.Key.Key_Right:
return 5000 # 5 seconds forward
if key == QtCore.Qt.Key.Key_Left:
return -5000 # 5 seconds backward
return 0
class VideoPlayer(QtWidgets.QWidget):
def __init__(self, file_path):
super().__init__()
self.setWindowTitle(os.path.basename(file_path))
self.resize(800, 600)
# Media player setup
self.media_player = QtMultimedia.QMediaPlayer()
self.video_widget = QtMultimediaWidgets.QVideoWidget()
self.media_player.setVideoOutput(self.video_widget)
self.media_player.setSource(QtCore.QUrl.fromLocalFile(file_path))
# UI controls
self.play_pause_btn = QtWidgets.QPushButton()
self.play_pause_btn.setIcon(self.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MediaPlay))
self.position_lbl = QtWidgets.QLabel("0:00:00")
self.seek_bar = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
self.duration_lbl = QtWidgets.QLabel("0:00:00")
# Prevent controls from stealing arrow key navigation
self.play_pause_btn.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
self.seek_bar.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
# Layouts
controls_layout = QtWidgets.QHBoxLayout()
controls_layout.addWidget(self.play_pause_btn)
controls_layout.addWidget(self.position_lbl)
controls_layout.addWidget(self.seek_bar)
controls_layout.addWidget(self.duration_lbl)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(self.video_widget)
main_layout.addLayout(controls_layout)
# Signals
self.play_pause_btn.clicked.connect(self.toggle_play_pause)
self.media_player.positionChanged.connect(self.update_seek_bar)
self.media_player.durationChanged.connect(self.update_duration)
self.media_player.playbackStateChanged.connect(self.update_play_icon)
# Debounce timer for smooth seeking
self.seek_timer = QtCore.QTimer(self)
self.seek_timer.setSingleShot(True)
self.seek_timer.setInterval(50)
self.seek_timer.timeout.connect(self.apply_seek)
self.was_playing_before_scrub = False
# Event filter for arrow keys
self.key_filter = VideoKeyFilter(self)
self.key_filter.keyPressed.connect(self.on_scrub_start)
self.key_filter.keyReleased.connect(self.on_scrub_end)
self.key_filter.skipStep.connect(self.on_scrub_step)
self.installEventFilter(self.key_filter)
def is_playing(self):
return self.media_player.playbackState() == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState
def toggle_play_pause(self):
if self.is_playing():
self.media_player.pause()
else:
self.media_player.play()
def update_play_icon(self, state):
icon = (QtWidgets.QStyle.StandardPixmap.SP_MediaPause
if state == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState
else QtWidgets.QStyle.StandardPixmap.SP_MediaPlay)
self.play_pause_btn.setIcon(self.style().standardIcon(icon))
def update_seek_bar(self, position):
if not self.seek_timer.isActive():
self.seek_bar.setValue(position)
sec = int(position / 1000)
self.position_lbl.setText(str(datetime.timedelta(seconds=sec)))
def update_duration(self, duration):
self.seek_bar.setRange(0, duration)
sec = int(duration / 1000)
self.duration_lbl.setText(str(datetime.timedelta(seconds=sec)))
def on_scrub_start(self):
self.was_playing_before_scrub = self.is_playing()
if self.was_playing_before_scrub:
self.media_player.pause()
def on_scrub_step(self, delta):
new_position = max(0, min(self.seek_bar.value() + delta, self.seek_bar.maximum()))
self.seek_bar.setValue(new_position)
self.seek_timer.start()
def on_scrub_end(self):
if self.was_playing_before_scrub:
self.media_player.play()
def apply_seek(self):
self.media_player.setPosition(self.seek_bar.value())
if __name__ == "__main__":
app = QtWidgets.QApplication([])
player = VideoPlayer("path_to_video.mp4")
player.show()
app.exec()Key Takeaways
- Never ignore
event.isAutoRepeat()on release: Both key presses and key releases generate repeated events while a key is held. Guarding both ensures your state changes (like pausing and resuming) only execute at the actual start and end of the gesture. - Beware of default widget focus: Widgets like
QSliderintercept arrow keys. Disabling focus on non-text child widgets withsetFocusPolicy(Qt.FocusPolicy.NoFocus)allows your main container to reliably capture directional inputs. - Debounce hardware seeks: Video decoders can stutter if you hammer
setPosition()on every auto-repeat tick. Updating a slider value immediately while debouncing the underlyingQMediaPlayer.setPosition()call ensures snappy, smooth scrubbing.