Failamp, a Multimedia Player
Multimedia playlist and player in Python, using PyQt

Failamp is a simple audio & video mediaplayer implemented in Python, using the built-in Qt playlist and media handling features. It is modelled, very loosely on the original Winamp, although nowhere near as complete (hence the fail).

The Main Window

The main window UI was built using Qt Designer. The screenshot below shows the constructed layout, with the default colour scheme as visible within Designer.

Mediaplayer Design in Qt Designer Mediaplayer Design in Qt Designer

The layout is constructed in a QVBoxLayout which in turn contains the playlist view (QListView) and two QHBoxLayout horizontal layouts which contain the time slider and time indicators and the control buttons respectively.

Player

First we need to setup the Qt media player controller QMediaPlayer. This controller handles load and playback of media files automatically, we just need to provide it with the appropriate signals.

We create a persistent player which we'll use globally. We setup an error handler by connecting our custom erroralert slot to the error signal.

python
    class MainWindow(QMainWindow, Ui_MainWindow):
        def __init__(self, *args, **kwargs):
            super(MainWindow, self).__init__(*args, **kwargs)
            self.setupUi(self)

            self.player = QMediaPlayer()
            self.player.error.connect(self.erroralert)

The generic media player controls can all be connected to the player directly, using the appropriate slots on self.player.

python
            # Connect control buttons/slides for media player.
            self.playButton.pressed.connect(self.player.play)
            self.pauseButton.pressed.connect(self.player.pause)
            self.stopButton.pressed.connect(self.player.stop)

We also have two slots for volume control and time slider position. Updating either of these will alter the playback automatically, without any handling on by us.

python
            self.volumeSlider.valueChanged.connect(self.player.setVolume)
            self.timeSlider.valueChanged.connect(self.player.setPosition)

Finally we connect up our timer display methods to the player position signals, allowing us to automatically update the display as the play position changes.

python
            self.player.durationChanged.connect(self.update_duration)
            self.player.positionChanged.connect(self.update_position)

When you drag the slider, this sends a signal to update the play position, which in turn sends a signal to update the time display. Chaining operations off each other allows you to keep your app components independent, one of the great things about signals.

Qt Multimedia also provides a simple playlist controller. This does not provide the widget itself, just a simple interface for queuing up tracks (we handle the display using our own QListView).

Playlist

Helpfully, the playlist can be passed to the player, which wil then use it to automatically select the track to play once the current one is complete.

python
            self.playlist = QMediaPlaylist()
            self.player.setPlaylist(self.playlist)

The previous and next control buttons are connected to the playlist and will perform skip/restart/back as expected (all handled by QMediaPlaylist). Because the playlist is connected to the player, this will automatically trigger the player to play the appropriate track.

python
            self.previousButton.pressed.connect(self.playlist.previous)
            self.nextButton.pressed.connect(self.playlist.next)

The display of the playlist is handled by a QListView object. This is a view component from Qt's model/view architecture, which is used to efficiently display data held in data models. In our case, we are storing the data in a playlist object QMediaPlaylist from the Qt Multimedia module.

The PlaylistModel is our custom model for taking data from the QMediaPlaylist and mapping it to the view. We instantiate the model and pass it into our view.

python
            self.model = PlaylistModel(self.playlist)
            self.playlistView.setModel(self.model)
            self.playlist.currentIndexChanged.connect(self.playlist_position_changed)
            selection_model = self.playlistView.selectionModel()
            selection_model.selectionChanged.connect(self.playlist_selection_changed)

Opening and dropping

We have a single file operation — open a file — which adds the file to the playlist. We also accept drag and drop, which is covered later.

python
            self.open_file_action.triggered.connect(self.open_file)
            self.setAcceptDrops(True)

Video

Finally, we add the viewer for video playback. If we don't add this the player will still play videos, but just play the audio component. Video playback is handled by a specific QVideoWidget. To enable playback we just pass this widget to the players .setVideoOutput method.

