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:
*** The slideshow application and all the steps in this post were developed with the assistance of Qwen3.8-Max ***
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
Post a Comment