Build a Python Slideshow App with PyQt6 and Package It as a Windows .exe

In this post, we build a small desktop slideshow application with Python and PyQt6. The app lets you pick any folder through a built-in file explorer, then plays its contents as a slideshow — each image is displayed for 5 seconds, each video plays once, and a mouse click or touch tap skips to the next item.

The walkthrough covers the full real-world workflow on Windows: create an isolated virtual environment, install PyQt6, run the app from source, and finally package everything into a single standalone .exe with PyInstaller — so the finished slideshow can run on any Windows PC, even one without Python installed.


To create a Python virtual environment, enter in Command Prompt:
python -m venv .venv
Creates an isolated Python environment in a .venv folder, keeping this project's dependencies separate from the system-wide Python. (.venv is just a naming convention — any folder name works.)

To activate it, enter:
.venv\Scripts\activate.bat
or
.venv\Scripts\activate

Install PyQt6 in .venv:
pip install PyQt6
Installs the PyQt6 GUI framework — including Qt Multimedia for video playback — into the virtual environment only.

Save and run the slideshow:
slideshow.py
# slideshow.py
import sys
import random
from pathlib import Path

from PyQt6.QtCore import (
    Qt, QDir, QTimer, QUrl, QEvent, QEasingCurve, QPropertyAnimation,
    pyqtProperty,
)
from PyQt6.QtGui import QPixmap, QPainter, QKeySequence, QShortcut, QFileSystemModel
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLabel, QTreeView, QLineEdit, QFileDialog, QStackedWidget,
    QCheckBox, QMessageBox,
)
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget

IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif"}  # gif shown as still
VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v"}
IMAGE_MS = 5000   # how long an image stays on screen
FADE_MS = 600     # crossfade duration


class Playlist:
    """Pure logic layer: folder scan, ordering, shuffle. GUI-free, headless-testable."""

    def __init__(self, folder, recursive=False, shuffle=False):
        self._base = self._scan(Path(folder), recursive)
        self.shuffle = shuffle
        self.items = []
        self._reshuffle()
        self.index = -1

    @staticmethod
    def _scan(root, recursive):
        it = root.rglob("*") if recursive else root.iterdir()
        out = []
        for p in it:
            if not p.is_file():
                continue
            ext = p.suffix.lower()
            if ext in IMAGE_EXTS:
                out.append(("image", p))
            elif ext in VIDEO_EXTS:
                out.append(("video", p))
        # Sort by file name first; extension (type) only as tiebreaker
        out.sort(key=lambda t: (t[1].stem.lower(), t[1].suffix.lower()))
        return out

    def _reshuffle(self):
        # Shuffle = deal a shuffled deck per round, no repeats inside a round
        if self.shuffle:
            self.items = random.sample(self._base, len(self._base))
        else:
            self.items = list(self._base)

    def __len__(self):
        return len(self.items)

    def first(self):
        self.index = 0
        return self.current()

    def current(self):
        if 0 <= self.index < len(self.items):
            return self.items[self.index]
        return None

    def advance(self):
        if not self.items:
            return None
        self.index += 1
        if self.index >= len(self.items):
            self.index = 0
            if self.shuffle:
                self._reshuffle()  # new round, new order
        return self.current()