python
            self.viewer = ViewerWindow(self)
            self.viewer.setWindowFlags(self.viewer.windowFlags() | Qt.WindowStaysOnTopHint)
            self.viewer.setMinimumSize(QSize(480,360))

            videoWidget = QVideoWidget()
            self.viewer.setCentralWidget(videoWidget)
            self.player.setVideoOutput(videoWidget)

Finally we just enable the toggles for the viewer to show/hide on demand.

python
            self.viewButton.toggled.connect(self.toggle_viewer)
            self.viewer.state.connect(self.viewButton.setChecked)

Playlist model

As mentioned we're using a QListView object from Qt's model/view architecture for playlist display, with data held in the QMediaPlaylist. Since the data store is already handled for us, all we need to handle is the mapping from the playlist to the view.

In this case the requirements are pretty basic — we need:

  1. a method rowCount to return the total number of rows in the playlist, via .mediaCount()
  2. a method data which returns data for a specific row, in this case we're only displaying the filename

You could extend this to access media file metadata and show the track name instead.

python
class PlaylistModel(QAbstractListModel):
    def __init__(self, playlist, *args, **kwargs):
        super(PlaylistModel, self).__init__(*args, **kwargs)
        self.playlist = playlist

    def data(self, index, role):
        if role == Qt.DisplayRole:
            media = self.playlist.media(index.row())
            return media.canonicalUrl().fileName()

    def rowCount(self, index):
        return self.playlist.mediaCount()

By storing a reference to the playlist in __init__ the can get the other data easily at any time. Changes to the playlist in the application will be automatically reflected in the view.

The playlist and the player can handle track changes automatically, and we have the controls for skipping. However, we also want users to be able to select a track to play in the playlist, and we want the selection in the playlist view to update automatically as the tracks progress.

For both of these we need to define our own custom handlers. The first is for updating the playlist position in response to playlist selection by the user —

python
        def playlist_selection_changed(self, ix):
            # We receive a QItemSelection from selectionChanged.
            i = ix.indexes()[0].row()
            self.playlist.setCurrentIndex(i)

The next is to update the selection in the playlist as the track progresses. We specifically check for -1 since this value is sent by the playlist when there are not more tracks to play — either we're at the end of the playlist, or the playlist is empty.

python
        def playlist_position_changed(self, i):
            if i > -1:
                ix = self.model.index(i)
                self.playlistView.setCurrentIndex(ix)

Drag, drop and file operations

We enabled drag and drop on the main window by setting self.setAcceptDrops(True). With this enabled, the main window will raise the dragEnterEvent and dropEvent events when we perform drag-drop operations.

This enter/drop duo is the standard approach to drag-drop in desktop UIs. The enter event recognises what is being dropped and either *accepts* or *rejects*. Only if accepted can a drop occur.

The dragEnterEvent checks whether the dragged object is droppable on our application. In this implementation we're very lax — we only check that the drop is file (by path). By default QMimeData has checks built in for html, image, text and path/URL types, but not audio or video. If we want these we would have to implement them ourselves.

We could add a check here for specific file extension based on what we support.

python
        def dragEnterEvent(self, e):
            if e.mimeData().hasUrls():
                e.acceptProposedAction()

The dropEvent iterates over the URLs in the provided data, and adds them to the playlist. If we're not playing, dropping the file triggers autoplay from the newly added file.

python
        def dropEvent(self, e):
            for url in e.mimeData().urls():
                self.playlist.addMedia(
                    QMediaContent(url)
                )

            self.model.layoutChanged.emit()

            # If not playing, seeking to first of newly added + play.
            if self.player.state() != QMediaPlayer.PlayingState:
                i = self.playlist.mediaCount() - len(e.mimeData().urls())
                self.playlist.setCurrentIndex(i)
                self.player.play()

The single operation defined is to open a file, which adds it to the current playlist. We predefine a number of standard audio and video file types — you can easily add more, as long as they are supported by the QMediaPlayer controller, they will work fine.

python
        def open_file(self):
            path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "mp3 Audio (*.mp3);;mp4 Video (*.mp4);;Movie files (*.mov);;All files (*.*)")

            if path:
                self.playlist.addMedia(
                    QMediaContent(
                        QUrl.fromLocalFile(path)
                    )
                )

            self.model.layoutChanged.emit()

