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 receiving KeyPress events.
  • Ghost Releases from Auto-Repeat: When holding down a key on most operating systems, the system repeatedly fires a sequence of simulated KeyPress and KeyRelease events. 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 QSlider intercept arrow keys. Disabling focus on non-text child widgets with setFocusPolicy(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 underlying QMediaPlayer.setPosition() call ensures snappy, smooth scrubbing.