class CrossfadeWidget(QWidget):
    """Painter-based crossfade (Linear only). Avoids QGraphicsOpacityEffect,
    which can render black rectangles on some Qt6/Windows driver combos."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self._shown = None      # fully visible pixmap
        self._shown_s = None    # its scaled cache
        self._incoming = None   # pixmap fading in
        self._incoming_s = None
        self._t = 0.0           # fade progress 0..1
        self.anim = None

    def _get_t(self):
        return self._t

    def _set_t(self, v):
        self._t = v
        self.update()

    t = pyqtProperty(float, _get_t, _set_t)  # animatable fade property

    def _fit(self, pm):
        if self.width() < 2 or self.height() < 2:
            return None
        return pm.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio,
                         Qt.TransformationMode.SmoothTransformation)

    def show_pixmap(self, pm, duration):
        if self.anim is not None:
            self.anim.stop()
        if self._incoming is not None:  # interrupted mid-fade: commit it
            self._shown = self._incoming
            self._shown_s = self._incoming_s
        self._incoming = pm
        self._incoming_s = self._fit(pm)
        self._t = 0.0
        self.anim = QPropertyAnimation(self, b"t", self)
        self.anim.setDuration(duration)
        self.anim.setEasingCurve(QEasingCurve.Type.Linear)  # the one and only
        self.anim.setStartValue(0.0)
        self.anim.setEndValue(1.0)
        self.anim.finished.connect(self._commit)
        self.anim.start()
        self.update()

    def _commit(self):
        self._shown = self._incoming
        self._shown_s = self._incoming_s
        self._incoming = None
        self._incoming_s = None
        self._t = 0.0
        self.update()

    def clear(self):
        """Forget everything so the next image fades in from black."""
        if self.anim is not None:
            self.anim.stop()
        self._shown = None
        self._shown_s = None
        self._incoming = None
        self._incoming_s = None
        self._t = 0.0
        self.update()

    def resizeEvent(self, e):
        super().resizeEvent(e)
        if self._shown is not None:
            self._shown_s = self._fit(self._shown)
        if self._incoming is not None:
            self._incoming_s = self._fit(self._incoming)

    def paintEvent(self, e):
        p = QPainter(self)
        p.fillRect(self.rect(), Qt.GlobalColor.black)
        if self._shown_s is not None:
            self._draw_centered(p, self._shown_s)
        if self._incoming_s is not None and self._t > 0:
            p.setOpacity(self._t)  # we control the alpha ourselves
            self._draw_centered(p, self._incoming_s)

    def _draw_centered(self, p, pm):
        x = (self.width() - pm.width()) // 2
        y = (self.height() - pm.height()) // 2
        p.drawPixmap(x, y, pm)


class PickerPage(QWidget):
    """Mini file explorer + playback options."""

    def __init__(self, on_start):
        super().__init__()
        self._on_start = on_start
        lay = QVBoxLayout(self)

        lay.addWidget(QLabel("Pick a folder to play:"))

        self.model = QFileSystemModel()
        self.model.setRootPath("")
        self.model.setFilter(
            QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot | QDir.Filter.Drives
        )
        self.tree = QTreeView()
        self.tree.setModel(self.model)
        for col in range(1, self.model.columnCount()):
            self.tree.hideColumn(col)
        self.tree.clicked.connect(self._select)
        self.tree.doubleClicked.connect(self._select)
        lay.addWidget(self.tree, 1)

        row = QHBoxLayout()
        self.path_edit = QLineEdit()
        self.path_edit.setPlaceholderText("Selected folder path")
        row.addWidget(self.path_edit, 1)
        browse = QPushButton("Browse...")
        browse.clicked.connect(self._browse)
        row.addWidget(browse)
        lay.addLayout(row)

        opt = QHBoxLayout()
        self.sub_cb = QCheckBox("Include subfolders")
        opt.addWidget(self.sub_cb)
        self.shuffle_cb = QCheckBox("Shuffle")
        opt.addWidget(self.shuffle_cb)
        opt.addStretch(1)
        lay.addLayout(opt)

        start = QPushButton("▶ Start Slideshow")
        start.clicked.connect(self._start)
        lay.addWidget(start)

    def _select(self, idx):
        self.path_edit.setText(self.model.filePath(idx))

    def _browse(self):
        d = QFileDialog.getExistingDirectory(self, "Pick a folder")
        if d:
            self.path_edit.setText(d)

    def _start(self):
        path = self.path_edit.text().strip()
        if not path or not Path(path).is_dir():
            QMessageBox.warning(self, "Invalid path", "Please pick a valid folder first.")
            return
        opts = {
            "recursive": self.sub_cb.isChecked(),
            "shuffle": self.shuffle_cb.isChecked(),
        }
        self._on_start(path, opts)


class SlideshowPage(QWidget):

    def __init__(self):
        super().__init__()
        lay = QVBoxLayout(self)

        # Stage: native video widget + painter-based crossfade layer
        self.stage = QWidget()
        lay.addWidget(self.stage, 1)
        self.video_widget = QVideoWidget(self.stage)
        self.crossfade = CrossfadeWidget(self.stage)

        self.hud = QLabel()
        self.hud.setStyleSheet("color:#999; background:black;")
        lay.addWidget(self.hud)

        self.timer = QTimer(self)
        self.timer.setSingleShot(True)
        self.timer.timeout.connect(self.next)

        self.player = QMediaPlayer(self)
        self.player.setAudioOutput(QAudioOutput(self))
        self.player.setVideoOutput(self.video_widget)
        self.player.mediaStatusChanged.connect(self._on_status)
        self.player.errorOccurred.connect(self._on_error)

        self.playlist = None
        self._fail = 0

        # Any mouse click / touch tap on the stage jumps to the next item
        self.video_widget.installEventFilter(self)
        self.crossfade.installEventFilter(self)

    # --- input ---------------------------------------------------
    def eventFilter(self, obj, event):
        if (event.type() == QEvent.Type.MouseButtonPress
                and event.button() == Qt.MouseButton.LeftButton):
            self.next()
            return True
        return super().eventFilter(obj, event)

    def resizeEvent(self, e):
        super().resizeEvent(e)
        self.video_widget.setGeometry(self.stage.rect())
        self.crossfade.setGeometry(self.stage.rect())

    # --- playback --------------------------------------------------
    def start(self, playlist):
        self.playlist = playlist
        self._fail = 0
        self.crossfade.clear()  # fresh run always starts from black
        self._show_current()

    def _show_current(self):
        item = self.playlist.current()
        if item is None:
            return
        kind, path = item
        self.hud.setText(f"{self.playlist.index + 1}/{len(self.playlist)}  {path.name}")
        self.timer.stop()
        self.player.stop()

        if kind == "image":
            pm = QPixmap(str(path))
            if pm.isNull():
                self.hud.setText(f"Cannot read: {path.name}")
                self.timer.start(1000)
                return
            self.video_widget.hide()
            self.crossfade.show()
            self.crossfade.show_pixmap(pm, FADE_MS)
            self._fail = 0
            self.timer.start(IMAGE_MS)
        else:
            # Videos cut in directly; wipe the crossfade memory so the next
            # image fades from black instead of ghosting the old one.
            self.crossfade.clear()
            self.crossfade.hide()
            self.video_widget.show()
            self.player.setSource(QUrl.fromLocalFile(str(path)))
            self.player.play()

    # --- media signals ---------------------------------------------
    def _on_status(self, status):
        if status == QMediaPlayer.MediaStatus.LoadedMedia:
            self._fail = 0
        elif status == QMediaPlayer.MediaStatus.EndOfMedia:
            self.next()  # video played once -> advance

    def _on_error(self, err, msg):
        self._fail += 1
        if self.playlist and self._fail >= len(self.playlist):
            self.hud.setText("All files failed to play")
            return
        self.hud.setText(f"Cannot play, skipping: {msg}")
        self.next()

    def next(self):
        self.timer.stop()
        if self.playlist:
            self.playlist.advance()
            self._show_current()

    def stop_all(self):
        self.timer.stop()
        self.player.stop()
        self.crossfade.clear()


class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Qwen Slideshow")
        self.resize(1280, 800)

        self.picker = PickerPage(self._start_show)
        self.show_page = SlideshowPage()
        self.stack = QStackedWidget()
        self.stack.addWidget(self.picker)
        self.stack.addWidget(self.show_page)
        self.setCentralWidget(self.stack)

        QShortcut(QKeySequence("Esc"), self).activated.connect(self._back)

    def _start_show(self, folder, opts):
        pl = Playlist(folder, recursive=opts["recursive"], shuffle=opts["shuffle"])
        if len(pl) == 0:
            QMessageBox.warning(self, "No files",
                                "No image/video files found in this folder.")
            return
        pl.first()
        self.show_page.start(pl)
        self.stack.setCurrentWidget(self.show_page)
        self.showFullScreen()

    def _back(self):
        self.show_page.stop_all()
        self.stack.setCurrentWidget(self.picker)
        self.showNormal()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    sys.exit(app.exec())

python slideshow.py
Launches the app using the venv's interpreter.



Convert to .exe

Install PyInstaller:
pip install pyinstaller
Adds PyInstaller to the venv; it bundles your script together with the Python interpreter and all dependencies into a standalone executable.

Build the .exe:
pyinstaller --onefile --noconsole --name exeSlideshow slideshow.py
  • --onefile — pack everything into a single self-contained .exe, easy to share;
  • --noconsole — suppress the black console window so only the GUI shows (standard for GUI apps);
  • --name exeSlideshow — set the output executable name (exeSlideshow.exe).
The finished file lands in dist\ (i.e. dist\exeSlideshow.exe) and runs on any Windows PC without Python installed.

Finished, you can deactivate the venv:
deactivate



*** The slideshow application and all the steps in this post were developed with the assistance of Qwen3.8-Max ***


Comments

Popular posts from this blog

Python/PyQt6 slideshow run on Windows 11

480x320 TFT/ILI9488 SPI wih EP32C3 (arduino-esp32) using Arduino_GFX Library