From 906c6a2501ed05680004a003765d44433269dd4c Mon Sep 17 00:00:00 2001 From: Kangheng Liu <72962885+kanghengliu@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:14:59 -0400 Subject: [PATCH] feat: FileBrowser video thumbnail (#2077) * feat(filebrowser): add filebrowser video thumbnails display - Find cached thumbnails first - If not found, generate with ffmpegthumbnailer - Fallback to placeholder icon if dependency not met * fix(filebrowser): create thumbnail cache dir if not exists * refactor(filebrowser): prefer using Paths lib * fix(filebrowser): only check filetype once for each file * fix(filebrowser): early test for thumbnails * feat: add xdgCache path --- quickshell/Common/Paths.qml | 1 + .../FileBrowser/FileBrowserGridDelegate.qml | 54 +++++++++++++++++-- .../FileBrowser/FileBrowserListDelegate.qml | 52 ++++++++++++++++-- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/quickshell/Common/Paths.qml b/quickshell/Common/Paths.qml index 87ed822e..19a4b161 100644 --- a/quickshell/Common/Paths.qml +++ b/quickshell/Common/Paths.qml @@ -10,6 +10,7 @@ Singleton { readonly property url home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0] readonly property url pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property url xdgCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0] readonly property url data: `${StandardPaths.standardLocations(StandardPaths.GenericDataLocation)[0]}/DankMaterialShell` readonly property url state: `${StandardPaths.standardLocations(StandardPaths.GenericStateLocation)[0]}/DankMaterialShell` diff --git a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml index 86fb9984..effa9bd7 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml @@ -75,6 +75,50 @@ StyledRect { return determineFileType(fileName) === "image"; } + function isVideoFile(fileName) { + if (!fileName) { + return false; + } + return determineFileType(fileName) === "video"; + } + + property bool isImage: isImageFile(delegateRoot.fileName) + property bool isVideo: isVideoFile(delegateRoot.fileName) + + property string _xdgCacheHome: Paths.strip(Paths.xdgCache) + property string _thumbnailSize: iconSizeIndex >= 2 ? "x-large" : "large" + property int _thumbnailPx: iconSizeIndex >= 2 ? 512 : 256 + property string videoThumbnailPath: { + if (!delegateRoot.fileIsDir && isVideo) { + const hash = Qt.md5("file://" + delegateRoot.filePath); + return _xdgCacheHome + "/thumbnails/" + _thumbnailSize + "/" + hash + ".png"; + } + return ""; + } + + property string _videoThumb: "" + + onVideoThumbnailPathChanged: { + _videoThumb = ""; + if (!videoThumbnailPath) + return; + const thumbPath = videoThumbnailPath; + const thumbDir = _xdgCacheHome + "/thumbnails/" + _thumbnailSize; + const size = _thumbnailPx; + const fp = delegateRoot.filePath; + Paths.mkdir(thumbDir); + Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) { + if (exitCode === 0) { + _videoThumb = thumbPath; + } else { + Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function(output, exitCode) { + if (exitCode === 0) + _videoThumb = thumbPath; + }); + } + }); + } + function getIconForFile(fileName) { const lowerName = fileName.toLowerCase(); if (lowerName.startsWith("dockerfile")) { @@ -124,7 +168,11 @@ StyledRect { property string imagePath: { if (weMode && delegateRoot.fileIsDir) return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]; - return (!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) ? delegateRoot.filePath : ""; + if (!delegateRoot.fileIsDir && isImage) + return delegateRoot.filePath; + if (_videoThumb) + return _videoThumb; + return ""; } source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" onStatusChanged: { @@ -149,7 +197,7 @@ StyledRect { source: gridPreviewImage maskEnabled: true maskSource: gridImageMask - visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) || (weMode && delegateRoot.fileIsDir)) + visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && (isImage || isVideo)) || (weMode && delegateRoot.fileIsDir)) maskThresholdMin: 0.5 maskSpreadAtMin: 1 } @@ -175,7 +223,7 @@ StyledRect { name: delegateRoot.fileIsDir ? "folder" : getIconForFile(delegateRoot.fileName) size: iconSizes[iconSizeIndex] * 0.45 color: delegateRoot.fileIsDir ? Theme.primary : Theme.surfaceText - visible: (!delegateRoot.fileIsDir && !isImageFile(delegateRoot.fileName)) || (delegateRoot.fileIsDir && !weMode) + visible: (!delegateRoot.fileIsDir && !isImage && !(isVideo && gridPreviewImage.status === Image.Ready)) || (delegateRoot.fileIsDir && !weMode) } } diff --git a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml index ebc22046..2f452b45 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml @@ -74,6 +74,46 @@ StyledRect { return determineFileType(fileName) === "image"; } + function isVideoFile(fileName) { + if (!fileName) { + return false; + } + return determineFileType(fileName) === "video"; + } + + property bool isImage: isImageFile(listDelegateRoot.fileName) + property bool isVideo: isVideoFile(listDelegateRoot.fileName) + + property string _xdgCacheHome: Paths.strip(Paths.xdgCache) + property string videoThumbnailPath: { + if (!listDelegateRoot.fileIsDir && isVideo) { + const hash = Qt.md5("file://" + listDelegateRoot.filePath); + return _xdgCacheHome + "/thumbnails/normal/" + hash + ".png"; + } + return ""; + } + + property string _videoThumb: "" + + onVideoThumbnailPathChanged: { + _videoThumb = ""; + if (!videoThumbnailPath) + return; + const thumbPath = videoThumbnailPath; + const fp = listDelegateRoot.filePath; + Paths.mkdir(_xdgCacheHome + "/thumbnails/normal"); + Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) { + if (exitCode === 0) { + _videoThumb = thumbPath; + } else { + Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function(output, exitCode) { + if (exitCode === 0) + _videoThumb = thumbPath; + }); + } + }); + } + function getIconForFile(fileName) { const lowerName = fileName.toLowerCase(); if (lowerName.startsWith("dockerfile")) { @@ -127,7 +167,13 @@ StyledRect { Image { id: listPreviewImage anchors.fill: parent - property string imagePath: (!listDelegateRoot.fileIsDir && isImageFile(listDelegateRoot.fileName)) ? listDelegateRoot.filePath : "" + property string imagePath: { + if (!listDelegateRoot.fileIsDir && isImage) + return listDelegateRoot.filePath; + if (_videoThumb) + return _videoThumb; + return ""; + } source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" fillMode: Image.PreserveAspectCrop sourceSize.width: 32 @@ -141,7 +187,7 @@ StyledRect { source: listPreviewImage maskEnabled: true maskSource: listImageMask - visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && isImageFile(listDelegateRoot.fileName) + visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && (isImage || isVideo) maskThresholdMin: 0.5 maskSpreadAtMin: 1 } @@ -166,7 +212,7 @@ StyledRect { name: listDelegateRoot.fileIsDir ? "folder" : getIconForFile(listDelegateRoot.fileName) size: Theme.iconSize - 2 color: listDelegateRoot.fileIsDir ? Theme.primary : Theme.surfaceText - visible: listDelegateRoot.fileIsDir || !isImageFile(listDelegateRoot.fileName) + visible: listDelegateRoot.fileIsDir || (!isImage && !(isVideo && listPreviewImage.status === Image.Ready)) } }