feat: video wallpapers #163

Merged
zach merged 12 commits from feat/video-wallpaper into main 2026-10-29 19:55:05 +01:00
14 changed files with 1280 additions and 88 deletions
Showing only changes of commit 0d0faecb1c - Show all commits
+26 -1
View File
@@ -18,6 +18,8 @@ Searcher {
property string actualCurrent: WallpaperPath.currentWallpaperPath property string actualCurrent: WallpaperPath.currentWallpaperPath
property alias crops: adapter.monitorCrops property alias crops: adapter.monitorCrops
readonly property string current: showPreview ? previewPath : actualCurrent readonly property string current: showPreview ? previewPath : actualCurrent
readonly property string currentThumbnail: thumbnailFor(current)
readonly property bool currentIsVideo: isVideoPath(current)
property alias monitorCrops: monitorCrops property alias monitorCrops: monitorCrops
property bool pendingPreviewClear property bool pendingPreviewClear
property bool previewColorLock property bool previewColorLock
@@ -32,6 +34,29 @@ Searcher {
return category; return category;
} }
readonly property var videoExts: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "mpg", "mpeg", "3gp", "ogv", "ts", "vob"]
function isVideoPath(path: string): bool {
if (!path)
return false;
const dot = path.lastIndexOf(".");
if (dot < 0)
return false;
return videoExts.includes(path.slice(dot + 1).toLowerCase());
}
function thumbnailFor(path: string): string {
if (!isVideoPath(path))
return path;
for (const entry of root.list) {
if (entry.path === path && entry.thumbnailPath)
return entry.thumbnailPath;
}
return path;
}
function getCrop(screen: string): var { function getCrop(screen: string): var {
return root.crops[screen]; return root.crops[screen];
} }
@@ -125,7 +150,7 @@ Searcher {
FileSystemModel { FileSystemModel {
id: wallpapers id: wallpapers
filter: FileSystemModel.Images filter: FileSystemModel.Media
path: Config.general.wallpaperPath path: Config.general.wallpaperPath
recursive: true recursive: true
} }
+58 -6
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import QtQuick import QtQuick
import ZShell.Config import ZShell.Config
@@ -9,7 +11,7 @@ Item {
id: root id: root
required property bool isCurrent required property bool isCurrent
required property var modelData // FileSystemEntry-like {path, relativePath} required property var modelData
required property real tileHeight required property real tileHeight
required property real tileWidth required property real tileWidth
required property ScreenState screenState required property ScreenState screenState
@@ -29,25 +31,75 @@ Item {
radius: Tokens.rounding.small radius: Tokens.rounding.small
y: Tokens.padding.large y: Tokens.padding.large
MaterialIcon { Loader {
active: opacity > 0
anchors.centerIn: parent anchors.centerIn: parent
color: Colors.tPalette.m3outline opacity: img.status === Image.Ready ? 0 : 1
fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build()
text: "image" Behavior on opacity {
Anim {}
}
sourceComponent: CustomRect {
color: Colors.palette.m3primaryContainer
implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2
implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2
radius: Tokens.rounding.full
LoadingIndicator {
id: loadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: Math.min(image.width, image.height) * 0.3
}
}
} }
CachingImage { CachingImage {
id: img
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top anchors.top: parent.top
cache: true cache: true
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
height: root.tileHeight height: root.tileHeight
path: root.modelData ? root.modelData.path : "" path: root.modelData ? root.modelData.thumbnailPath : ""
smooth: root.isCurrent smooth: root.isCurrent
sourceSize.height: root.tileHeight sourceSize.height: root.tileHeight
sourceSize.width: root.tileHeight * (16 / 9) sourceSize.width: root.tileHeight * (16 / 9)
width: root.tileHeight * (16 / 9) width: root.tileHeight * (16 / 9)
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
Loader {
opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0
active: opacity > 0
anchors.centerIn: parent
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
sourceComponent: CustomRect {
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
implicitHeight: implicitWidth
radius: Tokens.rounding.full
color: Colors.tPalette.m3surface
MaterialIcon {
id: videoIcon
anchors.centerIn: parent
text: "video_library"
fontStyle: Tokens.font.icon.extraLarge
}
}
}
} }
StateLayer { StateLayer {
+27 -3
View File
@@ -39,8 +39,7 @@ Item {
opacity: img.status === Image.Ready ? 0 : 1 opacity: img.status === Image.Ready ? 0 : 1
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
sourceComponent: CustomRect { sourceComponent: CustomRect {
color: Colors.palette.m3primaryContainer color: Colors.palette.m3primaryContainer
@@ -64,18 +63,43 @@ Item {
anchors.fill: parent anchors.fill: parent
asynchronous: true asynchronous: true
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
opacity: status === Image.Ready ? 1 : 0
retainWhileLoading: true retainWhileLoading: true
sourceSize: { sourceSize: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return Qt.size(width * dpr, height * dpr); return Qt.size(width * dpr, height * dpr);
} }
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity { Behavior on opacity {
Anim { Anim {
type: Anim.SlowEffects type: Anim.SlowEffects
} }
} }
Loader {
opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0
active: opacity > 0
anchors.centerIn: parent
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
sourceComponent: CustomRect {
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
implicitHeight: implicitWidth
radius: Tokens.rounding.full
color: Colors.tPalette.m3surface
MaterialIcon {
id: videoIcon
anchors.centerIn: parent
text: "video_library"
fontStyle: Tokens.font.icon.extraLarge
}
}
}
} }
} }
} }
+36 -8
View File
@@ -13,7 +13,9 @@ import qs.Services
Item { Item {
id: wrapper id: wrapper
property int buttonRowHeight: Tokens.padding.large * 3 readonly property bool isVideo: Wallpapers.currentIsVideo
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
readonly property real maxZoom: 5.0
readonly property bool changesMade: { readonly property bool changesMade: {
cropRevision; cropRevision;
@@ -25,9 +27,9 @@ Item {
return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001;
} }
property int buttonRowHeight: Tokens.padding.large * 3
property int cropRevision: 0 property int cropRevision: 0
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
readonly property real maxZoom: 5.0
property var screens: [] property var screens: []
property int selectedScreenIndex: 0 property int selectedScreenIndex: 0
property bool shouldBeActive: true property bool shouldBeActive: true
@@ -127,7 +129,7 @@ Item {
anchors.rightMargin: Tokens.padding.extraLarge * 2 anchors.rightMargin: Tokens.padding.extraLarge * 2
anchors.top: parent.top anchors.top: parent.top
active: wrapper.screens.length > 1 active: wrapper.screens.length > 1 && !wrapper.isVideo
sourceComponent: ButtonRow { sourceComponent: ButtonRow {
id: screenSelector id: screenSelector
@@ -163,6 +165,7 @@ Item {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
implicitHeight: 30 implicitHeight: 30
enabled: !wrapper.isVideo
CustomSlider { CustomSlider {
id: zoomSlider id: zoomSlider
@@ -274,7 +277,7 @@ Item {
opacity: status === Image.Ready ? 1 : 0 opacity: status === Image.Ready ? 1 : 0
retainWhileLoading: true retainWhileLoading: true
smooth: true smooth: true
source: Wallpapers.current source: Wallpapers.currentThumbnail
sourceSize.height: { sourceSize.height: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return imageSlot.height * dpr; return imageSlot.height * dpr;
@@ -285,12 +288,37 @@ Item {
type: Anim.SlowEffects type: Anim.SlowEffects
} }
} }
Loader {
opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0
active: opacity > 0
anchors.centerIn: parent
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
sourceComponent: CustomRect {
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
implicitHeight: implicitWidth
radius: Tokens.rounding.full
color: Colors.tPalette.m3surface
MaterialIcon {
id: videoIcon
anchors.centerIn: parent
text: "video_library"
fontStyle: Tokens.font.icon.extraLarge
}
}
}
} }
Loader { Loader {
id: cropRectLoader id: cropRectLoader
active: scaledImg.status === Image.Ready active: scaledImg.status === Image.Ready && !wrapper.isVideo
sourceComponent: Component { sourceComponent: Component {
CustomRect { CustomRect {
@@ -333,7 +361,6 @@ Item {
y = t.y; y = t.y;
} }
// Where the rect should be according to saved data (same rules as a restore).
function savedTarget() { function savedTarget() {
const data = Wallpapers.getCrop(wrapper.currentScreen.name); const data = Wallpapers.getCrop(wrapper.currentScreen.name);
const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001);
@@ -436,9 +463,10 @@ Item {
} }
anchors.fill: parent anchors.fill: parent
cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor cursorShape: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
preventStealing: true preventStealing: true
enabled: !wrapper.isVideo
onPositionChanged: mouse => { onPositionChanged: mouse => {
if (pressed) { if (pressed) {
@@ -50,11 +50,12 @@ PageBase {
} }
WallItem { WallItem {
id: wallItem
required property FileSystemEntry modelData required property FileSystemEntry modelData
enabled: modelData enabled: modelData
opacity: modelData ? 1 : 0 opacity: modelData ? 1 : 0
source: String(modelData?.path ?? "") source: String(modelData?.thumbnailPath ?? "")
onClicked: { onClicked: {
Wallpapers.setWallpaper(modelData.path); Wallpapers.setWallpaper(modelData.path);
+1
View File
@@ -20,6 +20,7 @@ pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl)
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
+1
View File
@@ -39,4 +39,5 @@ qml_module(ZShell-internal
PkgConfig::EGL PkgConfig::EGL
PkgConfig::WAYLAND_EGL PkgConfig::WAYLAND_EGL
PkgConfig::GL PkgConfig::GL
PkgConfig::MPV
) )
+630 -50
View File
@@ -7,15 +7,26 @@
#include <GL/gl.h> #include <GL/gl.h>
#include <GL/glext.h> #include <GL/glext.h>
#include <mpv/client.h>
#include <mpv/render_gl.h>
#include <clocale>
#include <fcntl.h> #include <fcntl.h>
#include <poll.h> #include <poll.h>
#include <unistd.h> #include <unistd.h>
#include <QDebug> #include <QDebug>
#include <QFileInfo>
#include <QGuiApplication> #include <QGuiApplication>
#include <QImageReader> #include <QImageReader>
#include <QScreen> #include <QScreen>
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <qguiapplication_platform.h> #include <qguiapplication_platform.h>
#include <private/qwaylandscreen_p.h> #include <private/qwaylandscreen_p.h>
@@ -29,7 +40,6 @@ namespace {
const char* kVertexShader = R"( const char* kVertexShader = R"(
#version 300 es #version 300 es
void main() { void main() {
// The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer.
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
} }
@@ -41,16 +51,36 @@ precision highp float;
uniform sampler2D source; uniform sampler2D source;
uniform vec2 size; uniform vec2 size;
uniform vec4 crop; uniform vec4 crop;
uniform float alpha;
out vec4 outColor; out vec4 outColor;
void main() { void main() {
// The pixels are stored top-down, so the fragment coordinate is flipped.
vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size;
outColor = texture(source, crop.xy + p * crop.zw); vec4 c = texture(source, crop.xy + p * crop.zw);
outColor = vec4(c.rgb, c.a * alpha);
} }
)"; )";
constexpr int kWaitTimeoutMs = 20; constexpr int kWaitTimeoutMs = 20;
constexpr int kFadeDurationMs = 400;
const QStringList kVideoExtensions = {
"mp4",
"mkv",
"webm",
"avi",
"mov",
"wmv",
"flv",
"m4v",
"mpg",
"mpeg",
"3gp",
"ogv",
"ts",
"vob",
};
void logShader(uint32_t object, bool program) { void logShader(uint32_t object, bool program) {
int ok = 0; int ok = 0;
if (program) if (program)
@@ -94,6 +124,16 @@ WallpaperService::~WallpaperService() {
if (m_thread.joinable()) m_thread.join(); if (m_thread.joinable()) m_thread.join();
if (m_mpvRender) {
mpv_render_context_free(m_mpvRender);
m_mpvRender = nullptr;
}
if (m_mpv) {
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
}
for (auto* surface : m_surfaces) for (auto* surface : m_surfaces)
delete surface; delete surface;
m_surfaces.clear(); m_surfaces.clear();
@@ -102,6 +142,8 @@ WallpaperService::~WallpaperService() {
if (eglMakeCurrent( if (eglMakeCurrent(
m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) {
if (m_texture) glDeleteTextures(1, &m_texture); if (m_texture) glDeleteTextures(1, &m_texture);
if (m_previousTexture) glDeleteTextures(1, &m_previousTexture);
if (m_blackTex) glDeleteTextures(1, &m_blackTex);
if (m_program) glDeleteProgram(m_program); if (m_program) glDeleteProgram(m_program);
} }
@@ -127,22 +169,210 @@ QString WallpaperService::path() const {
} }
void WallpaperService::setPath(const QString& path) { void WallpaperService::setPath(const QString& path) {
if (m_path == path) return; if (path.isEmpty() || path == this->path()) return;
if (isVideoPath(path) && needsTranscode(path)) {
const QString dst = transcodeTarget(path);
if (QFile::exists(dst)) {
applyPath(dst);
return;
}
applyPath(path);
transcodeVideo(path, dst);
return;
}
applyPath(path);
}
void WallpaperService::applyPath(const QString& path) {
const bool video = isVideoPath(path);
{ {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_path = path; m_path = path;
const bool wasVideo = m_isVideo && m_mpv != nullptr;
const bool wasImage = !m_image.isNull() && m_texture != 0;
m_previousVideo = wasVideo && !video;
if (wasImage) {
m_previousImage = m_image;
m_previousCrops = m_crops;
m_previousTexture = 0;
} else {
m_previousImage = QImage();
}
m_isVideo = video;
m_fading = true;
m_fadeClockStarted = false;
m_forceDecode = true; m_forceDecode = true;
m_imageChanged = true; m_imageChanged = true;
m_videoReload = true;
} }
emit pathChanged(); emit pathChanged();
start(); start();
queueAll(); queueAll();
} }
bool WallpaperService::isHardwareCodec(const QString& codec) {
static const QStringList hardware{
"av1",
"hevc",
"h264",
"vp9",
"vp8",
"vc1",
"mpeg2",
"mpeg2video",
"mpeg4",
"mjpeg",
};
return hardware.contains(codec);
}
QString WallpaperService::codecName(const QString& path) const {
QProcess probe;
probe.start(
"ffprobe",
{
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=nw=1:nk=1",
path,
});
if (!probe.waitForFinished(2000) || probe.exitCode() != 0) {
qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit"
<< probe.exitCode() << ")";
return {};
}
const QString codec =
QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower();
qWarning() << "WallpaperService: codec of" << path << "is"
<< (codec.isEmpty() ? "<empty>" : codec);
return codec;
}
QString WallpaperService::cacheDir() const {
return QDir::homePath() + "/.cache/zshell/wallpaper-videos";
}
QString WallpaperService::transcodeTarget(const QString& path) const {
const QFileInfo info(path);
const QByteArray key =
path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' +
QByteArray::number(info.lastModified().toMSecsSinceEpoch());
const QString hash = QString::fromUtf8(
QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex());
return cacheDir() + '/' + hash + ".av1.mkv";
}
bool WallpaperService::needsTranscode(const QString& path) const {
if (path.startsWith(cacheDir())) return false;
const QString codec = codecName(path);
if (codec.isEmpty()) return false;
return !isHardwareCodec(codec);
}
void WallpaperService::transcodeVideo(const QString& src, const QString& dst) {
QDir().mkpath(cacheDir());
if (m_transcode) {
m_transcode->kill();
m_transcode->deleteLater();
m_transcode = nullptr;
}
m_transcode = new QProcess(this);
auto* proc = m_transcode;
connect(proc, &QProcess::readyReadStandardError, proc, [proc]() {
qWarning().noquote()
<< "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError());
});
connect(
proc,
&QProcess::finished,
this,
[this, proc, dst](int code, QProcess::ExitStatus status) {
const bool ok = code == 0 && status == QProcess::NormalExit &&
QFile::exists(dst);
qWarning() << "WallpaperService: transcode finished, exit" << code
<< "status" << int(status) << "exists"
<< QFile::exists(dst) << "->" << dst;
if (proc == m_transcode) m_transcode = nullptr;
proc->deleteLater();
if (ok) applyPath(dst);
});
qWarning() << "WallpaperService: transcoding" << src << "->" << dst;
proc->setProgram("nice");
proc->setArguments({
"-n",
"19",
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-threads",
"2",
"-i",
src,
"-map",
"0:v:0",
"-vf",
"scale=in_range=pc:out_range=tv:out_color_matrix=bt709",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
"-c:v",
"libsvtav1",
"-preset",
"6",
"-crf",
"20",
"-pix_fmt",
"yuv420p10le",
"-an",
dst,
});
proc->start();
}
void WallpaperService::addScreen(const QString& screen) { void WallpaperService::addScreen(const QString& screen) {
{ {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
@@ -212,6 +442,178 @@ void WallpaperService::redraw() {
queueAll(); queueAll();
} }
bool WallpaperService::isVideoPath(const QString& path) {
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
}
void WallpaperService::startVideo(const QString& path) {
if (path.isEmpty()) return;
m_videoReady.store(false, std::memory_order_relaxed);
m_videoLoaded.store(false, std::memory_order_relaxed);
if (!m_mpv) {
std::setlocale(LC_NUMERIC, "C");
m_mpv = mpv_create();
if (!m_mpv) {
qWarning() << "WallpaperService: could not create mpv handle";
return;
}
mpv_set_option_string(m_mpv, "config", "no");
mpv_set_option_string(m_mpv, "vo", "libmpv");
mpv_set_option_string(m_mpv, "audio", "no");
mpv_set_option_string(m_mpv, "loop", "inf");
mpv_set_option_string(m_mpv, "hwdec", "auto-copy");
mpv_set_option_string(m_mpv, "video-sync", "display");
mpv_set_option_string(m_mpv, "framedrop", "decoder");
mpv_set_option_string(m_mpv, "background-color", "#00000000");
if (mpv_initialize(m_mpv) < 0) {
qWarning() << "WallpaperService: mpv_initialize failed";
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
return;
}
}
const QByteArray local = path.toUtf8();
initMpvRender();
const char* loadCmd[]{"loadfile", local.constData(), nullptr};
if (mpv_command(m_mpv, loadCmd) < 0)
qWarning() << "WallpaperService: mpv could not load" << path;
m_mpvFrameReady.store(true, std::memory_order_relaxed);
}
void WallpaperService::pumpMpvEvents() {
if (!m_mpv) return;
mpv_event* event = nullptr;
while ((event = mpv_wait_event(m_mpv, 0)) != nullptr &&
event->event_id != MPV_EVENT_NONE) {
if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
auto* data = static_cast<mpv_event_log_message*>(event->data);
if (data && data->text)
qWarning().noquote() << "mpv:" << data->prefix << data->text;
}
if (event->event_id == MPV_EVENT_FILE_LOADED) {
m_videoLoaded.store(true, std::memory_order_relaxed);
}
if (event->event_id == MPV_EVENT_END_FILE) {
m_videoLoaded.store(false, std::memory_order_relaxed);
m_videoReady.store(false, std::memory_order_relaxed);
auto* data = static_cast<mpv_event_end_file*>(event->data);
if (data && data->error != MPV_END_FILE_REASON_EOF)
qWarning() << "WallpaperService: mpv ended the file, error"
<< data->error;
}
}
}
void WallpaperService::stopVideo() {
teardownMpv();
}
void WallpaperService::teardownMpv() {
if (m_mpvRender) {
mpv_render_context_free(m_mpvRender);
m_mpvRender = nullptr;
}
if (m_mpv) {
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
}
m_mpvFrameReady.store(false, std::memory_order_relaxed);
m_videoReady.store(false, std::memory_order_relaxed);
m_videoLoaded.store(false, std::memory_order_relaxed);
}
void* WallpaperService::getMpvProcAddress(void* ctx, const char* name) {
(void)ctx;
if (strcmp(name, "glFenceSync") == 0)
return reinterpret_cast<void*>(
+[](GLenum, GLbitfield) -> GLsync { return nullptr; });
return reinterpret_cast<void*>(eglGetProcAddress(name));
}
void WallpaperService::mpvUpdate(void* ctx) {
auto* self = static_cast<WallpaperService*>(ctx);
self->m_mpvFrameReady.store(true, std::memory_order_relaxed);
if (self->m_videoLoaded.load(std::memory_order_relaxed))
self->m_videoReady.store(true, std::memory_order_relaxed);
self->wake();
}
void WallpaperService::initMpvRender() {
if (m_mpvRender || !m_mpv) return;
mpv_opengl_init_params gl_init{getMpvProcAddress, nullptr};
mpv_render_param params[]{
{MPV_RENDER_PARAM_API_TYPE,
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) {
qWarning() << "WallpaperService: could not create mpv render context";
m_mpvRender = nullptr;
return;
}
mpv_render_context_set_update_callback(m_mpvRender, mpvUpdate, this);
}
void WallpaperService::renderMpvToSurface(const QSize& size) {
if (!m_mpvRender) initMpvRender();
if (!m_mpvRender) return;
glViewport(0, 0, size.width(), size.height());
mpv_opengl_fbo fbo{0, size.width(), size.height(), GL_RGBA};
int flip = 1;
int block = 0;
mpv_render_param params[]{
{MPV_RENDER_PARAM_OPENGL_FBO, &fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip},
{MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_render(m_mpvRender, params);
}
void WallpaperService::createBlackTexture(uint32_t& texture) {
if (texture != 0) return;
const unsigned char pixel[4] = {0, 0, 0, 255};
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
}
void WallpaperService::start() { void WallpaperService::start() {
{ {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
@@ -260,11 +662,7 @@ void WallpaperService::wake() {
} }
void WallpaperService::queue(WallpaperSurface* surface) { void WallpaperService::queue(WallpaperSurface* surface) {
{ surface->markDirty();
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_pending.push_back(surface);
}
wake(); wake();
} }
@@ -273,7 +671,7 @@ void WallpaperService::queueAll() {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
for (auto* surface : m_surfaces) for (auto* surface : m_surfaces)
m_pending.push_back(surface); surface->markDirty();
} }
wake(); wake();
@@ -323,13 +721,50 @@ void WallpaperService::present() {
ensureDecodeSize(); ensureDecodeSize();
std::vector<WallpaperSurface*> pending; QList<WallpaperSurface*> surfaces;
bool fading = false;
bool videoReload = false;
QString path;
bool isVideo = false;
{ {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
pending.swap(m_pending);
if (m_videoReload) {
m_videoReload = false;
videoReload = true;
path = m_path;
isVideo = m_isVideo;
} }
for (auto* surface : pending) fading = m_fading;
const bool newVideoFrame =
m_isVideo &&
m_mpvFrameReady.exchange(false, std::memory_order_relaxed);
if (fading || newVideoFrame)
for (auto* surface : m_surfaces)
surface->markDirty();
surfaces = m_surfaces;
}
if (m_context == EGL_NO_CONTEXT) return;
eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context);
if (videoReload) {
if (isVideo)
startVideo(path);
else if (m_mpv && !m_previousVideo)
teardownMpv();
}
const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr;
if (videoActive) pumpMpvEvents();
for (auto* surface : surfaces)
drawSurface(surface); drawSurface(surface);
} }
@@ -494,7 +929,8 @@ uint32_t WallpaperService::createProgram() {
logShader(program, true); logShader(program, true);
if (glGetUniformLocation(program, "size") < 0 || if (glGetUniformLocation(program, "size") < 0 ||
glGetUniformLocation(program, "crop") < 0) { glGetUniformLocation(program, "crop") < 0 ||
glGetUniformLocation(program, "alpha") < 0) {
qWarning() << "WallpaperService: the program does not have the " qWarning() << "WallpaperService: the program does not have the "
"uniforms it needs"; "uniforms it needs";
glDeleteProgram(program); glDeleteProgram(program);
@@ -503,22 +939,23 @@ uint32_t WallpaperService::createProgram() {
m_sizeUniform = glGetUniformLocation(program, "size"); m_sizeUniform = glGetUniformLocation(program, "size");
m_cropUniform = glGetUniformLocation(program, "crop"); m_cropUniform = glGetUniformLocation(program, "crop");
m_alphaUniform = glGetUniformLocation(program, "alpha");
glDeleteShader(vertex); glDeleteShader(vertex);
glDeleteShader(fragment); glDeleteShader(fragment);
return program; return program;
} }
void WallpaperService::uploadImage() { void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) {
if (m_image.isNull()) return; if (image.isNull()) return;
if (m_texture == 0) { if (texture == 0) {
glGenTextures(1, &m_texture); glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, m_texture); glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
} else { } else {
glBindTexture(GL_TEXTURE_2D, m_texture); glBindTexture(GL_TEXTURE_2D, texture);
} }
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
@@ -526,12 +963,35 @@ void WallpaperService::uploadImage() {
GL_TEXTURE_2D, GL_TEXTURE_2D,
0, 0,
GL_RGBA8, GL_RGBA8,
m_image.width(), image.width(),
m_image.height(), image.height(),
0, 0,
GL_RGBA, GL_RGBA,
GL_UNSIGNED_BYTE, GL_UNSIGNED_BYTE,
m_image.constBits()); image.constBits());
}
void WallpaperService::uploadImage() {
uploadTexture(m_texture, m_image);
}
void WallpaperService::drawTexture(
uint32_t texture,
const QRectF& rect,
float sizeW,
float sizeH,
float alpha) {
glBindTexture(GL_TEXTURE_2D, texture);
glUniform2f(GLint(m_sizeUniform), sizeW, sizeH);
glUniform4f(
GLint(m_cropUniform),
GLfloat(rect.x()),
GLfloat(rect.y()),
GLfloat(rect.width()),
GLfloat(rect.height()));
glUniform1f(GLint(m_alphaUniform), alpha);
glDrawArrays(GL_TRIANGLES, 0, 3);
} }
bool WallpaperService::decode(const QString& path) { bool WallpaperService::decode(const QString& path) {
@@ -564,6 +1024,8 @@ bool WallpaperService::ensureDecodeSize() {
{ {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_isVideo) return false;
largest = largestBufferSize(); largest = largestBufferSize();
if (largest.isEmpty()) return false; if (largest.isEmpty()) return false;
@@ -611,7 +1073,10 @@ QSize WallpaperService::largestBufferSize() {
} }
QRectF WallpaperService::sourceRect( QRectF WallpaperService::sourceRect(
const QVariantMap& crops, const QString& screen, const QSize& size) const { const QVariantMap& crops,
const QString& screen,
const QSize& size,
const QSize& image) const {
QVariantMap crop = cropFor(crops, screen); QVariantMap crop = cropFor(crops, screen);
QRectF rect( QRectF rect(
@@ -620,10 +1085,10 @@ QRectF WallpaperService::sourceRect(
crop.value("width", 1.0).toDouble(), crop.value("width", 1.0).toDouble(),
crop.value("height", 1.0).toDouble()); crop.value("height", 1.0).toDouble());
if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; if (size.isEmpty() || rect.isEmpty() || image.isEmpty()) return rect;
qreal imageWidth = qreal(m_image.width()); qreal imageWidth = qreal(image.width());
qreal imageHeight = qreal(m_image.height()); qreal imageHeight = qreal(image.height());
QRectF pixels( QRectF pixels(
rect.x() * imageWidth, rect.x() * imageWidth,
@@ -652,6 +1117,8 @@ QRectF WallpaperService::sourceRect(
} }
void WallpaperService::drawSurface(WallpaperSurface* surface) { void WallpaperService::drawSurface(WallpaperSurface* surface) {
if (!surface->needsDraw() || !surface->frameReady()) return;
EGLSurface target = surface->eglSurface(); EGLSurface target = surface->eglSurface();
if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE ||
surface->bufferSize().isEmpty()) surface->bufferSize().isEmpty())
@@ -662,9 +1129,8 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
return; return;
} }
// Release the context when this scope ends (see FrameService::drawSurface). eglSwapInterval(m_eglDisplay, 0);
// Holding it across the draw loop's sleep serializes the two raw-EGL clients on
// NVIDIA, which makes eglSwapBuffers fail and starves other processes' contexts.
struct ContextRelease { struct ContextRelease {
EGLDisplay display; EGLDisplay display;
~ContextRelease() { ~ContextRelease() {
@@ -675,7 +1141,6 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
QVariantMap crops; QVariantMap crops;
QSize size; QSize size;
bool upload = false;
{ {
std::lock_guard<std::recursive_mutex> lock(m_mutex); std::lock_guard<std::recursive_mutex> lock(m_mutex);
@@ -683,38 +1148,153 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
crops = m_crops; crops = m_crops;
size = surface->bufferSize(); size = surface->bufferSize();
upload = m_imageChanged;
m_imageChanged = false;
}
if (size.isEmpty()) return; if (size.isEmpty()) return;
if (m_program == 0) m_program = createProgram(); if (m_program == 0) m_program = createProgram();
if (m_texture == 0 || upload) uploadImage(); if (m_program == 0) return;
if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; const bool videoNow = m_isVideo;
glUseProgram(m_program); if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return;
glBindTexture(GL_TEXTURE_2D, m_texture);
glUniform2f( const bool videoAsBackground =
GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); (videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr;
QRectF rect = sourceRect(crops, surface->screen(), size); if (videoAsBackground) {
glUniform4f( if (!m_mpvRender) initMpvRender();
GLint(m_cropUniform), if (!m_mpvRender) return;
GLfloat(rect.x()),
GLfloat(rect.y()), glDisable(GL_BLEND);
GLfloat(rect.width()),
GLfloat(rect.height()));
glViewport(0, 0, size.width(), size.height()); glViewport(0, 0, size.width(), size.height());
glDrawArrays(GL_TRIANGLES, 0, 3); renderMpvToSurface(size);
} else {
glUseProgram(m_program);
glViewport(0, 0, size.width(), size.height());
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
}
if (!eglSwapBuffers(m_eglDisplay, target)) if (!videoNow) {
const bool upload = m_imageChanged;
m_imageChanged = false;
if (m_texture == 0 || upload) uploadImage();
if (m_texture == 0 || m_image.isNull()) return;
}
glUseProgram(m_program);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
if (m_fading && !m_fadeClockStarted) {
m_fadeClockStarted = true;
m_fadeTimer.start();
}
bool fade = false;
bool fadeDone = false;
qreal progress = 1.0;
if (m_fading) {
progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs);
if (progress >= 1.0)
fadeDone = true;
else
fade = true;
}
const float w = float(size.width());
const float h = float(size.height());
if (videoNow) {
if (fade && !m_previousImage.isNull()) {
if (m_previousTexture == 0)
uploadTexture(m_previousTexture, m_previousImage);
if (m_previousTexture != 0) {
const QRectF previousRect = sourceRect(
m_previousCrops,
surface->screen(),
size,
m_previousImage.size());
drawTexture(
m_previousTexture,
previousRect,
w,
h,
1.0f - float(progress));
}
} else if (fade && !m_firstFadeDone) {
if (m_blackTex == 0) createBlackTexture(m_blackTex);
if (m_blackTex != 0)
drawTexture(
m_blackTex,
QRectF(0.0, 0.0, 1.0, 1.0),
w,
h,
1.0f - float(progress));
}
} else {
const QRectF rect =
sourceRect(crops, surface->screen(), size, m_image.size());
if (fade) {
if (!m_previousVideo && !m_previousImage.isNull() &&
m_previousTexture == 0) {
uploadTexture(m_previousTexture, m_previousImage);
}
if (!m_previousVideo && m_previousTexture != 0) {
const QRectF previousRect = sourceRect(
m_previousCrops,
surface->screen(),
size,
m_previousImage.size());
drawTexture(m_previousTexture, previousRect, w, h, 1.0f);
}
drawTexture(m_texture, rect, w, h, float(progress));
} else {
drawTexture(m_texture, rect, w, h, 1.0f);
}
}
if (fadeDone) {
m_fading = false;
m_firstFadeDone = true;
if (m_previousVideo) {
teardownMpv();
m_previousVideo = false;
}
if (m_previousTexture != 0) {
glDeleteTextures(1, &m_previousTexture);
m_previousTexture = 0;
}
m_previousImage = QImage();
}
}
surface->requestFrame();
if (!eglSwapBuffers(m_eglDisplay, target)) {
qWarning() qWarning()
<< "WallpaperService: eglSwapBuffers failed, eglGetError = 0x" << "WallpaperService: eglSwapBuffers failed, eglGetError = 0x"
<< QString::number(unsigned(eglGetError()), 16); << QString::number(unsigned(eglGetError()), 16);
return;
}
if ((m_isVideo || m_previousVideo) && m_mpvRender)
mpv_render_context_report_swap(m_mpvRender);
surface->endDraw();
} }
void WallpaperService::addSurface(const QString& screen) { void WallpaperService::addSurface(const QString& screen) {
+62 -2
View File
@@ -9,6 +9,7 @@
#include <QImage> #include <QImage>
#include <QList> #include <QList>
#include <QElapsedTimer>
#include <QRectF> #include <QRectF>
#include <QSize> #include <QSize>
#include <QString> #include <QString>
@@ -16,12 +17,18 @@
#include <QVariantMap> #include <QVariantMap>
#include <QtQml/qqml.h> #include <QtQml/qqml.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
#include <atomic>
#include <qwayland-wlr-layer-shell-unstable-v1.h> #include <qwayland-wlr-layer-shell-unstable-v1.h>
#include <wayland-client-protocol.h> #include <wayland-client-protocol.h>
#include "wallpapersurface.hpp" #include "wallpapersurface.hpp"
struct mpv_handle;
struct mpv_render_context;
class QProcess;
namespace ZShell::internal { namespace ZShell::internal {
class WallpaperSurface; class WallpaperSurface;
@@ -57,6 +64,29 @@ class WallpaperService : public QObject {
private: private:
friend class WallpaperSurface; friend class WallpaperSurface;
static bool isVideoPath(const QString& path);
static bool isHardwareCodec(const QString& codec);
[[nodiscard]] QString codecName(const QString& path) const;
[[nodiscard]] QString cacheDir() const;
[[nodiscard]] QString transcodeTarget(const QString& path) const;
[[nodiscard]] bool needsTranscode(const QString& path) const;
void transcodeVideo(const QString& src, const QString& dst);
void applyPath(const QString& path);
void startVideo(const QString& path);
void stopVideo();
void teardownMpv();
void initMpvRender();
void renderMpvToSurface(const QSize& size);
void pumpMpvEvents();
static void* getMpvProcAddress(void* ctx, const char* name);
static void mpvUpdate(void* ctx);
void bindGlobals(); void bindGlobals();
static void registryGlobal( static void registryGlobal(
void* data, void* data,
@@ -70,7 +100,16 @@ class WallpaperService : public QObject {
[[nodiscard]] bool setupGl(); [[nodiscard]] bool setupGl();
[[nodiscard]] uint32_t createProgram(); [[nodiscard]] uint32_t createProgram();
void uploadTexture(uint32_t& texture, const QImage& image);
void uploadImage(); void uploadImage();
void createBlackTexture(uint32_t& texture);
void drawTexture(
uint32_t texture,
const QRectF& rect,
float sizeW,
float sizeH,
float alpha);
bool decode(const QString& path); bool decode(const QString& path);
@@ -95,7 +134,8 @@ class WallpaperService : public QObject {
[[nodiscard]] QRectF sourceRect( [[nodiscard]] QRectF sourceRect(
const QVariantMap& crops, const QVariantMap& crops,
const QString& screen, const QString& screen,
const QSize& size) const; const QSize& size,
const QSize& image) const;
void start(); void start();
@@ -108,6 +148,18 @@ class WallpaperService : public QObject {
QImage m_image; QImage m_image;
bool m_isVideo = false;
bool m_videoReload = false;
bool m_previousVideo = false;
mpv_handle* m_mpv = nullptr;
mpv_render_context* m_mpvRender = nullptr;
uint32_t m_blackTex = 0;
std::atomic<bool> m_mpvFrameReady{false};
std::atomic<bool> m_videoReady{false};
std::atomic<bool> m_videoLoaded{false};
QProcess* m_transcode = nullptr;
QSize m_sourceSize; QSize m_sourceSize;
QSize m_decodeSize; QSize m_decodeSize;
@@ -125,12 +177,20 @@ class WallpaperService : public QObject {
uint32_t m_program = 0; uint32_t m_program = 0;
int m_sizeUniform = -1; int m_sizeUniform = -1;
int m_cropUniform = -1; int m_cropUniform = -1;
int m_alphaUniform = -1;
QImage m_previousImage;
QVariantMap m_previousCrops;
uint32_t m_previousTexture = 0;
bool m_fading = false;
bool m_fadeClockStarted = false;
bool m_firstFadeDone = false;
QElapsedTimer m_fadeTimer;
QList<WallpaperSurface*> m_surfaces; QList<WallpaperSurface*> m_surfaces;
std::thread m_thread; std::thread m_thread;
mutable std::recursive_mutex m_mutex; mutable std::recursive_mutex m_mutex;
std::vector<WallpaperSurface*> m_pending;
wl_event_queue* m_queue = nullptr; wl_event_queue* m_queue = nullptr;
int m_wake[2] = {-1, -1}; int m_wake[2] = {-1, -1};
+45 -2
View File
@@ -53,6 +53,11 @@ WallpaperSurface::WallpaperSurface(
} }
WallpaperSurface::~WallpaperSurface() { WallpaperSurface::~WallpaperSurface() {
if (m_frameCallback) {
wl_callback_destroy(m_frameCallback);
m_frameCallback = nullptr;
}
if (m_eglSurface != EGL_NO_SURFACE) if (m_eglSurface != EGL_NO_SURFACE)
eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); eglDestroySurface(m_service->m_eglDisplay, m_eglSurface);
@@ -68,8 +73,45 @@ WallpaperSurface::~WallpaperSurface() {
if (m_surface) wl_surface_destroy(m_surface); if (m_surface) wl_surface_destroy(m_surface);
} }
void WallpaperSurface::redraw() { void WallpaperSurface::markDirty() {
m_service->queue(this); m_dirty.store(true, std::memory_order_relaxed);
}
bool WallpaperSurface::needsDraw() const {
return m_dirty.load(std::memory_order_relaxed);
}
bool WallpaperSurface::frameReady() const {
return m_frameReady.load(std::memory_order_relaxed);
}
void WallpaperSurface::requestFrame() {
if (!m_surface || m_frameCallback) return;
m_frameCallback = wl_surface_frame(m_surface);
static const struct wl_callback_listener listener = {
[](void* data, struct wl_callback*, uint32_t) {
static_cast<WallpaperSurface*>(data)->onFrameDone();
},
};
wl_callback_add_listener(m_frameCallback, &listener, this);
}
void WallpaperSurface::onFrameDone() {
if (m_frameCallback) {
wl_callback_destroy(m_frameCallback);
m_frameCallback = nullptr;
}
m_frameReady.store(true, std::memory_order_relaxed);
m_service->wake();
}
void WallpaperSurface::endDraw() {
m_dirty.store(false, std::memory_order_relaxed);
m_frameReady.store(false, std::memory_order_relaxed);
} }
void WallpaperSurface::zwlr_layer_surface_v1_configure( void WallpaperSurface::zwlr_layer_surface_v1_configure(
@@ -141,6 +183,7 @@ void WallpaperSurface::updateBuffer() {
if (QtWayland::wp_viewport::isInitialized()) if (QtWayland::wp_viewport::isInitialized())
set_destination(m_size.width(), m_size.height()); set_destination(m_size.width(), m_size.height());
requestFrame();
m_service->queue(this); m_service->queue(this);
} }
+16 -1
View File
@@ -3,6 +3,8 @@
#include <EGL/egl.h> #include <EGL/egl.h>
#include <wayland-egl.h> #include <wayland-egl.h>
#include <atomic>
#include <QSize> #include <QSize>
#include <QString> #include <QString>
@@ -32,7 +34,14 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
[[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; }
[[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; }
void redraw(); void markDirty();
[[nodiscard]] bool needsDraw() const;
[[nodiscard]] bool frameReady() const;
void requestFrame();
void endDraw();
private: private:
void zwlr_layer_surface_v1_configure( void zwlr_layer_surface_v1_configure(
@@ -42,6 +51,8 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
void updateBuffer(); void updateBuffer();
void onFrameDone();
WallpaperService* m_service; WallpaperService* m_service;
QString m_screen; QString m_screen;
wl_surface* m_surface; wl_surface* m_surface;
@@ -53,6 +64,10 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
qreal m_scale = 1.0; qreal m_scale = 1.0;
bool configured = false; bool configured = false;
std::atomic<bool> m_dirty{false};
std::atomic<bool> m_frameReady{true};
struct wl_callback* m_frameCallback = nullptr;
}; };
} // namespace ZShell::internal } // namespace ZShell::internal
+230 -3
View File
@@ -1,17 +1,183 @@
#include "filesystemmodel.hpp" #include "filesystemmodel.hpp"
#include <qcoreapplication.h>
#include <qdatetime.h>
#include <qdir.h>
#include <qdiriterator.h> #include <qdiriterator.h>
#include <qfile.h>
#include <qfileinfo.h>
#include <qfuturewatcher.h> #include <qfuturewatcher.h>
#include <qimage.h>
#include <qpointer.h>
#include <qprocess.h>
#include <qregularexpression.h>
#include <qtemporarydir.h>
#include <qtconcurrentrun.h> #include <qtconcurrentrun.h>
#include <algorithm>
namespace ZShell::models { namespace ZShell::models {
const QStringList kVideoExtensions = {
"mp4",
"mkv",
"webm",
"avi",
"mov",
"wmv",
"flv",
"m4v",
"mpg",
"mpeg",
"3gp",
"ogv",
"ts",
"vob",
};
bool hasVideoExtension(const QString& path) {
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
}
QString videoThumbnailPath(const QString& path) {
const QFileInfo info(path);
QString key = QStringLiteral("%1_%2_%3")
.arg(info.baseName())
.arg(info.size())
.arg(info.lastModified().toMSecsSinceEpoch() / 1000);
key.replace(QRegularExpression("[^A-Za-z0-9._-]"), "_");
return QDir::homePath() + "/.cache/zshell/imagecache/video-previews/" +
key + "_preview.jpg";
}
bool generateVideoThumbnail(const QString& src, const QString& cachePath) {
if (QFile::exists(cachePath)) {
return true;
}
const QString scale = QStringLiteral(
"scale=1920:1080:force_original_aspect_ratio=decrease,"
"scale=trunc(iw/2)*2:trunc(ih/2)*2");
auto runProcess = [](const QString& program, const QStringList& args) {
QProcess process;
process.start(program, args);
process.waitForFinished(15000);
return qMakePair(process.exitCode(), process.readAllStandardOutput());
};
double duration = 0.0;
const auto probe = runProcess(
"ffprobe",
{
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
src,
});
if (probe.first == 0) {
duration = QString::fromUtf8(probe.second).trimmed().toDouble();
}
const int samples = duration > 0 ? std::min(10, std::max(4, int(duration)))
: 8;
QTemporaryDir tmpDir;
if (!tmpDir.isValid()) {
return false;
}
int bestIndex = -1;
double bestChroma = -1.0;
double bestLuma = -1.0;
for (int i = 0; i < samples; ++i) {
const double frac = duration > 0 ? (i + 0.5) / samples : 0.0;
const QString seek = duration > 0
? QString::number(frac * duration, 'f', 3)
: QStringLiteral("0");
const QString framePath =
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i));
runProcess(
"ffmpeg",
{
"-hide_banner",
"-loglevel",
"error",
"-ss",
seek,
"-i",
src,
"-frames:v",
"1",
"-vf",
scale,
"-pix_fmt",
"yuvj420p",
"-q:v",
"2",
"-y",
framePath,
});
QImage frame(framePath);
if (frame.isNull()) {
continue;
}
frame = frame.convertToFormat(QImage::Format_RGB888);
double chroma = 0.0;
double luma = 0.0;
int count = 0;
for (int y = 0; y < frame.height(); ++y) {
const auto* line = frame.constScanLine(y);
for (int x = 0; x < frame.width(); ++x) {
const int r = line[x * 3];
const int g = line[x * 3 + 1];
const int b = line[x * 3 + 2];
chroma += std::max({r, g, b}) - std::min({r, g, b});
luma += r + g + b;
++count;
}
}
const int n = std::max(1, count);
chroma /= n;
luma /= 3.0 * n;
if (chroma > bestChroma + 0.5 ||
(chroma > bestChroma - 0.5 && luma > bestLuma)) {
bestChroma = chroma;
bestLuma = luma;
bestIndex = i;
}
}
if (bestIndex < 0) {
return false;
}
QDir().mkpath(QFileInfo(cachePath).absolutePath());
return QFile::copy(
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex)),
cachePath);
}
FileSystemEntry::FileSystemEntry( FileSystemEntry::FileSystemEntry(
const QString& path, const QString& relativePath, QObject* parent) const QString& path, const QString& relativePath, QObject* parent)
: QObject(parent) : QObject(parent)
, m_fileInfo(path) , m_fileInfo(path)
, m_path(path) , m_path(path)
, m_relativePath(relativePath) , m_relativePath(relativePath)
, m_thumbnailPath(path)
, m_isImageInitialised(false) , m_isImageInitialised(false)
, m_mimeTypeInitialised(false) {} , m_mimeTypeInitialised(false) {}
@@ -56,6 +222,23 @@ bool FileSystemEntry::isImage() const {
return m_isImage; return m_isImage;
} }
bool FileSystemEntry::isVideo() const {
return hasVideoExtension(m_path);
}
QString FileSystemEntry::thumbnailPath() const {
return m_thumbnailPath;
}
void FileSystemEntry::setThumbnailPath(const QString& path) {
if (m_thumbnailPath == path) {
return;
}
m_thumbnailPath = path;
emit thumbnailPathChanged();
}
QString FileSystemEntry::mimeType() const { QString FileSystemEntry::mimeType() const {
if (!m_mimeTypeInitialised) { if (!m_mimeTypeInitialised) {
const QMimeDatabase db; const QMimeDatabase db;
@@ -318,12 +501,21 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
std::optional<QDirIterator> iter; std::optional<QDirIterator> iter;
if (filter == Images) { if (filter == Images || filter == Videos || filter == Media) {
QStringList extraNameFilters = nameFilters; QStringList extraNameFilters = nameFilters;
if (filter == Images || filter == Media) {
const auto formats = QImageReader::supportedImageFormats(); const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) { for (const auto& format : formats) {
extraNameFilters << "*." + format; extraNameFilters << "*." + format;
} }
}
if (filter == Videos || filter == Media) {
for (const auto& ext : kVideoExtensions) {
extraNameFilters << "*." + ext;
}
}
QDir::Filters filters = QDir::Files; QDir::Filters filters = QDir::Files;
if (showHidden) { if (showHidden) {
@@ -366,6 +558,13 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
if (!reader.canRead()) { if (!reader.canRead()) {
continue; continue;
} }
} else if (filter == Media) {
if (!hasVideoExtension(path)) {
QImageReader reader(path);
if (!reader.canRead()) {
continue;
}
}
} }
newPaths.insert(path); newPaths.insert(path);
@@ -409,6 +608,30 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
} }
void FileSystemModel::startThumbnailGeneration(FileSystemEntry* entry) {
const QPointer<FileSystemEntry> guard(entry);
const QString source = entry->path();
const QString cachePath = videoThumbnailPath(source);
const auto future = QtConcurrent::run([source, cachePath]() {
return generateVideoThumbnail(source, cachePath);
});
const auto watcher = new QFutureWatcher<bool>(this);
connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[guard, cachePath, watcher]() {
if (guard && watcher->result()) {
guard->setThumbnailPath(cachePath);
}
watcher->deleteLater();
});
watcher->setFuture(future);
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void FileSystemModel::applyChanges( void FileSystemModel::applyChanges(
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths) { const QSet<QString>& removedPaths, const QSet<QString>& addedPaths) {
QList<int> removedIndices; QList<int> removedIndices;
@@ -450,8 +673,12 @@ void FileSystemModel::applyChanges(
// Create new entries // Create new entries
QList<FileSystemEntry*> newEntries; QList<FileSystemEntry*> newEntries;
for (const auto& path : addedPaths) { for (const auto& path : addedPaths) {
newEntries << new FileSystemEntry( auto* entry =
path, m_dir.relativeFilePath(path), this); new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
if (entry->isVideo()) {
startThumbnailGeneration(entry);
}
newEntries << entry;
} }
std::sort( std::sort(
newEntries.begin(), newEntries.begin(),
+11 -1
View File
@@ -28,6 +28,9 @@ class FileSystemEntry : public QObject {
Q_PROPERTY(qint64 size READ size CONSTANT) Q_PROPERTY(qint64 size READ size CONSTANT)
Q_PROPERTY(bool isDir READ isDir CONSTANT) Q_PROPERTY(bool isDir READ isDir CONSTANT)
Q_PROPERTY(bool isImage READ isImage CONSTANT) Q_PROPERTY(bool isImage READ isImage CONSTANT)
Q_PROPERTY(bool isVideo READ isVideo CONSTANT)
Q_PROPERTY(
QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged)
Q_PROPERTY(QString mimeType READ mimeType CONSTANT) Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
public: public:
@@ -45,18 +48,24 @@ class FileSystemEntry : public QObject {
[[nodiscard]] qint64 size() const; [[nodiscard]] qint64 size() const;
[[nodiscard]] bool isDir() const; [[nodiscard]] bool isDir() const;
[[nodiscard]] bool isImage() const; [[nodiscard]] bool isImage() const;
[[nodiscard]] bool isVideo() const;
[[nodiscard]] QString thumbnailPath() const;
[[nodiscard]] QString mimeType() const; [[nodiscard]] QString mimeType() const;
void setThumbnailPath(const QString& path);
void updateRelativePath(const QDir& dir); void updateRelativePath(const QDir& dir);
signals: signals:
void relativePathChanged(); void relativePathChanged();
void thumbnailPathChanged();
private: private:
const QFileInfo m_fileInfo; const QFileInfo m_fileInfo;
const QString m_path; const QString m_path;
QString m_relativePath; QString m_relativePath;
QString m_thumbnailPath;
mutable bool m_isImage; mutable bool m_isImage;
mutable bool m_isImageInitialised; mutable bool m_isImageInitialised;
@@ -91,7 +100,7 @@ class FileSystemModel : public QAbstractListModel {
NOTIFY entriesChanged) NOTIFY entriesChanged)
public: public:
enum Filter { NoFilter, Images, Files, Dirs }; enum Filter { NoFilter, Images, Videos, Media, Files, Dirs };
Q_ENUM(Filter) Q_ENUM(Filter)
explicit FileSystemModel(QObject* parent = nullptr); explicit FileSystemModel(QObject* parent = nullptr);
@@ -156,6 +165,7 @@ class FileSystemModel : public QAbstractListModel {
void updateEntriesForDir(const QString& dir); void updateEntriesForDir(const QString& dir);
void applyChanges( void applyChanges(
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths); const QSet<QString>& removedPaths, const QSet<QString>& addedPaths);
void startThumbnailGeneration(FileSystemEntry* entry);
[[nodiscard]] bool compareEntries( [[nodiscard]] bool compareEntries(
const FileSystemEntry* a, const FileSystemEntry* b) const; const FileSystemEntry* a, const FileSystemEntry* b) const;
}; };
+125
View File
@@ -5,6 +5,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -31,6 +32,23 @@ from zshell.utils.schemepalettes import (
app = typer.Typer() app = typer.Typer()
VIDEO_EXTENSIONS = {
".mp4",
".mkv",
".webm",
".avi",
".mov",
".wmv",
".flv",
".m4v",
".mpg",
".mpeg",
".3gp",
".ogv",
".ts",
".vob",
}
def _complete_scheme_name(incomplete): def _complete_scheme_name(incomplete):
schemes = [ schemes = [
@@ -351,6 +369,109 @@ def generate(
safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
return thumb_dir / f"{safe_key}_thumbnail.jpg" return thumb_dir / f"{safe_key}_thumbnail.jpg"
def is_video(path: Path) -> bool:
return path.suffix.lower() in VIDEO_EXTENSIONS
def video_duration(path: Path) -> float:
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
str(path),
],
capture_output=True,
text=True,
check=False,
)
return float(out.stdout.strip())
except (ValueError, OSError):
return 0.0
def video_frame_chroma(path: Path) -> tuple[float, float]:
"""Return (mean chroma, mean luminance) of a frame.
Mean chroma (mean of max-min across RGB channels) is ~0 for black,
white and grey frames, and rises with saturation - so it identifies a
"good", colorful frame and rejects black or fade frames.
"""
with Image.open(path) as im:
im = im.convert("RGB")
pixels = list(im.getdata())
chroma = 0
luma = 0
for r, g, b in pixels:
chroma += max(r, g, b) - min(r, g, b)
luma += r + g + b
n = max(1, len(pixels))
return chroma / n, luma / (3 * n)
def generate_video_thumbnail(
video_path: Path, cache_path: Path, size=(128, 128)
) -> None:
w, h = size
duration = video_duration(video_path)
samples = 8 if duration <= 0 else min(10, max(4, int(duration)))
tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-"))
try:
best: Path | None = None
best_key: tuple[float, float] = (-1.0, -1.0)
for i in range(samples):
frac = (i + 0.5) / samples if duration > 0 else 0.0
seek = f"{frac * duration:.3f}" if duration > 0 else "0"
frame = tmp_dir / f"frame_{i}.jpg"
subprocess.run(
[
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-ss",
seek,
"-i",
str(video_path),
"-frames:v",
"1",
"-vf",
f"scale={w}:{h}",
"-pix_fmt",
"yuvj420p",
"-q:v",
"2",
"-y",
str(frame),
],
check=False,
)
if not frame.exists():
continue
key = video_frame_chroma(frame)
if key > best_key:
best_key = key
best = frame
if best is None:
raise RuntimeError(
f"Could not extract a frame from {video_path}"
)
shutil.copy(best, cache_path)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def generate_thumbnail( def generate_thumbnail(
image_path: Path, thumb_dir: Path, size=(128, 128) image_path: Path, thumb_dir: Path, size=(128, 128)
) -> Path: ) -> Path:
@@ -360,6 +481,10 @@ def generate(
if cache_path.exists(): if cache_path.exists():
return cache_path return cache_path
if is_video(image_path):
generate_video_thumbnail(image_path, cache_path, size)
return cache_path
image = Image.open(image_path) image = Image.open(image_path)
image.draft("RGB", size) image.draft("RGB", size)
image = image.convert("RGB") image = image.convert("RGB")