diff --git a/Modals/FileBrowser/FileBrowserModal.qml b/Modals/FileBrowser/FileBrowserModal.qml index 6bf990a7..9943b437 100644 --- a/Modals/FileBrowser/FileBrowserModal.qml +++ b/Modals/FileBrowser/FileBrowserModal.qml @@ -47,6 +47,14 @@ DankModal { return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext) } + function isVideoFile(fileName) { + if (!fileName) { + return false + } + const ext = fileName.toLowerCase().split('.').pop() + return ['mp4', 'mkv', 'webm', 'avi', 'mov'].includes(ext) + } + function getLastPath() { const lastPath = browserType === "wallpaper" ? SessionData.wallpaperLastPath : browserType === "profile" ? SessionData.profileLastPath : "" return (lastPath && lastPath !== "") ? lastPath : homeDir @@ -201,6 +209,11 @@ DankModal { selectedFilePath = "" selectedFileName = "" selectedFileIsDir = false + + // Preload thumbnails for video files in new directory + if (browserType === "wallpaper") { + ThumbnailService.preloadThumbnails(currentPath) + } } onSelectedIndexChanged: { if (selectedIndex >= 0 && folderModel && selectedIndex < folderModel.count) { @@ -667,7 +680,10 @@ DankModal { if (weMode && delegateRoot.fileIsDir) { return "file://" + delegateRoot.filePath + "/preview" + weExtensions[weExtIndex] } - return (!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) ? ("file://" + delegateRoot.filePath) : "" + if (!delegateRoot.fileIsDir && (isImageFile(delegateRoot.fileName) || isVideoFile(delegateRoot.fileName))) { + return ThumbnailService.getThumbnail(delegateRoot.filePath) + } + return "" } onStatusChanged: { if (weMode && delegateRoot.fileIsDir && status === Image.Error) { @@ -680,24 +696,38 @@ DankModal { } } fillMode: Image.PreserveAspectCrop - visible: (!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) || (weMode && delegateRoot.fileIsDir) + visible: (!delegateRoot.fileIsDir && (isImageFile(delegateRoot.fileName) || isVideoFile(delegateRoot.fileName))) || (weMode && delegateRoot.fileIsDir) maxCacheSize: weMode ? 225 : 80 } - DankIcon { - anchors.centerIn: parent - name: "description" - size: Theme.iconSizeLarge - color: Theme.primary - visible: !delegateRoot.fileIsDir && !isImageFile(delegateRoot.fileName) + Rectangle { + anchors.fill: parent + color: "black" + opacity: 0.3 + visible: delegateRoot.fileIsDir && !weMode } DankIcon { anchors.centerIn: parent - name: "folder" + name: delegateRoot.fileIsDir ? "folder" : isVideoFile(delegateRoot.fileName) ? "movie" : "description" size: Theme.iconSizeLarge color: Theme.primary - visible: delegateRoot.fileIsDir && !weMode + visible: (delegateRoot.fileIsDir && !weMode) || (!delegateRoot.fileIsDir && !isImageFile(delegateRoot.fileName) && !isVideoFile(delegateRoot.fileName)) + } + + // Video overlay for video files + Rectangle { + anchors.fill: parent + color: "#80000000" + visible: !delegateRoot.fileIsDir && isVideoFile(delegateRoot.fileName) && parent.status !== Image.Ready + radius: Theme.cornerRadiusSmall + + DankIcon { + anchors.centerIn: parent + name: "play_circle" + size: Theme.iconSizeLarge + color: "white" + } } } @@ -731,7 +761,12 @@ DankModal { } else if (delegateRoot.fileIsDir) { navigateTo(delegateRoot.filePath) } else { - fileSelected(delegateRoot.filePath) + // For video files, automatically prefix with "gs:" for gSlapper support + var selectedPath = delegateRoot.filePath + if (isVideoFile(delegateRoot.fileName) && browserType === "wallpaper") { + selectedPath = "gs:" + selectedPath + } + fileSelected(selectedPath) fileBrowserModal.close() } } @@ -751,7 +786,12 @@ DankModal { } else if (delegateRoot.fileIsDir) { navigateTo(delegateRoot.filePath) } else { - fileSelected(delegateRoot.filePath) + // For video files, automatically prefix with "gs:" for gSlapper support + var selectedPath = delegateRoot.filePath + if (isVideoFile(delegateRoot.fileName) && browserType === "wallpaper") { + selectedPath = "gs:" + selectedPath + } + fileSelected(selectedPath) fileBrowserModal.close() } } diff --git a/Modules/Settings/PersonalizationTab.qml b/Modules/Settings/PersonalizationTab.qml index e35df9f6..b0f07a99 100644 --- a/Modules/Settings/PersonalizationTab.qml +++ b/Modules/Settings/PersonalizationTab.qml @@ -1384,7 +1384,7 @@ Item { browserTitle: "Select Wallpaper" browserIcon: "wallpaper" browserType: "wallpaper" - fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"] + fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.mp4", "*.mkv", "*.webm", "*.avi", "*.mov"] onFileSelected: path => { if (SessionData.perMonitorWallpaper) { SessionData.setMonitorWallpaper(selectedMonitorName, path) diff --git a/Modules/WallpaperBackground.qml b/Modules/WallpaperBackground.qml index ffe4b11c..4c4979db 100644 --- a/Modules/WallpaperBackground.qml +++ b/Modules/WallpaperBackground.qml @@ -1,448 +1,184 @@ +import QtCore import QtQuick import Quickshell -import Quickshell.Wayland -import Quickshell.Widgets import Quickshell.Io +import Quickshell.Wlr import qs.Common +import qs.Services import qs.Widgets -import qs.Modules +import ".." -Variants { - model: { - if (SessionData.isGreeterMode) { - return Quickshell.screens - } - return SettingsData.getFilteredScreens("wallpaper") - } +LazyLoader { + active: true - PanelWindow { - id: wallpaperWindow + Variants { + model: SettingsData.getFilteredScreens("wallpaper") - required property var modelData + PanelWindow { + id: wallpaperWindow - screen: modelData + required property var modelData - WlrLayershell.layer: WlrLayer.Background - WlrLayershell.exclusionMode: ExclusionMode.Ignore + screen: modelData - anchors.top: true - anchors.bottom: true - anchors.left: true - anchors.right: true + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusionMode: ExclusionMode.Ignore - color: "transparent" + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true - Item { - id: root - anchors.fill: parent + color: "black" - property string source: SessionData.getMonitorWallpaper(modelData.name) || "" - property bool isColorSource: source.startsWith("#") - property string transitionType: SessionData.wallpaperTransition - property string actualTransitionType: transitionType + Item { + id: root + anchors.fill: parent - Connections { - target: SessionData - function onIsLightModeChanged() { - if (SessionData.perModeWallpaper) { - var newSource = SessionData.getMonitorWallpaper(modelData.name) || "" - if (newSource !== root.source) { - root.source = newSource - } - } - } - } - onTransitionTypeChanged: { - if (transitionType === "random") { - if (SessionData.includedTransitions.length === 0) { - actualTransitionType = "none" - } else { - actualTransitionType = SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)] - } - } else { - actualTransitionType = transitionType - } - } + property string source: SessionData.getMonitorWallpaper(modelData.name) || "" + property bool isColorSource: source.startsWith("#") + property Image current: one - onActualTransitionTypeChanged: { - if (actualTransitionType === "none") { - currentWallpaper.visible = true - nextWallpaper.visible = false + WallpaperEngineProc { + id: weProc + monitor: modelData.name } - } - property real transitionProgress: 0 - property real fillMode: 1.0 - property vector4d fillColor: Qt.vector4d(0, 0, 0, 1) - property real edgeSmoothness: 0.1 - - property real wipeDirection: 0 - property real discCenterX: 0.5 - property real discCenterY: 0.5 - property real stripesCount: 16 - property real stripesAngle: 0 - - readonly property bool transitioning: transitionAnimation.running - - property bool hasCurrent: currentWallpaper.status === Image.Ready && !!currentWallpaper.source - property bool booting: !hasCurrent && nextWallpaper.status === Image.Ready - - WallpaperEngineProc { - id: weProc - monitor: modelData.name - } - - Component.onDestruction: { - weProc.stop() - } - onSourceChanged: { - const isWE = source.startsWith("we:") - const isColor = source.startsWith("#") - - if (isWE) { - setWallpaperImmediate("") - weProc.start(source.substring(3)) - } else { + Component.onDestruction: { weProc.stop() - if (!source) { - setWallpaperImmediate("") - } else if (isColor) { - setWallpaperImmediate("") + GslapperService.stopVideo(modelData.name) + } + + onSourceChanged: { + const isWE = source.startsWith("we:") + const isGS = source.startsWith("gs:") + if (isWE) { + current = null + one.source = "" + two.source = "" + GslapperService.stopVideo(modelData.name) + weProc.start(source.substring(3)) // strip "we:" + } else if (isGS) { + current = null + one.source = "" + two.source = "" + weProc.stop() + const videoPath = source.substring(3) // strip "gs:" + + if (!SessionData.perMonitorWallpaper) { + // Global mode: Only start from the first monitor to avoid duplicates + // Use comma-separated monitor list for gSlapper dual output + const allScreens = Quickshell.screens + const firstScreenName = allScreens.length > 0 ? allScreens[0].name : "" + + if (modelData.name === firstScreenName) { + const outputNames = allScreens.map(screen => screen.name).join(",") + console.log("WallpaperBackground: Starting global video with monitors:", outputNames) + GslapperService.startVideo(outputNames, videoPath) + } else { + console.log("WallpaperBackground: Skipping duplicate global video start for monitor:", modelData.name, "(first screen is", firstScreenName + ")") + } + } else if (SessionData.perMonitorWallpaper) { + // Per-monitor mode: start individual process for this specific monitor + console.log("WallpaperBackground: Starting per-monitor video for:", modelData.name) + GslapperService.startVideo(modelData.name, videoPath) + } } else { - // Always set immediately if there's no current wallpaper (startup) - if (!currentWallpaper.source) { - setWallpaperImmediate(source.startsWith("file://") ? source : "file://" + source) + weProc.stop() + GslapperService.stopVideo(modelData.name) + if (!source) { + current = null + one.source = "" + two.source = "" + } else if (isColorSource) { + current = null + one.source = "" + two.source = "" } else { - changeWallpaper(source.startsWith("file://") ? source : "file://" + source) + if (current === one) + two.update() + else + one.update() } } } - } - - function setWallpaperImmediate(newSource) { - transitionAnimation.stop() - root.transitionProgress = 0.0 - currentWallpaper.source = newSource - nextWallpaper.source = "" - currentWallpaper.visible = true - nextWallpaper.visible = false - } - function changeWallpaper(newPath, force) { - if (!force && newPath === currentWallpaper.source) - return - if (!newPath || newPath.startsWith("#")) - return - - if (root.transitioning) { - transitionAnimation.stop() - root.transitionProgress = 0 - currentWallpaper.source = nextWallpaper.source - nextWallpaper.source = "" + onIsColorSourceChanged: { + if (isColorSource) { + current = null + one.source = "" + two.source = "" + } else if (source) { + if (current === one) + two.update() + else + one.update() + } } - // If no current wallpaper, set immediately to avoid scaling issues - if (!currentWallpaper.source) { - setWallpaperImmediate(newPath) - return - } + Loader { + active: !root.source || root.isColorSource + asynchronous: true - // If transition is "none", set immediately - if (root.transitionType === "random") { - if (SessionData.includedTransitions.length === 0) { - root.actualTransitionType = "none" - } else { - root.actualTransitionType = SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)] + sourceComponent: DankBackdrop { + screenName: modelData.name } } - if (root.actualTransitionType === "none") { - setWallpaperImmediate(newPath) - return + Img { + id: one } - if (root.actualTransitionType === "wipe") { - root.wipeDirection = Math.random() * 4 - } else if (root.actualTransitionType === "disc") { - root.discCenterX = Math.random() - root.discCenterY = Math.random() - } else if (root.actualTransitionType === "stripes") { - root.stripesCount = Math.round(Math.random() * 20 + 4) - root.stripesAngle = Math.random() * 360 + Img { + id: two } - nextWallpaper.source = newPath + component Img: Image { + id: img - if (nextWallpaper.status === Image.Ready) { - transitionAnimation.start() - } - } - - Loader { - anchors.fill: parent - active: !root.source || root.isColorSource - asynchronous: true - - sourceComponent: DankBackdrop { - screenName: modelData.name - } - } - - Rectangle { - id: transparentRect - anchors.fill: parent - color: "transparent" - visible: false - } - - ShaderEffectSource { - id: transparentSource - sourceItem: transparentRect - hideSource: true - live: false - } - - Image { - id: currentWallpaper - anchors.fill: parent - visible: root.actualTransitionType === "none" - opacity: 1 - layer.enabled: false - asynchronous: true - smooth: true - cache: true - fillMode: Image.PreserveAspectCrop - } + function update(): void { + source = "" + source = root.source + } - Image { - id: nextWallpaper - anchors.fill: parent - visible: false - opacity: 0 - layer.enabled: false - asynchronous: true - smooth: true - cache: true - fillMode: Image.PreserveAspectCrop + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + smooth: true + asynchronous: true + cache: false + + opacity: 0 + + onStatusChanged: { + if (status === Image.Ready) { + root.current = this + if (root.current === one && two.source) { + two.source = "" + } else if (root.current === two && one.source) { + one.source = "" + } + } + } - onStatusChanged: { - if (status !== Image.Ready) - return + states: State { + name: "visible" + when: root.current === img - if (root.actualTransitionType === "none") { - currentWallpaper.source = source - nextWallpaper.source = "" - root.transitionProgress = 0.0 - } else { - visible = true - if (!root.transitioning) { - transitionAnimation.start() + PropertyChanges { + img.opacity: 1 } } - } - } - Loader { - id: effectLoader - anchors.fill: parent - active: root.actualTransitionType !== "none" && (root.hasCurrent || root.booting) - sourceComponent: { - switch (root.actualTransitionType) { - case "fade": - return fadeComp - case "wipe": - return wipeComp - case "disc": - return discComp - case "stripes": - return stripesComp - case "iris bloom": - return irisComp - case "pixelate": - return pixelateComp - case "portal": - return portalComp - default: - return null + transitions: Transition { + NumberAnimation { + target: img + properties: "opacity" + duration: Theme.mediumDuration + easing.type: Easing.OutCubic + } } } } - - Component { - id: fadeComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_fade.frag.qsb") - } - } - - Component { - id: wipeComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real smoothness: root.edgeSmoothness - property real direction: root.wipeDirection - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_wipe.frag.qsb") - } - } - - Component { - id: discComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real smoothness: root.edgeSmoothness - property real aspectRatio: root.width / root.height - property real centerX: root.discCenterX - property real centerY: root.discCenterY - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_disc.frag.qsb") - } - } - - Component { - id: stripesComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real smoothness: root.edgeSmoothness - property real aspectRatio: root.width / root.height - property real stripeCount: root.stripesCount - property real angle: root.stripesAngle - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_stripes.frag.qsb") - } - } - - Component { - id: irisComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real smoothness: root.edgeSmoothness - property real centerX: 0.5 - property real centerY: 0.5 - property real aspectRatio: root.width / root.height - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_iris_bloom.frag.qsb") - } - } - - Component { - id: pixelateComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real smoothness: root.edgeSmoothness - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - property real centerX: root.discCenterX - property real centerY: root.discCenterY - property real aspectRatio: root.width / root.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_pixelate.frag.qsb") - } - } - - Component { - id: portalComp - ShaderEffect { - anchors.fill: parent - property variant source1: root.hasCurrent ? currentWallpaper : transparentSource - property variant source2: nextWallpaper - property real progress: root.transitionProgress - property real smoothness: root.edgeSmoothness - property real aspectRatio: root.width / root.height - property real centerX: root.discCenterX - property real centerY: root.discCenterY - property real fillMode: root.fillMode - property vector4d fillColor: root.fillColor - property real imageWidth1: Math.max(1, root.hasCurrent ? source1.sourceSize.width : modelData.width) - property real imageHeight1: Math.max(1, root.hasCurrent ? source1.sourceSize.height : modelData.height) - property real imageWidth2: Math.max(1, source2.sourceSize.width) - property real imageHeight2: Math.max(1, source2.sourceSize.height) - property real screenWidth: modelData.width - property real screenHeight: modelData.height - fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_portal.frag.qsb") - } - } - - NumberAnimation { - id: transitionAnimation - target: root - property: "transitionProgress" - from: 0.0 - to: 1.0 - duration: root.actualTransitionType === "none" ? 0 : 1000 - easing.type: Easing.InOutCubic - onFinished: { - Qt.callLater(() => { - if (nextWallpaper.source && nextWallpaper.status === Image.Ready && !nextWallpaper.source.toString().startsWith("#")) { - currentWallpaper.source = nextWallpaper.source - } - nextWallpaper.source = "" - nextWallpaper.visible = false - currentWallpaper.visible = root.actualTransitionType === "none" - root.transitionProgress = 0.0 - }) - } - } } } -} +} \ No newline at end of file diff --git a/Services/GslapperService.qml b/Services/GslapperService.qml new file mode 100644 index 00000000..fcfe9c63 --- /dev/null +++ b/Services/GslapperService.qml @@ -0,0 +1,347 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + property var activeProcesses: ({}) + property var recentFailures: ({}) // Track failed videos to prevent endless restart loops + property bool gSlapperAvailable: false + property bool previousPerMonitorMode: false // Track mode changes + + Component.onCompleted: { + checkGSlapperAvailability() + // Initialize mode tracking + previousPerMonitorMode = SessionData.perMonitorWallpaper + } + + // Monitor SessionData for wallpaper changes to clean up processes + Connections { + target: SessionData + + function onWallpaperPathChanged() { + console.log("GslapperService: Global wallpaper changed, cleaning up processes") + // When global wallpaper changes, stop all processes if not per-monitor mode + if (!SessionData.perMonitorWallpaper) { + stopAllVideos() + } + } + + function onMonitorWallpapersChanged() { + console.log("GslapperService: Monitor wallpapers changed, checking for cleanup") + // Clean up processes for monitors that no longer have gs: wallpapers + const currentWallpapers = SessionData.monitorWallpapers || {} + + for (const monitor in activeProcesses) { + const currentWallpaper = currentWallpapers[monitor] || "" + if (!currentWallpaper.startsWith("gs:")) { + console.log("GslapperService: Stopping video for monitor", monitor, "- no longer has gs: wallpaper") + stopVideo(monitor) + } + } + } + + function onPerMonitorWallpaperChanged() { + console.log("GslapperService: Per-monitor mode changed from", root.previousPerMonitorMode, "to", SessionData.perMonitorWallpaper) + + // When switching between per-monitor and global mode, clean up ALL processes + // This ensures clean state transition between modes + console.log("GslapperService: Mode transition - force cleaning all processes") + forceCleanup() + + // Update our tracking of the current mode + root.previousPerMonitorMode = SessionData.perMonitorWallpaper + } + } + + function checkGSlapperAvailability() { + gSlapperCheck.running = true + } + + function startVideo(monitor, videoPath, options = "") { + stopVideo(monitor) + + // Wait a moment for processes to be fully killed + delayTimer.interval = 500 // Wait 500ms for cleanup + delayTimer.monitor = monitor + delayTimer.videoPath = videoPath + delayTimer.options = options + delayTimer.running = true + + return true + } + + function startVideoDelayed(monitor, videoPath, options = "") { + if (!gSlapperAvailable) { + console.warn("GslapperService: gSlapper not available") + return false + } + + // Verify video file exists + if (!videoPath || videoPath.trim() === "") { + console.error("GslapperService: Invalid video path provided") + return false + } + + // Check if this video has failed too many times recently + const failureKey = monitor + ":" + videoPath + if (recentFailures[failureKey] && recentFailures[failureKey] >= 3) { + console.error("GslapperService: Video has failed too many times recently:", videoPath) + return false + } + + const process = processComponent.createObject(root) + if (!process) { + console.error("GslapperService: Failed to create gSlapper process") + return false + } + + // Use proper gSlapper options with background mode and proper GStreamer options + const gstOptions = options || "no-audio loop panscan=1.0" + // Use -s for background mode, -vs for verbose background mode, -l background for layer + process.command = ["gslapper", "-vs", "-l", "background", "-o", gstOptions, monitor, videoPath] + process.monitorName = monitor + process.videoPath = videoPath + process.startTime = Date.now() + process.crashCount = 0 + + activeProcesses[monitor] = process + process.running = true + + console.log("GslapperService: Starting gSlapper for monitor:", monitor, "with video:", videoPath, "options:", gstOptions) + return true + } + + function stopVideo(monitor) { + let hadProcess = false + if (activeProcesses[monitor]) { + hadProcess = true + console.log("GslapperService: Stopping video for monitor:", monitor) + const process = activeProcesses[monitor] + + // Force kill immediately - no graceful termination + if (process.running) { + process.running = false + } + + process.destroy() + delete activeProcesses[monitor] + console.log("GslapperService: Cleaned up process for monitor:", monitor) + } + + // Improved logic: Only kill other processes when necessary + if (hadProcess) { + if (!SessionData.perMonitorWallpaper) { + // Global mode: Kill all gslapper processes since we're changing global wallpaper + // This is safe because in global mode, one process handles all monitors + console.log("GslapperService: Killing all gslapper processes (global mode change)") + killAllGslapperProcesses.running = true + } else if (SessionData.perMonitorWallpaper) { + // Per-monitor mode: Only kill specifically targeted processes, don't touch others + // Use pkill with specific monitor pattern to avoid killing other monitor processes + console.log("GslapperService: Per-monitor mode - only stopping specific monitor process") + // Don't kill all processes in per-monitor mode unless explicitly stopping everything + } + } + } + + function stopAllVideos() { + for (const monitor in activeProcesses) { + stopVideo(monitor) + } + + // Also kill any orphaned processes as a safety measure + Qt.callLater(() => { + console.log("GslapperService: Running safety cleanup of all gSlapper processes") + killAllGslapperProcesses.running = true + }) + } + + function forceCleanup() { + console.log("GslapperService: Force cleanup requested - killing all processes") + // Destroy all tracked processes + for (const monitor in activeProcesses) { + if (activeProcesses[monitor]) { + activeProcesses[monitor].kill() + activeProcesses[monitor].destroy() + delete activeProcesses[monitor] + } + } + activeProcesses = {} + + // Kill any remaining gSlapper processes + killAllGslapperProcesses.running = true + } + + function isVideoRunning(monitor) { + return activeProcesses[monitor] && activeProcesses[monitor].running + } + + function getRunningVideo(monitor) { + return activeProcesses[monitor] ? activeProcesses[monitor].videoPath : "" + } + + function getServiceStatus() { + const status = { + available: gSlapperAvailable, + activeProcesses: Object.keys(activeProcesses).length, + recentFailures: Object.keys(recentFailures).length, + processes: {} + } + + for (const monitor in activeProcesses) { + const proc = activeProcesses[monitor] + status.processes[monitor] = { + running: proc.running, + videoPath: proc.videoPath, + uptime: Date.now() - proc.startTime, + crashCount: proc.crashCount + } + } + + return status + } + + function pauseVideo(monitor) { + // gSlapper doesn't have pause/resume, so we stop for now + stopVideo(monitor) + } + + function resumeVideo(monitor, videoPath, options = "") { + startVideo(monitor, videoPath, options) + } + + Process { + id: gSlapperCheck + command: ["which", "gslapper"] + running: false + + onExited: code => { + gSlapperAvailable = (code === 0) + if (!gSlapperAvailable) { + console.warn("gSlapper not found - video wallpaper support disabled") + } else { + console.log("gSlapper found - video wallpaper support enabled") + } + } + } + + Process { + id: killAllGslapperProcesses + command: ["pkill", "-f", "gslapper"] + running: false + + onExited: code => { + console.log("GslapperService: Force killed all gSlapper processes, exit code:", code) + } + } + + Component { + id: processComponent + + Process { + property string monitorName: "" + property string videoPath: "" + property real startTime: 0 + property int crashCount: 0 + readonly property int maxCrashCount: 2 + readonly property int minRunTime: 10000 // 10 seconds minimum run time + readonly property int maxIdleTime: 30000 // 30 seconds max to start properly + + running: false + + onExited: code => { + const runTime = Date.now() - startTime + const wasCrash = (code !== 0 && runTime < minRunTime) + const failureKey = monitorName + ":" + videoPath + + if (code !== 0) { + console.error("GslapperService: Process exited with code:", code, "for monitor:", monitorName, "runtime:", runTime + "ms") + + // Track failures + root.recentFailures[failureKey] = (root.recentFailures[failureKey] || 0) + 1 + + if (wasCrash) { + crashCount++ + console.warn("GslapperService: Detected crash #" + crashCount + " for monitor:", monitorName) + + // Auto-restart if under crash limit and failure count is reasonable + if (crashCount < maxCrashCount && + root.recentFailures[failureKey] < 3 && + activeProcesses[monitorName] === this) { + + console.log("GslapperService: Auto-restarting video for monitor:", monitorName, "after crash") + Qt.callLater(() => { + root.startVideo(monitorName, videoPath) + }) + } else { + console.error("GslapperService: Too many crashes/failures for:", failureKey, "- giving up") + } + } + } else { + console.log("GslapperService: Process exited normally for monitor:", monitorName) + // Clear failure count on successful run + if (runTime > minRunTime && root.recentFailures[failureKey]) { + delete root.recentFailures[failureKey] + } + } + + // Clean up from activeProcesses when process exits + if (activeProcesses[monitorName] === this) { + delete activeProcesses[monitorName] + } + + destroy() + } + + onRunningChanged: { + if (running) { + console.log("GslapperService: Process started for monitor:", monitorName) + } else { + console.log("GslapperService: Process stopped for monitor:", monitorName) + } + } + + stderr: StdioCollector { + onStreamFinished: { + if (text && text.trim()) { + console.warn("GslapperService stderr [" + monitorName + "]:", text.trim()) + } + } + } + + stdout: StdioCollector { + onStreamFinished: { + if (text && text.trim()) { + console.log("GslapperService stdout [" + monitorName + "]:", text.trim()) + } + } + } + } + } + + // Timer for delayed video startup + Timer { + id: delayTimer + property string monitor: "" + property string videoPath: "" + property string options: "" + interval: 500 + running: false + repeat: false + + onTriggered: { + startVideoDelayed(monitor, videoPath, options) + } + } + + // Clean up all processes when service is destroyed + Component.onDestruction: { + stopAllVideos() + } +} \ No newline at end of file diff --git a/Services/ThumbnailService.qml b/Services/ThumbnailService.qml new file mode 100644 index 00000000..a757deb6 --- /dev/null +++ b/Services/ThumbnailService.qml @@ -0,0 +1,545 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtCore +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + property string cacheDir: "" + property var thumbnailQueue: [] + property var thumbnailCache: ({}) + property var activeProcesses: ({}) + property bool processing: false + property int maxConcurrentProcesses: 1 // Severely limit concurrent processes + property int currentProcessCount: 0 + property bool preloadingEnabled: true // Re-enable background preloading + property bool startupComplete: false // Track if startup is complete + + // Safety and monitoring properties + property bool emergencyShutdown: false // Emergency kill switch + property int processFailureCount: 0 // Track failures + property int maxFailures: 5 // Max failures before shutdown + property int maxQueueTime: 30000 // Max time a process can run (30 seconds) + property var processStartTimes: ({}) // Track when processes started + property int totalProcessesSpawned: 0 // Track total processes + property int maxTotalProcesses: 50 // Emergency brake after 50 processes + + // Supported video and image extensions + property var videoExtensions: ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"] + property var imageExtensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "tga"] + + Component.onCompleted: { + initializeCacheDirectory() + + // Start background preloading after a delay to let system stabilize + startupDelayTimer.start() + + // Start safety monitoring + safetyMonitorTimer.start() + } + + // Emergency shutdown function + function emergencyStop() { + console.error("ThumbnailService: EMERGENCY SHUTDOWN ACTIVATED") + emergencyShutdown = true + preloadingEnabled = false + + // Stop all timers + if (startupDelayTimer) startupDelayTimer.running = false + if (processDelayTimer) processDelayTimer.running = false + if (safetyMonitorTimer) safetyMonitorTimer.running = false + + // Kill all active processes immediately + for (const hash in activeProcesses) { + if (activeProcesses[hash]) { + console.warn("ThumbnailService: Emergency killing process:", hash) + activeProcesses[hash].running = false + activeProcesses[hash].destroy() + } + } + + // Clear everything + activeProcesses = {} + thumbnailQueue = [] + processStartTimes = {} + currentProcessCount = 0 + } + + // Safety check function + function performSafetyCheck() { + if (emergencyShutdown) return + + // Check for too many failures + if (processFailureCount >= maxFailures) { + console.error("ThumbnailService: Too many failures (" + processFailureCount + "), emergency shutdown") + emergencyStop() + return + } + + // Check for too many total processes + if (totalProcessesSpawned >= maxTotalProcesses) { + console.error("ThumbnailService: Process limit reached (" + totalProcessesSpawned + "), emergency shutdown") + emergencyStop() + return + } + + // Check for hung processes + const currentTime = Date.now() + for (const hash in processStartTimes) { + const startTime = processStartTimes[hash] + if (currentTime - startTime > maxQueueTime) { + console.error("ThumbnailService: Process timeout detected for:", hash) + if (activeProcesses[hash]) { + activeProcesses[hash].running = false + activeProcesses[hash].destroy() + delete activeProcesses[hash] + } + delete processStartTimes[hash] + currentProcessCount = Math.max(0, currentProcessCount - 1) + processFailureCount++ + } + } + + // Check for queue size explosion + if (thumbnailQueue.length > 20) { + console.warn("ThumbnailService: Queue too large, emergency clear") + thumbnailQueue = [] + } + } + + function initializeCacheDirectory() { + // Create cache directory using Process + const homeDir = StandardPaths.writableLocation(StandardPaths.HomeLocation).toString() + // Remove file:// prefix if present + const cleanHomeDir = homeDir.replace(/^file:\/\//, '') + cacheDir = cleanHomeDir + "/.cache/dms/thumbnails" + + const mkdirProcess = processComponent.createObject(root) + mkdirProcess.command = ["mkdir", "-p", cacheDir] + mkdirProcess.onExited.connect(function(code) { + if (code === 0) { + console.log("ThumbnailService: Cache directory initialized:", cacheDir) + } else { + console.error("ThumbnailService: Failed to create cache directory") + } + mkdirProcess.destroy() + }) + mkdirProcess.running = true + } + + function isVideoFile(filePath) { + const extension = filePath.split('.').pop().toLowerCase() + return videoExtensions.includes(extension) + } + + function isImageFile(filePath) { + const extension = filePath.split('.').pop().toLowerCase() + return imageExtensions.includes(extension) + } + + function isSupportedFile(filePath) { + return isVideoFile(filePath) || isImageFile(filePath) + } + + function getVideoHash(videoPath) { + return Qt.md5(videoPath) + } + + function getThumbnailPath(videoPath) { + const hash = getVideoHash(videoPath) + return cacheDir + "/" + hash + ".jpg" + } + + function getThumbnail(filePath) { + if (!isSupportedFile(filePath)) { + return "" + } + + const hash = getVideoHash(filePath) + const thumbnailPath = getThumbnailPath(filePath) + + // Return cached if available in memory + if (thumbnailCache[hash]) { + return thumbnailCache[hash] + } + + // For images, we can return the original file path if no thumbnail exists yet + if (isImageFile(filePath)) { + // Still generate a thumbnail for consistency and proper sizing + checkThumbnailExists(filePath, thumbnailPath, hash) + return "file://" + filePath // Return original image while thumbnail generates + } + + // For videos, check if thumbnail exists on disk and validate it + checkThumbnailExists(filePath, thumbnailPath, hash) + return "" // Return empty while processing/checking + } + + function checkThumbnailExists(videoPath, thumbnailPath, hash) { + const checkProcess = processComponent.createObject(root, { + videoPath: videoPath, + outputPath: thumbnailPath, + hash: hash + }) + checkProcess.command = ["test", "-f", thumbnailPath, "-a", "-s", thumbnailPath] + + checkProcess.onExited.connect(function(code) { + if (code === 0) { + // File exists and has size > 0, validate it's a proper image + validateThumbnail(checkProcess.videoPath, checkProcess.outputPath, checkProcess.hash) + } else { + // File doesn't exist or is empty, queue for generation + queueThumbnail(checkProcess.videoPath, checkProcess.outputPath, checkProcess.hash) + } + checkProcess.destroy() + }) + checkProcess.running = true + } + + function validateThumbnail(videoPath, thumbnailPath, hash) { + const validateProcess = processComponent.createObject(root, { + videoPath: videoPath, + outputPath: thumbnailPath, + hash: hash + }) + validateProcess.command = ["file", thumbnailPath] + + validateProcess.onExited.connect(function(code) { + if (code === 0) { + // Check if output contains "JPEG" to ensure it's a valid image + const output = validateProcess.stdout.text || "" + if (output.includes("JPEG")) { + // Valid thumbnail, add to cache + root.thumbnailCache[validateProcess.hash] = "file://" + validateProcess.outputPath + console.log("ThumbnailService: Using cached thumbnail for:", validateProcess.videoPath) + } else { + // Invalid thumbnail, regenerate + console.warn("ThumbnailService: Invalid thumbnail detected, regenerating:", validateProcess.outputPath) + queueThumbnail(validateProcess.videoPath, validateProcess.outputPath, validateProcess.hash) + } + } else { + // Validation failed, regenerate + queueThumbnail(validateProcess.videoPath, validateProcess.outputPath, validateProcess.hash) + } + validateProcess.destroy() + }) + validateProcess.running = true + } + + function queueThumbnail(videoPath, thumbnailPath, hash) { + // Check if already queued or processing + const existing = thumbnailQueue.find(item => item.hash === hash) + if (existing || activeProcesses[hash]) { + return + } + + // Limit queue size to prevent resource exhaustion + limitQueueSize() + + thumbnailQueue.push({ + video: videoPath, + output: thumbnailPath, + hash: hash + }) + + console.log("ThumbnailService: Queued thumbnail generation for:", videoPath) + processQueue() + } + + function processQueue() { + // Safety checks before processing + if (emergencyShutdown) { + console.warn("ThumbnailService: Processing blocked - emergency shutdown active") + return + } + + if (totalProcessesSpawned >= maxTotalProcesses) { + console.warn("ThumbnailService: Process limit reached, stopping generation") + emergencyStop() + return + } + + // Conservative processing - only process when we have available slots + if (currentProcessCount >= maxConcurrentProcesses || thumbnailQueue.length === 0) { + return + } + + const item = thumbnailQueue.shift() + generateThumbnail(item) + } + + // New function to limit queue size + function limitQueueSize() { + const maxQueueSize = startupComplete ? 5 : 10 // Smaller queue during background processing + if (thumbnailQueue.length > maxQueueSize) { + console.warn("ThumbnailService: Queue too large, clearing excess items") + thumbnailQueue = thumbnailQueue.slice(0, maxQueueSize) + } + } + + function generateThumbnail(item) { + // Final safety check + if (emergencyShutdown || totalProcessesSpawned >= maxTotalProcesses) { + console.warn("ThumbnailService: Thumbnail generation blocked by safety limits") + return + } + + currentProcessCount++ + totalProcessesSpawned++ + const process = processComponent.createObject(root) + process.videoPath = item.video + process.outputPath = item.output + process.hash = item.hash + + // Record process start time for timeout monitoring + processStartTimes[item.hash] = Date.now() + activeProcesses[item.hash] = process + + console.log("ThumbnailService: Starting process", totalProcessesSpawned, "for:", item.video) + + // Use different commands for images vs videos + if (isImageFile(item.video)) { + // For images, use ImageMagick for consistent thumbnail sizing + process.command = [ + "magick", + item.video, + "-thumbnail", "200x150^", + "-gravity", "center", + "-extent", "200x150", + "-quality", "85", + item.output + ] + } else { + // For videos, use ffmpeg + process.command = [ + "ffmpeg", "-y", + "-i", item.video, + "-ss", "00:00:03", // Seek to 3 seconds + "-vframes", "1", // Extract one frame + "-vf", "scale=200:150:force_original_aspect_ratio=decrease,pad=200:150:(ow-iw)/2:(oh-ih)/2:black", + "-q:v", "3", // Good quality + "-f", "image2", // Force image format + item.output + ] + } + + process.onExited.connect(function(code) { + currentProcessCount = Math.max(0, currentProcessCount - 1) // Ensure it doesn't go negative + delete activeProcesses[process.hash] + delete processStartTimes[process.hash] // Clean up timing data + + if (code === 0) { + // Successful generation + console.log("ThumbnailService: Successfully completed thumbnail for:", process.videoPath) + verifyGeneratedThumbnail(process.videoPath, process.outputPath, process.hash) + } else { + // Failed generation - increment failure counter + processFailureCount++ + console.error("ThumbnailService: Failed to generate thumbnail for:", process.videoPath, "exit code:", code, "failure count:", processFailureCount) + + // Log stderr if available + if (process.stderr && process.stderr.text) { + console.error("ThumbnailService: Process error:", process.stderr.text) + } + + // Check if we need emergency shutdown due to failures + if (processFailureCount >= maxFailures) { + console.error("ThumbnailService: Too many failures, triggering emergency shutdown") + emergencyStop() + return + } + } + + // Ensure process is properly cleaned up + if (process) { + process.destroy() + process = null + } + + // Only continue processing if not in emergency shutdown + if (!emergencyShutdown) { + processDelayTimer.start() + } + }) + + console.log("ThumbnailService: Generating thumbnail for:", item.video) + process.running = true + } + + function verifyGeneratedThumbnail(videoPath, thumbnailPath, hash) { + const verifyProcess = processComponent.createObject(root, { + videoPath: videoPath, + outputPath: thumbnailPath, + hash: hash + }) + verifyProcess.command = ["test", "-s", thumbnailPath] + + verifyProcess.onExited.connect(function(code) { + if (code === 0) { + // File exists and has size, add to cache + root.thumbnailCache[verifyProcess.hash] = "file://" + verifyProcess.outputPath + console.log("ThumbnailService: Successfully generated thumbnail for:", verifyProcess.videoPath) + } else { + console.error("ThumbnailService: Generated thumbnail is empty or missing:", verifyProcess.outputPath) + } + verifyProcess.destroy() + }) + verifyProcess.running = true + } + + function preloadThumbnails(directoryPath) { + console.log("ThumbnailService: Preloading thumbnails for directory:", directoryPath) + + const lsProcess = processComponent.createObject(root) + // Use a simple approach with multiple -name patterns + let cmd = "find \"" + directoryPath + "\" -type f \\( " + const allExtensions = videoExtensions.concat(imageExtensions) + + for (let i = 0; i < allExtensions.length; i++) { + if (i > 0) cmd += " -o " + cmd += "-iname '*." + allExtensions[i] + "'" + } + cmd += " \\)" + + lsProcess.command = ["bash", "-c", cmd] + + lsProcess.onExited.connect(function(code) { + if (code === 0 && lsProcess.stdout && lsProcess.stdout.text) { + const files = lsProcess.stdout.text.trim().split('\n').filter(f => f.length > 0) + const videoFiles = files.filter(f => isVideoFile(f)) + const imageFiles = files.filter(f => isImageFile(f)) + + console.log("ThumbnailService: Found", videoFiles.length, "video files and", imageFiles.length, "image files to preload") + + // Queue all files for thumbnail generation + files.forEach(filePath => { + getThumbnail(filePath) // This will queue them for generation + }) + } + lsProcess.destroy() + }) + lsProcess.running = true + } + + function startBackgroundProcessing() { + if (!preloadingEnabled || !startupComplete) { + return + } + + // Start with just the main wallpaper directory + const homeDir = StandardPaths.writableLocation(StandardPaths.HomeLocation).toString() + const cleanHomeDir = homeDir.replace(/^file:\/\//, '') + const mainWallpaperDir = cleanHomeDir + "/Pictures/wallpapers" + + console.log("ThumbnailService: Starting slow background processing for:", mainWallpaperDir) + preloadThumbnails(mainWallpaperDir) + } + + function clearCache() { + console.log("ThumbnailService: Clearing thumbnail cache") + thumbnailCache = {} + + const rmProcess = processComponent.createObject(root) + rmProcess.command = ["rm", "-rf", cacheDir + "/*"] + rmProcess.onExited.connect(function(code) { + console.log("ThumbnailService: Cache cleared, exit code:", code) + rmProcess.destroy() + }) + rmProcess.running = true + } + + function getServiceStatus() { + return { + cacheDir: cacheDir, + queueLength: thumbnailQueue.length, + cachedThumbnails: Object.keys(thumbnailCache).length, + activeProcesses: currentProcessCount, + maxProcesses: maxConcurrentProcesses + } + } + + Component { + id: processComponent + + Process { + property string videoPath: "" + property string outputPath: "" + property string hash: "" + + running: false + + stdout: StdioCollector { + id: stdoutCollector + property string text: "" + onStreamFinished: { + text = data + } + } + + stderr: StdioCollector { + id: stderrCollector + property string text: "" + onStreamFinished: { + text = data + } + } + } + } + + // Timer for delaying between thumbnail generations + Timer { + id: processDelayTimer + interval: 2000 // 2 second delay between processes (much slower) + running: false + onTriggered: processQueue() + } + + // Timer to delay startup processing + Timer { + id: startupDelayTimer + interval: 5000 // Wait 5 seconds after startup before beginning + running: false + onTriggered: { + console.log("ThumbnailService: Starting background thumbnail generation") + startupComplete = true + startBackgroundProcessing() + } + } + + // Safety monitoring timer + Timer { + id: safetyMonitorTimer + interval: 10000 // Check every 10 seconds + running: false + repeat: true + onTriggered: { + performSafetyCheck() + + // Log status every few checks + if ((Date.now() / 10000) % 6 === 0) { // Every 60 seconds + console.log("ThumbnailService: Status - Active processes:", currentProcessCount, + "Queue size:", thumbnailQueue.length, + "Total spawned:", totalProcessesSpawned, + "Failures:", processFailureCount) + } + } + } + + // Clean up when service is destroyed + Component.onDestruction: { + console.log("ThumbnailService: Service destruction - cleaning up") + + // Trigger emergency shutdown to ensure clean state + emergencyStop() + + // Additional cleanup for safety + processStartTimes = {} + processFailureCount = 0 + totalProcessesSpawned = 0 + } +} \ No newline at end of file diff --git a/Services/VideoThumbnailService.qml b/Services/VideoThumbnailService.qml new file mode 100644 index 00000000..c6c115d9 --- /dev/null +++ b/Services/VideoThumbnailService.qml @@ -0,0 +1,180 @@ +pragma Singleton + +import QtCore +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + property bool ffmpegAvailable: false + property var generatingThumbnails: ({}) // Track which thumbnails are being generated + property string thumbnailDir: { + const cacheUrl = StandardPaths.writableLocation(StandardPaths.CacheLocation) + const cachePath = cacheUrl.toString().replace("file://", "") + return cachePath + "/video-thumbnails" + } + + signal thumbnailReady(string videoPath, string thumbnailPath) + + Component.onCompleted: { + checkFfmpegAvailability() + createThumbnailDirectory() + } + + function checkFfmpegAvailability() { + ffmpegCheck.running = true + } + + function createThumbnailDirectory() { + createDirProcess.command = ["mkdir", "-p", thumbnailDir] + createDirProcess.running = true + } + + function getThumbnailPath(videoPath) { + // Create a unique filename based on video path and modification time + const fileName = videoPath.split('/').pop() + const baseName = fileName.substring(0, fileName.lastIndexOf('.')) || fileName + const hash = Qt.md5(videoPath) + return thumbnailDir + "/" + baseName + "_" + hash + ".jpg" + } + + function hasThumbnail(videoPath) { + const thumbnailPath = getThumbnailPath(videoPath) + thumbnailExists.path = thumbnailPath + return thumbnailExists.exists + } + + function generateThumbnail(videoPath) { + if (!ffmpegAvailable) { + console.warn("VideoThumbnailService: ffmpeg not available, cannot generate thumbnail") + return "" + } + + const thumbnailPath = getThumbnailPath(videoPath) + + // Check if already generating + if (generatingThumbnails[videoPath]) { + console.log("VideoThumbnailService: Already generating thumbnail for", videoPath) + return thumbnailPath + } + + // Skip existence check for now - just generate if not already generating + console.log("VideoThumbnailService: Generating thumbnail for", videoPath) + generatingThumbnails[videoPath] = true + + const process = processComponent.createObject(root) + if (!process) { + console.error("VideoThumbnailService: Failed to create process") + delete generatingThumbnails[videoPath] + return "" + } + + // Generate thumbnail - simpler approach + process.command = [ + "ffmpeg", "-y", "-loglevel", "error", + "-i", videoPath, + "-ss", "00:00:01", + "-vframes", "1", + "-vf", "scale=320:180", + "-q:v", "3", + thumbnailPath + ] + process.videoPath = videoPath + process.thumbnailPath = thumbnailPath + process.running = true + + return thumbnailPath + } + + function getThumbnailUrl(videoPath) { + const thumbnailPath = generateThumbnail(videoPath) + return thumbnailPath ? "file://" + thumbnailPath : "" + } + + function clearThumbnailCache() { + console.log("VideoThumbnailService: Clearing thumbnail cache") + clearCacheProcess.command = ["rm", "-rf", thumbnailDir] + clearCacheProcess.running = true + Qt.callLater(() => { + createThumbnailDirectory() + }) + } + + Process { + id: ffmpegCheck + command: ["which", "ffmpeg"] + running: false + + onExited: code => { + ffmpegAvailable = (code === 0) + if (!ffmpegAvailable) { + console.warn("VideoThumbnailService: ffmpeg not found - video thumbnail generation disabled") + } else { + console.log("VideoThumbnailService: ffmpeg found - video thumbnail generation enabled") + } + } + } + + Process { + id: createDirProcess + running: false + + onExited: code => { + if (code === 0) { + console.log("VideoThumbnailService: Thumbnail directory created:", thumbnailDir) + } else { + console.error("VideoThumbnailService: Failed to create thumbnail directory") + } + } + } + + Process { + id: clearCacheProcess + running: false + + onExited: code => { + console.log("VideoThumbnailService: Cache cleared, exit code:", code) + } + } + + FileView { + id: thumbnailExists + path: "" + // Used to check if thumbnail files exist + } + + Component { + id: processComponent + + Process { + property string videoPath: "" + property string thumbnailPath: "" + + running: false + + onExited: code => { + const success = (code === 0) + console.log("VideoThumbnailService: Thumbnail generation", success ? "succeeded" : "failed", "for", videoPath, "exit code:", code) + + if (success) { + thumbnailReady(videoPath, thumbnailPath) + } + + // Clean up tracking + delete root.generatingThumbnails[videoPath] + destroy() + } + + stderr: StdioCollector { + onStreamFinished: { + if (text && text.trim()) { + console.warn("VideoThumbnailService ffmpeg stderr:", text.trim()) + } + } + } + } + } +} \ No newline at end of file