Position duration

The QMediaPlayer controller emits signals when the current playback duration and position are updated. The former is changed when the current media being played changes, e.g. when we progress to the next track. The second is emitted repeatedly as the play position updates during playback.

Both receive an int64 (64 bit integer) which represents the time in milliseconds. This same scale is used by all signals so there is no conversion between them, and we can simply pass the value to our slider to update.

python
        def update_duration(self, duration):
            self.timeSlider.setMaximum(duration)

            if duration >= 0:
                self.totalTimeLabel.setText(hhmmss(duration))

One slightly tricky thing occurs where we update the slider position. We want to update the slider as the track progresses, however updating the slider triggers the update of the position (so the user can drag to a position in the track). This can trigger weird behaviour and a possible endless loop.

To work around it we just block the signals while we make the update, and re-enable the after.

python
        def update_position(self, position):
            if position >= 0:
                self.currentTimeLabel.setText(hhmmss(position))

            # Disable the events to prevent updating triggering a setPosition event (can cause stuttering).
            self.timeSlider.blockSignals(True)
            self.timeSlider.setValue(position)
            self.timeSlider.blockSignals(False)

Create GUI Applications with Python & Qt6
The easy way to create desktop applications

My complete guide, updated for 2021 & PyQt6. Everything you need build real apps.

Downloadable ebook (PDF, ePub) & Complete Source code

To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount with the code [[ couponCode ]] — Enjoy!

For [[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount with the code [[ couponCode ]] — Enjoy!

Timer

Finally, we need a method to convert a time in milliseconds in to an h:m:s or m:s display. For this we can use a series of divmod calls with the milliseconds for each time division. This returns the number of complete divisions (div) and the remainder (mod). The slight tweak is to only show the hour part when the time is longer than an hour.

python
    def hhmmss(ms):
        # s = 1000
        # m = 60000
        # h = 3600000
        s = round(ms / 1000)
        m, s = divmod(s, 60)
        h, m = divmod(m, 60)
        return ("%d:%02d:%02d" % (h,m,s)) if h else ("%d:%02d" % (m,s))

If you try this on Windows you may notice that the duration reported is incorrect. This is because of an issue with the legacy DirectShow media driver on Windows, which is unfortunately enabled by default. If you switch to windowsmediafoundation instead you get accurate duration reporting and better media file support. Usually an easy choice. To enable it, just add the following to your application, somewhere before you create the QApplication instance. os.environ['QT_MULTIMEDIA_PREFERRED_PLUGINS'] = 'windowsmediafoundation' You'll need to import os first. For more information, see the Qt documentation on Windows multimedia.

Video viewer

The video viewer is a simple QMainWindow with the addition of a toggle handler to show/display the window. We also add a hook into the closeEvent to update the toggle button, while overriding the default behaviour — closing the window will not actually close it, just hide it.

python
    class ViewerWindow(QMainWindow):
        state = pyqtSignal(bool)

        def closeEvent(self, e):
            # Emit the window state, to update the viewer toggle button.
            self.state.emit(False)

        def toggle_viewer(self, state):
            if state:
                self.viewer.show()
            else:
                self.viewer.hide()

Styling the windows

To mimic the style of Winamp (badly) we're using the Fusion application style as a base, then applying a dark theme. The Fusion style is nice Qt cross-platform application style. The dark theme has been borrowed from this Gist from user QuantumCD.

python
        app.setStyle("Fusion")

        palette = QPalette() # Get a copy of the standard palette.
        palette.setColor(QPalette.Window, QColor(53, 53, 53))
        palette.setColor(QPalette.WindowText, Qt.white)
        palette.setColor(QPalette.Base, QColor(25, 25, 25))
        palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53))
        palette.setColor(QPalette.ToolTipBase, Qt.white)
        palette.setColor(QPalette.ToolTipText, Qt.white)
        palette.setColor(QPalette.Text, Qt.white)
        palette.setColor(QPalette.Button, QColor(53, 53, 53))
        palette.setColor(QPalette.ButtonText, Qt.white)
        palette.setColor(QPalette.BrightText, Qt.red)
        palette.setColor(QPalette.Link, QColor(42, 130, 218))
        palette.setColor(QPalette.Highlight, QColor(42, 130, 218))
        palette.setColor(QPalette.HighlightedText, Qt.black)
        app.setPalette(palette)

        # Additional CSS styling for tooltip elements.
        app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }")

This covers all the elements used in this Failamp. If you want to use this is another app you may need to add additional CSS tweaks, like that added for QToolTip.

This gives us the final result —

mediaplayer-demo.jpg mediaplayer-demo.jpg

Suggestions for improvements

If you want to take the app further, here are some suggestions of things to do.

  1. Auto-displaying the video viewer when viewing video (and auto-hiding when not)
  2. Docking of windows so they can be snapped together — like the original Winamp.
  3. Graphic equalizer/display — QMediaPlayer does provide a stream of the audio data which is playing. With numpy for FFT we should be able to create a nice nice visual.

Continue reading

Drag & drop widgets with PyQt  PyQt

This week I had an interesting question from a reader of my PyQt6 book, about how to handle dragging and dropping of widgets in a container showing the dragged widget as it is moved. I'm interested in managing movement of a QWidget with mouse in a container. I've implemented the application with drag & drop, exchanging the position of buttons, but I want to show the motion of QPushButton, like what you see in Qt Designer. Dragging a widget should show the widget itself, not just the mouse pointer. First, we'll implement the simple case which drags widgets without showing anything extra. Then we can extend it to answer the question. Drag & drop widgets We'll start with this simple application which creates a window using QWidget and places a series of QPushButton widgets into it. You can substitute QPushButton for any other widget you like, e.g. QLabel python from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QPushButton class Window(QWidget): def __init__(self): super().__init__() self.blayout = QHBoxLayout() for l in ['A', 'B', 'C', 'D']: btn = QPushButton(l) self.blayout.addWidget(btn) self.setLayout(self.blayout) app = QApplication([]) w = Window() w.show() app.exec_() If you run this you should see something like this. The series of QPushButton widgets in a horizontal layout. Here we're creating a window, but the Window widget is subclassed from QWidget, meaning you can add this widget to any other layout. See later for an example of a generic object sorting widget. QPushButton objects aren't usually draggable, so to handle the mouse movements and initiate a drag we need to implement a subclass. We can add the following to the top of the file. python from PyQt5.QtCore import Qt, QMimeData from PyQt5.QtGui import QDrag class DragButton(QPushButton): def mouseMoveEvent(self, e): if e.buttons() == Qt.LeftButton: drag = QDrag(self) mime = QMimeData() drag.setMimeData(mime) drag.exec_(Qt.MoveAction) We implement a mouseMoveEvent which accepts the single e parameter of the event. We check to see if the left mouse button is pressed on this event -- as it would be when dragging -- and then initiate a drag. To start a drag, we create a QDrag object, passing in self to give us access later to the widget that was dragged. We also must pass in mime data. This is used for including information about what is dragged, particularly for passing data between applications. However, as here, it is fine to leave this empty. Finally, we initiate a drag by calling drag.exec_(Qt.MoveAction). As with dialogs exec_() starts a new event loop, blocking the main loop until the drag is complete. The parameter Qt.MoveAction tells the drag handler what type of operation is happening, so it can show the appropriate icon tip to the user. You can update the main window code to use our new DragButton class as follows. python class Window(QWidget): def __init__(self): super().__init__() self.blayout = QHBoxLayout() for l in ['A', 'B', 'C', 'D']: btn = DragButton(l) self.blayout.addWidget(btn) self.setLayout(self.blayout) If you run the code now, you can drag the buttons, but you'll notice the drag is forbidden. Dragging of the widget starts but is forbidden. What's happening? The mouse movement is being detected by our DragButton object and the drag started, but the main window does not accept drag & drop. To fix this we need to enable drops on the window and implement dragEnterEvent to actually accept them. python class Window(QWidget): def __init__(self): super().__init__() self.setAcceptDrops(True) self.blayout = QHBoxLayout() for l in ['A', 'B', 'C', 'D']: btn = DragButton(l) self.blayout.addWidget(btn) self.setLayout(self.blayout) def dragEnterEvent(self, e): e.accept() If you run this now, you'll see the drag is now accepted and you see the move icon. This indicates that the drag has started and been accepted by the window we're dragging onto. The icon shown is determined by the action we pass when calling drag.exec_(). Dragging of the widget starts and is accepted, showing a move icon. Releasing the mouse button during a drag drop operation triggers a dropEvent on the widget you're currently hovering the mouse over (if it is configured to accept drops). In our case that's the window. To handle the move we need to implement the code to do this in our dropEvent method. The drop event contains the position the mouse was at when the button was released & the drop triggered. We can use this to determine where to move the widget to. python def dropEvent(self, e): pos = e.pos() widget = e.source() for n in range(self.blayout.count()): # Get the widget at each index in turn. w = self.blayout.itemAt(n).widget() if pos.x() < w.x(): # We didn't drag past this widget. # insert to the left of it. self.blayout.insertWidget(n-1, widget) break e.accept() To determine where to place the widget, we iterate over all the widgets in the layout, until we find one who's x position is greater than that of the mouse pointer. If so then when insert the widget directly to the left of this widget and exit the loop. The effect of this is that if you drag 1 pixel past the start of another widget, which might be a bit confusing. You adjust this the cut off to use the middle of the widget using if pos.x() < w.x() + w.size().width() // 2: -- that is x + half of the width. python def dropEvent(self, e): pos = e.pos() widget = e.source() for n in range(self.blayout.count()): # Get the widget at each index in turn. w = self.blayout.itemAt(n).widget() if pos.x() < w.x() + w.size().width() // 2: # We didn't drag past this widget. # insert to the left of it. self.blayout.insertWidget(n-1, widget) break e.accept() The complete working drag-drop code is shown below. python from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QPushButton from PyQt5.QtCore import Qt, QMimeData from PyQt5.QtGui import QDrag class DragButton(QPushButton): def mouseMoveEvent(self, e): if e.buttons() == Qt.LeftButton: drag = QDrag(self) mime = QMimeData() drag.setMimeData(mime) drag.exec_(Qt.MoveAction) class Window(QWidget): def __init__(self): super().__init__() self.setAcceptDrops(True) self.blayout = QHBoxLayout() for l in ['A', 'B', 'C', 'D']: btn = DragButton(l) self.blayout.addWidget(btn) self.setLayout(self.blayout) def dragEnterEvent(self, e): e.accept() def dropEvent(self, e): pos = e.pos() widget = e.source() for n in range(self.blayout.count()): # Get the widget at each index in turn. w = self.blayout.itemAt(n).widget() if pos.x() < w.x() + w.size().width() // 2: # We didn't drag past this widget. # insert to the left of it. self.blayout.insertWidget(n-1, widget) break e.accept() app = QApplication([]) w = Window() w.show() app.exec_() Visual drag & drop So now we have our working drag & drop implementation we can move on to showing the drag visually. What we want to achieve here is showing the button being dragged next to the mouse point as it is dragged. Qt's QDrag handler natively provides a mechanism for showing dragged objects which we can use. We can update our DragButton class to pass a pixmap image to QDrag and this will be displayed under the mouse pointer as the drag occurs. To show the widget, we just need to get a QPixmap of the widget we're dragging. python from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QPushButton from PyQt5.QtCore import Qt, QMimeData from PyQt5.QtGui import QDrag, QPixmap class DragButton(QPushButton): def mouseMoveEvent(self, e): if e.buttons() == Qt.LeftButton: drag = QDrag(self) mime = QMimeData() drag.setMimeData(mime) pixmap = QPixmap(self.size()) self.render(pixmap) drag.setPixmap(pixmap) drag.exec_(Qt.MoveAction) To create the pixmap we create a QPixmap object passing in the size of the widget this event is fired on with self.size(). This creates an empty pixmap which we can then pass into self.render to render -- or draw -- the current widget onto it. That's it. Then we set the resulting pixmap on the drag object. If you run the code with this modification you'll see something like the following -- Dragging of the widget showing the dragged widget. Generic drag & drop container We can take this a step further and implement a generic drag drop widget which allows us to sort arbitrary objects. In the code below we've created a new widget DragWidget which can be added to any window. You can add items -- instances of DragItem -- which you want to be sorted, as well as setting data on them. When items are re-ordered the new order is emitted as a signal orderChanged. python from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QLabel, QMainWindow, QVBoxLayout from PyQt5.QtCore import Qt, QMimeData, pyqtSignal from PyQt5.QtGui import QDrag, QPixmap class DragItem(QLabel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setContentsMargins(25, 5, 25, 5) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setStyleSheet("border: 1px solid black;") # Store data separately from display label, but use label for default. self.data = self.text() def set_data(self, data): self.data = data def mouseMoveEvent(self, e): if e.buttons() == Qt.LeftButton: drag = QDrag(self) mime = QMimeData() drag.setMimeData(mime) pixmap = QPixmap(self.size()) self.render(pixmap) drag.setPixmap(pixmap) drag.exec_(Qt.MoveAction) class DragWidget(QWidget): """ Generic list sorting handler. """ orderChanged = pyqtSignal(list) def __init__(self, *args, orientation=Qt.Orientation.Vertical, **kwargs): super().__init__() self.setAcceptDrops(True) # Store the orientation for drag checks later. self.orientation = orientation if self.orientation == Qt.Orientation.Vertical: self.blayout = QVBoxLayout() else: self.blayout = QHBoxLayout() self.setLayout(self.blayout) def dragEnterEvent(self, e): e.accept() def dropEvent(self, e): pos = e.pos() widget = e.source() for n in range(self.blayout.count()): # Get the widget at each index in turn. w = self.blayout.itemAt(n).widget() if self.orientation == Qt.Orientation.Vertical: # Drag drop vertically. drop_here = pos.y() < w.y() + w.size().height() // 2 else: # Drag drop horizontally. drop_here = pos.x() < w.x() + w.size().width() // 2 if drop_here: # We didn't drag past this widget. # insert to the left of it. self.blayout.insertWidget(n-1, widget) self.orderChanged.emit(self.get_item_data()) break e.accept() def add_item(self, item): self.blayout.addWidget(item) def get_item_data(self): data = [] for n in range(self.blayout.count()): # Get the widget at each index in turn. w = self.blayout.itemAt(n).widget() data.append(w.data) return data class MainWindow(QMainWindow): def __init__(self): super().__init__() self.drag = DragWidget(orientation=Qt.Orientation.Vertical) for n, l in enumerate(['A', 'B', 'C', 'D']): item = DragItem(l) item.set_data(n) # Store the data. self.drag.add_item(item) # Print out the changed order. self.drag.orderChanged.connect(print) container = QWidget() layout = QVBoxLayout() layout.addStretch(1) layout.addWidget(self.drag) layout.addStretch(1) container.setLayout(layout) self.setCentralWidget(container) app = QApplication([]) w = MainWindow() w.show() app.exec_() Generic drag-drop sorting in horizontal orientation. You'll notice that when creating the item, you can set the label by passing it in as a parameter (just like for a normal QLabel which we've subclassed from). But you can also set a data value, which is the internal value of this item -- this is what will be emitted when the order changes, or if you call get_item_data yourself. This separates the visual representation from what is actually being sorted, meaning you can use this to sort anything not just strings. In the example above we're passing in the enumerated index as the data, so dragging will output (via the print connected to orderChanged) something like: python [1, 0, 2, 3] [1, 2, 0, 3] [1, 0, 2, 3] [1, 2, 0, 3] If you remove the item.set_data(n) you'll see the labels emitted on changes. python ['B', 'A', 'C', 'D'] ['B', 'C', 'A', 'D'] We've also implemented orientation onto the DragWidget using the Qt built in flags Qt.Orientation.Vertical or Qt.Orientation.Horizontal. This setting this allows you sort items either vertically or horizontally -- the calculations are handled for both directions. Generic drag-drop sorting in vertical orientation. More