diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 944470588..38bffeaf3 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -29,9 +29,6 @@ Singleton { readonly property string shellName: "noctalia" readonly property string configDir: Quickshell.env("NOCTALIA_CONFIG_DIR") || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/" + shellName + "/" readonly property string cacheDir: Quickshell.env("NOCTALIA_CACHE_DIR") || (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/" + shellName + "/" - readonly property string cacheDirImages: cacheDir + "images/" - readonly property string cacheDirImagesWallpapers: cacheDir + "images/wallpapers/" - readonly property string cacheDirImagesNotifications: cacheDir + "images/notifications/" readonly property string settingsFile: Quickshell.env("NOCTALIA_SETTINGS_FILE") || (configDir + "settings.json") readonly property string defaultLocation: "Tokyo" readonly property string defaultAvatar: Quickshell.env("HOME") + "/.face" @@ -50,9 +47,6 @@ Singleton { Quickshell.execDetached(["mkdir", "-p", configDir]); Quickshell.execDetached(["mkdir", "-p", cacheDir]); - Quickshell.execDetached(["mkdir", "-p", cacheDirImagesWallpapers]); - Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications]); - // Ensure PAM config file exists in configDir (create once, never override) ensurePamConfig(); diff --git a/Modules/Background/Background.qml b/Modules/Background/Background.qml index fe77e7c47..1a9eb90db 100644 --- a/Modules/Background/Background.qml +++ b/Modules/Background/Background.qml @@ -313,7 +313,7 @@ Variants { Qt.callLater(setWallpaperInitial); return; } - if (!WallpaperCacheService || !WallpaperCacheService.initialized) { + if (!ImageCacheService || !ImageCacheService.initialized) { Qt.callLater(setWallpaperInitial); return; } @@ -323,7 +323,7 @@ Variants { const targetWidth = Math.round(modelData.width * compositorScale); const targetHeight = Math.round(modelData.height * compositorScale); - WallpaperCacheService.getPreprocessed(wallpaperPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) { + ImageCacheService.getFullscreen(wallpaperPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) { if (success) { futureWallpaper = cachedPath; } else { @@ -340,7 +340,7 @@ Variants { const targetWidth = Math.round(modelData.width * compositorScale); const targetHeight = Math.round(modelData.height * compositorScale); - WallpaperCacheService.getPreprocessed(originalPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) { + ImageCacheService.getFullscreen(originalPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) { if (success) { futureWallpaper = cachedPath; } else { diff --git a/Modules/Background/Overview.qml b/Modules/Background/Overview.qml index d69d6da18..0f9c88661 100644 --- a/Modules/Background/Overview.qml +++ b/Modules/Background/Overview.qml @@ -58,7 +58,7 @@ Loader { if (!wallpaper) return; // Use 1280x720 for overview since it's heavily blurred anyway - WallpaperCacheService.getPreprocessed(wallpaper, modelData.name, 1280, 720, function (path, success) { + ImageCacheService.getFullscreen(wallpaper, modelData.name, 1280, 720, function (path, success) { cachedWallpaper = path; }); } diff --git a/Modules/LockScreen/LockScreen.qml b/Modules/LockScreen/LockScreen.qml index 18cf6b295..bbc396ae5 100644 --- a/Modules/LockScreen/LockScreen.qml +++ b/Modules/LockScreen/LockScreen.qml @@ -161,14 +161,15 @@ Loader { return; } - if (!WallpaperCacheService || !WallpaperCacheService.initialized) { - // Fallback to original if services not ready - resolvedWallpaperPath = WallpaperService.getWallpaper(screen.name) || ""; + const originalPath = WallpaperService.getWallpaper(screen.name) || ""; + if (originalPath === "") { + resolvedWallpaperPath = ""; return; } - resolvedWallpaperPath = WallpaperService.getWallpaper(screen.name) || ""; - if (resolvedWallpaperPath === "") { + if (!ImageCacheService || !ImageCacheService.initialized) { + // Fallback to original if services not ready + resolvedWallpaperPath = originalPath; return; } @@ -179,9 +180,14 @@ Loader { return; } - WallpaperCacheService.getPreprocessed(resolvedWallpaperPath, screen.name, targetWidth, targetHeight, function (cachedPath, success) { + // Don't set resolvedWallpaperPath until cache is ready + // This prevents loading the original huge image + ImageCacheService.getFullscreen(originalPath, screen.name, targetWidth, targetHeight, function (cachedPath, success) { if (success) { resolvedWallpaperPath = cachedPath; + } else { + // Only fall back to original if caching failed + resolvedWallpaperPath = originalPath; } }); diff --git a/Modules/Panels/Settings/Bar/WidgetSettings/ControlCenterSettings.qml b/Modules/Panels/Settings/Bar/WidgetSettings/ControlCenterSettings.qml index 5fe9009ee..c23cedf1f 100644 --- a/Modules/Panels/Settings/Bar/WidgetSettings/ControlCenterSettings.qml +++ b/Modules/Panels/Settings/Bar/WidgetSettings/ControlCenterSettings.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import qs.Commons +import qs.Services.UI import qs.Widgets ColumnLayout { @@ -133,7 +134,7 @@ ColumnLayout { id: imagePicker title: I18n.tr("bar.widget-settings.control-center.select-custom-icon") selectionMode: "files" - nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"] + nameFilters: ImageCacheService.basicImageFilters initialPath: Quickshell.env("HOME") onAccepted: paths => { if (paths.length > 0) { diff --git a/Modules/Panels/Settings/Tabs/AboutTab.qml b/Modules/Panels/Settings/Tabs/AboutTab.qml index 51e907de5..1a9c72541 100644 --- a/Modules/Panels/Settings/Tabs/AboutTab.qml +++ b/Modules/Panels/Settings/Tabs/AboutTab.qml @@ -16,8 +16,16 @@ ColumnLayout { property string currentVersion: UpdateService.currentVersion property var contributors: GitHubService.contributors property string commitInfo: "" + property int avatarCacheVersion: 0 readonly property int topContributorsCount: 20 + + Connections { + target: GitHubService + function onCachedAvatarsChanged() { + root.avatarCacheVersion++; + } + } readonly property bool isGitVersion: root.currentVersion.endsWith("-git") spacing: Style.marginL @@ -334,9 +342,11 @@ ColumnLayout { Image { anchors.fill: parent source: { + // Depend on avatarCacheVersion to trigger re-evaluation + var _ = root.avatarCacheVersion; // Try cached circular version first var username = root.contributors[index].login; - var cached = GitHubService.cachedCircularAvatars[username]; + var cached = GitHubService.getAvatarPath(username); if (cached) { wrapper.isRounded = true; return cached; diff --git a/Modules/Panels/Settings/Tabs/GeneralTab.qml b/Modules/Panels/Settings/Tabs/GeneralTab.qml index d0311eeb8..cc928f725 100644 --- a/Modules/Panels/Settings/Tabs/GeneralTab.qml +++ b/Modules/Panels/Settings/Tabs/GeneralTab.qml @@ -53,7 +53,7 @@ ColumnLayout { title: I18n.tr("settings.general.profile.select-avatar") selectionMode: "files" initialPath: Settings.preprocessPath(Settings.data.general.avatarImage).substr(0, Settings.preprocessPath(Settings.data.general.avatarImage).lastIndexOf("/")) || Quickshell.env("HOME") - nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"] + nameFilters: ImageCacheService.basicImageFilters onAccepted: paths => { if (paths.length > 0) { Settings.data.general.avatarImage = paths[0]; diff --git a/Modules/Panels/Wallpaper/WallpaperPanel.qml b/Modules/Panels/Wallpaper/WallpaperPanel.qml index 62c2c981c..98efa0017 100644 --- a/Modules/Panels/Wallpaper/WallpaperPanel.qml +++ b/Modules/Panels/Wallpaper/WallpaperPanel.qml @@ -767,7 +767,6 @@ SmartPanel { NImageCached { id: img imagePath: wallpaperPath - cacheFolder: Settings.cacheDirImagesWallpapers anchors.fill: parent } diff --git a/Services/Noctalia/GitHubService.qml b/Services/Noctalia/GitHubService.qml index ec4048d7e..8d214b240 100644 --- a/Services/Noctalia/GitHubService.qml +++ b/Services/Noctalia/GitHubService.qml @@ -4,6 +4,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Services.UI // GitHub API logic for contributors Singleton { @@ -18,17 +19,10 @@ Singleton { property string latestVersion: I18n.tr("system.unknown-version") property var contributors: [] - // Avatar caching properties - property var cachedCircularAvatars: ({}) // username → file:// path - property var cacheMetadata: ({}) // Loaded from metadata.json - property var avatarQueue: [] - property bool isProcessingAvatars: false - property bool metadataLoaded: false + // Avatar caching properties (simplified - uses ImageCacheService) + property var cachedAvatars: ({}) // username → file:// path property bool avatarsCached: false // Track if we've already processed avatars - readonly property string avatarCacheDir: Settings.cacheDirImages + "contributors/" - readonly property string metadataPath: avatarCacheDir + "metadata.json" - property bool isInitialized: false FileView { @@ -37,7 +31,7 @@ Singleton { printErrors: false watchChanges: false // Disable to prevent reload on our own writes Component.onCompleted: { - loadCacheMetadata(); + // Data loading handled by FileView onLoaded } onLoaded: { if (!root.isInitialized) { @@ -164,280 +158,49 @@ Singleton { } // -------------------------------- - // Avatar Caching Functions + // Avatar Caching Functions (simplified - uses ImageCacheService) // -------------------------------- - function loadCacheMetadata() { - var loadProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - command: ["cat", "${metadataPath}"] - } - `, root, "LoadMetadata"); - - loadProcess.stdout = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - StdioCollector {} - `, loadProcess, "StdoutCollector"); - - loadProcess.stdout.onStreamFinished.connect(function () { - try { - var text = loadProcess.stdout.text; - if (text && text.trim()) { - cacheMetadata = JSON.parse(text); - Logger.d("GitHubService", "Loaded cache metadata:", Object.keys(cacheMetadata).length, "entries"); - - // Populate cachedCircularAvatars from metadata - for (var username in cacheMetadata) { - var entry = cacheMetadata[username]; - cachedCircularAvatars[username] = "file://" + entry.cached_path; - } - - metadataLoaded = true; - Logger.d("GitHubService", "Cache metadata loaded successfully"); - } else { - Logger.d("GitHubService", "No existing cache metadata found (empty response)"); - cacheMetadata = {}; - metadataLoaded = true; - } - } catch (e) { - Logger.w("GitHubService", "Failed to parse cache metadata:", e); - cacheMetadata = {}; - metadataLoaded = true; - } - loadProcess.destroy(); - }); - - loadProcess.exited.connect(function (exitCode) { - if (exitCode !== 0) { - // File doesn't exist, initialize empty - cacheMetadata = {}; - metadataLoaded = true; - Logger.d("GitHubService", "Initializing empty cache metadata"); - } - }); - - loadProcess.running = true; - } - - function saveCacheMetadata() { - Quickshell.execDetached(["mkdir", "-p", avatarCacheDir]); - - var jsonContent = JSON.stringify(cacheMetadata, null, 2); - - // Use printf with base64 encoding to safely handle special characters - var base64Content = Qt.btoa(jsonContent); // Base64 encode - - var saveProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - command: ["sh", "-c", "echo '${base64Content}' | base64 -d > '${metadataPath}'"] - } - `, root, "SaveMetadata_" + Date.now()); - - saveProcess.exited.connect(function (exitCode) { - if (exitCode === 0) { - Logger.d("GitHubService", "Saved cache metadata"); - } else { - Logger.e("GitHubService", "Failed to save cache metadata, exit code:", exitCode); - } - saveProcess.destroy(); - }); - - saveProcess.running = true; + function getAvatarPath(username) { + return cachedAvatars[username] || ""; } function cacheTopContributorAvatars() { if (contributors.length === 0) return; - // Mark that we've processed avatars for this contributor set avatarsCached = true; - Quickshell.execDetached(["mkdir", "-p", avatarCacheDir]); - - // Build queue of avatars that need processing - avatarQueue = []; - var currentTop20 = {}; - for (var i = 0; i < Math.min(contributors.length, 20); i++) { var contributor = contributors[i]; var username = contributor.login; var avatarUrl = contributor.avatar_url; - var circularPath = avatarCacheDir + username + "_circular.png"; - currentTop20[username] = true; - - // Check if we need to process this avatar - var needsProcessing = false; - var reason = ""; - - if (!cacheMetadata[username]) { - // New user in top 20 - needsProcessing = true; - reason = "new user"; - } else if (cacheMetadata[username].avatar_url !== avatarUrl) { - // Avatar URL changed (user updated their GitHub avatar) - needsProcessing = true; - reason = "avatar URL changed"; - } else { - // Already cached - add to map - cachedCircularAvatars[username] = "file://" + circularPath; - } - - if (needsProcessing) { - Logger.d("GitHubService", "Queueing avatar for", username, "-", reason); - avatarQueue.push({ - username: username, - avatarUrl: avatarUrl, - circularPath: circularPath - }); - } + // Use closure to capture username + (function(uname, url) { + ImageCacheService.getCircularAvatar(url, uname, function(cachedPath, success) { + if (success) { + cachedAvatars[uname] = "file://" + cachedPath; + cachedAvatarsChanged(); + } + }); + })(username, avatarUrl); } - - // Cleanup: Remove metadata for users no longer in top 20 - var removedUsers = []; - for (var cachedUsername in cacheMetadata) { - if (!currentTop20[cachedUsername]) { - removedUsers.push(cachedUsername); - - // Delete cached circular file - var pathToDelete = cacheMetadata[cachedUsername].cached_path; - Quickshell.execDetached(["rm", "-f", pathToDelete]); - - delete cacheMetadata[cachedUsername]; - delete cachedCircularAvatars[cachedUsername]; - } - } - - if (removedUsers.length > 0) { - Logger.d("GitHubService", "Cleaned up avatars for users no longer in top 20:", removedUsers.join(", ")); - saveCacheMetadata(); - } - - // Start processing queue - if (avatarQueue.length > 0) { - Logger.i("GitHubService", "Processing", avatarQueue.length, "avatar(s)"); - processNextAvatar(); - } else { - Logger.d("GitHubService", "All avatars already cached"); - cachedCircularAvatarsChanged(); // Notify AboutTab - } - } - - function processNextAvatar() { - if (avatarQueue.length === 0 || isProcessingAvatars) - return; - - isProcessingAvatars = true; - var item = avatarQueue.shift(); - - Logger.d("GitHubService", "Downloading avatar for", item.username); - - // Download original avatar - var tempPath = avatarCacheDir + item.username + "_temp.png"; - downloadAvatar(item.avatarUrl, tempPath, function (success) { - if (success) { - // Render circular version - renderCircularAvatar(tempPath, item.circularPath, item.username, item.avatarUrl); - } else { - Logger.e("GitHubService", "Failed to download avatar for", item.username); - isProcessingAvatars = false; - processNextAvatar(); - } - }); - } - - function downloadAvatar(url, destPath, callback) { - var downloadCmd = `curl -L -s -o '${destPath}' '${url}' || wget -q -O '${destPath}' '${url}'`; - - var downloadProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - command: ["sh", "-c", "${downloadCmd}"] - } - `, root, "Download_" + Date.now()); - - downloadProcess.exited.connect(function (exitCode) { - callback(exitCode === 0); - downloadProcess.destroy(); - }); - - downloadProcess.running = true; - } - - function renderCircularAvatar(inputPath, outputPath, username, avatarUrl) { - Logger.d("GitHubService", "Rendering circular avatar for", username); - - // Use ImageMagick to create a circular avatar with proper alpha transparency - var convertProcess = Qt.createQmlObject(` - import QtQuick - import Quickshell.Io - Process { - command: ["magick", "${inputPath}", "-resize", "256x256^", "-gravity", "center", "-extent", "256x256", "-alpha", "set", "(", "+clone", "-channel", "A", "-evaluate", "set", "0", "+channel", "-fill", "white", "-draw", "circle 128,128 128,0", ")", "-compose", "DstIn", "-composite", "${outputPath}"] - } - `, root, "Convert_" + Date.now()); - - convertProcess.exited.connect(function (exitCode) { - var success = exitCode === 0; - - if (success) { - // Update cache metadata - cacheMetadata[username] = { - avatar_url: avatarUrl, - cached_path: outputPath, - cached_at: Date.now() - }; - - cachedCircularAvatars[username] = "file://" + outputPath; - cachedCircularAvatarsChanged(); - - saveCacheMetadata(); - - Logger.d("GitHubService", "Cached circular avatar for", username); - } else { - Logger.e("GitHubService", "Failed to render circular avatar for", username); - } - - // Clean up temp file - Quickshell.execDetached(["rm", "-f", inputPath]); - - // Process next in queue - isProcessingAvatars = false; - processNextAvatar(); - - convertProcess.destroy(); - }); - - convertProcess.running = true; } // -------------------------------- // Hook into contributors change - only process once onContributorsChanged: { - if (contributors.length > 0 && !avatarsCached) { - // Wait for metadata to load before processing - if (metadataLoaded) { - Qt.callLater(cacheTopContributorAvatars); - } else { - // Metadata not loaded yet, wait for it - metadataLoadedWatcher.start(); - } + if (contributors.length > 0 && !avatarsCached && ImageCacheService.initialized) { + Qt.callLater(cacheTopContributorAvatars); } } - // Wait for metadata to be loaded before caching avatars - Timer { - id: metadataLoadedWatcher - interval: 100 - repeat: true - onTriggered: { - if (metadataLoaded && contributors.length > 0 && !avatarsCached) { - stop(); + // Also watch for ImageCacheService to become initialized + Connections { + target: ImageCacheService + function onInitializedChanged() { + if (ImageCacheService.initialized && contributors.length > 0 && !avatarsCached) { Qt.callLater(cacheTopContributorAvatars); } } diff --git a/Services/System/NotificationService.qml b/Services/System/NotificationService.qml index 03aff3b8b..845e05823 100644 --- a/Services/System/NotificationService.qml +++ b/Services/System/NotificationService.qml @@ -32,60 +32,11 @@ Singleton { // Internal state property var activeNotifications: ({}) // Maps internal ID to {notification, watcher, metadata} property var quickshellIdToInternalId: ({}) - property var imageQueue: [] // Rate limiting for notification sounds (minimum 100ms between sounds) property var lastSoundTime: 0 readonly property int minSoundInterval: 100 - PanelWindow { - implicitHeight: 0 - implicitWidth: 0 - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "noctalia-notification-image-renderer" - color: Color.transparent - mask: Region {} - - Image { - id: cacher - width: 64 - height: 64 - visible: true - cache: false - asynchronous: true - mipmap: true - antialiasing: true - - onStatusChanged: { - if (imageQueue.length === 0) { - return; - } - - const req = imageQueue[0]; - - if (status === Image.Ready) { - Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications]); - grabToImage(result => { - if (result.saveToFile(req.dest)) - updateImagePath(req.imageId, req.dest); - processNextImage(); - }); - } else if (status === Image.Error) { - processNextImage(); - } - } - - function processNextImage() { - imageQueue.shift(); - if (imageQueue.length > 0) { - source = imageQueue[0].src; - } else { - source = ""; - } - } - } - } - // Notification server property var notificationServerLoader: null @@ -431,7 +382,7 @@ Singleton { const image = n.image || getIcon(n.appIcon); const imageId = generateImageId(n, image); - queueImage(image, imageId); + queueImage(image, n.appName || "", n.summary || "", id); return { "id": id, @@ -443,7 +394,7 @@ Singleton { "timestamp": time, "progress": 1.0, "originalImage": image, - "cachedImage": imageId ? (Settings.cacheDirImagesNotifications + imageId + ".png") : image, + "cachedImage": image, // Start with original, update when cached "actionsJson": JSON.stringify((n.actions || []).map(a => ({ "text": a.text || "Action", "identifier": a.identifier || "" @@ -545,35 +496,26 @@ Singleton { } // Image handling - function queueImage(path, imageId) { - if (!path || !path.startsWith("image://") || !imageId) + function queueImage(path, appName, summary, notificationId) { + if (!path || !path.startsWith("image://") || !notificationId) return; - const dest = Settings.cacheDirImagesNotifications + imageId + ".png"; - for (const req of imageQueue) { - if (req.imageId === imageId) - return; - } - - imageQueue.push({ - "src": path, - "dest": dest, - "imageId": imageId - }); - - if (imageQueue.length === 1) - cacher.source = path; + ImageCacheService.getNotificationIcon(path, appName, summary, function(cachedPath, success) { + if (success && cachedPath) { + updateImagePath(notificationId, "file://" + cachedPath); + } + }); } - function updateImagePath(id, path) { - updateModel(activeList, id, "cachedImage", path); - updateModel(historyList, id, "cachedImage", path); + function updateImagePath(notificationId, path) { + updateModel(activeList, notificationId, "cachedImage", path); + updateModel(historyList, notificationId, "cachedImage", path); saveHistory(); } - function updateModel(model, id, prop, value) { + function updateModel(model, notificationId, prop, value) { for (var i = 0; i < model.count; i++) { - if (model.get(i).id === id) { + if (model.get(i).id === notificationId) { model.setProperty(i, prop, value); break; } @@ -587,8 +529,9 @@ Singleton { while (historyList.count > maxHistory) { const old = historyList.get(historyList.count - 1); // Only delete cached images that are in our cache directory - if (old.cachedImage && old.cachedImage.startsWith(Settings.cacheDirImagesNotifications)) { - Quickshell.execDetached(["rm", "-f", old.cachedImage]); + const cachedPath = old.cachedImage ? old.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } historyList.remove(historyList.count - 1); } @@ -644,12 +587,10 @@ Singleton { for (const item of adapter.notifications || []) { const time = new Date(item.timestamp); + // Use the cached image if it exists and starts with file://, otherwise use originalImage let cachedImage = item.cachedImage || ""; - if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) { - const imageId = generateImageId(item, item.originalImage); - if (imageId) { - cachedImage = Settings.cacheDirImagesNotifications + imageId + ".png"; - } + if (!cachedImage || (!cachedImage.startsWith("file://") && !cachedImage.startsWith("/"))) { + cachedImage = item.originalImage || ""; } historyList.append({ @@ -826,8 +767,9 @@ Singleton { const notif = historyList.get(i); if (notif.id === notificationId) { // Only delete cached images that are in our cache directory - if (notif.cachedImage && notif.cachedImage.startsWith(Settings.cacheDirImagesNotifications)) { - Quickshell.execDetached(["rm", "-f", notif.cachedImage]); + const cachedPath = notif.cachedImage ? notif.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } historyList.remove(i); saveHistory(); @@ -841,8 +783,9 @@ Singleton { if (historyList.count > 0) { const oldest = historyList.get(historyList.count - 1); // Only delete cached images that are in our cache directory - if (oldest.cachedImage && oldest.cachedImage.startsWith(Settings.cacheDirImagesNotifications)) { - Quickshell.execDetached(["rm", "-f", oldest.cachedImage]); + const cachedPath = oldest.cachedImage ? oldest.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } historyList.remove(historyList.count - 1); saveHistory(); @@ -853,7 +796,7 @@ Singleton { function clearHistory() { try { - Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`]); + Quickshell.execDetached(["sh", "-c", `rm -rf "${ImageCacheService.notificationsDir}"*`]); } catch (e) { Logger.e("Notifications", "Failed to clear cache directory:", e); } diff --git a/Services/UI/ImageCacheService.qml b/Services/UI/ImageCacheService.qml new file mode 100644 index 000000000..acafdc294 --- /dev/null +++ b/Services/UI/ImageCacheService.qml @@ -0,0 +1,680 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import "../../Helpers/sha256.js" as Checksum +import qs.Commons + +Singleton { + id: root + + // ------------------------------------------------- + // Public Properties + // ------------------------------------------------- + property bool imageMagickAvailable: false + property bool initialized: false + + // Cache directories + readonly property string baseDir: Settings.cacheDir + "images/" + readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/" + readonly property string wpLargeDir: baseDir + "wallpapers/large/" + readonly property string notificationsDir: baseDir + "notifications/" + readonly property string contributorsDir: baseDir + "contributors/" + + // Supported image formats - extended list when ImageMagick is available + readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"] + readonly property var extendedImageFilters: [ + "*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", + "*.webp", "*.avif", "*.heic", "*.heif", + "*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm", + "*.svg", "*.svgz", + "*.ico", "*.icns", + "*.jxl", "*.jp2", "*.j2k", + "*.exr", "*.hdr", + "*.dds", "*.tga" + ] + readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters + + // Check if a file format needs conversion (not natively supported by Qt) + function needsConversion(filePath) { + const ext = "*." + filePath.toLowerCase().split('.').pop(); + return !basicImageFilters.includes(ext); + } + + // ------------------------------------------------- + // Internal State + // ------------------------------------------------- + property var pendingRequests: ({}) + property var fallbackQueue: [] + property bool fallbackProcessing: false + + // ------------------------------------------------- + // Signals + // ------------------------------------------------- + signal cacheHit(string cacheKey, string cachedPath) + signal cacheMiss(string cacheKey) + signal processingComplete(string cacheKey, string cachedPath) + signal processingFailed(string cacheKey, string error) + + // ------------------------------------------------- + // Initialization + // ------------------------------------------------- + function init() { + Logger.i("ImageCache", "Service started"); + createDirectories(); + checkMagickProcess.running = true; + } + + function createDirectories() { + Quickshell.execDetached(["mkdir", "-p", wpThumbDir]); + Quickshell.execDetached(["mkdir", "-p", wpLargeDir]); + Quickshell.execDetached(["mkdir", "-p", notificationsDir]); + Quickshell.execDetached(["mkdir", "-p", contributorsDir]); + } + + // ------------------------------------------------- + // Public API: Get Thumbnail (256x256) + // ------------------------------------------------- + function getThumbnail(sourcePath, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return; + } + + getMtime(sourcePath, function(mtime) { + const cacheKey = generateThumbnailKey(sourcePath, mtime); + const cachedPath = wpThumbDir + cacheKey + ".jpg"; + + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + if (imageMagickAvailable) { + startThumbnailProcessing(sourcePath, cachedPath, cacheKey); + } else { + queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 256); + } + }); + }); + } + + // ------------------------------------------------- + // Public API: Get Fullscreen Wallpaper + // ------------------------------------------------- + function getFullscreen(sourcePath, screenName, width, height, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return; + } + + if (!imageMagickAvailable) { + Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath); + callback(sourcePath, false); + return; + } + + // Fast dimension check - skip processing if image is smaller than screen AND format is Qt-native + getImageDimensions(sourcePath, function(imgWidth, imgHeight) { + const isSmaller = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height; + + if (isSmaller) { + // Only skip if format is natively supported by Qt + if (!needsConversion(sourcePath)) { + Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} <= screen ${width}x${height}, using original`); + callback(sourcePath, false); + return; + } + Logger.d("ImageCache", `Image needs conversion despite being smaller than screen`); + } + + // Use actual image dimensions if smaller (convert without upscaling), otherwise use screen dimensions + const targetWidth = isSmaller ? imgWidth : width; + const targetHeight = isSmaller ? imgHeight : height; + + getMtime(sourcePath, function(mtime) { + const cacheKey = generateLargeKey(sourcePath, screenName, width, height, mtime); + const cachedPath = wpLargeDir + cacheKey + ".jpg"; + + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey); + }); + }); + }); + } + + // ------------------------------------------------- + // Public API: Get Notification Icon (64x64) + // ------------------------------------------------- + function getNotificationIcon(imageUri, appName, summary, callback) { + if (!imageUri || imageUri === "") { + callback("", false); + return; + } + + // File paths are used directly, not cached + if (imageUri.startsWith("/") || imageUri.startsWith("file://")) { + callback(imageUri, false); + return; + } + + const cacheKey = generateNotificationKey(imageUri, appName, summary); + const cachedPath = notificationsDir + cacheKey + ".png"; + + processRequest(cacheKey, cachedPath, imageUri, callback, function() { + // Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick) + queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64); + }); + } + + // ------------------------------------------------- + // Public API: Get Circular Avatar (256x256) + // ------------------------------------------------- + function getCircularAvatar(url, username, callback) { + if (!url || !username) { + callback("", false); + return; + } + + const cacheKey = username; + const cachedPath = contributorsDir + username + "_circular.png"; + + processRequest(cacheKey, cachedPath, url, callback, function() { + if (imageMagickAvailable) { + downloadAndProcessAvatar(url, username, cachedPath, cacheKey); + } else { + // No fallback for circular avatars without ImageMagick + Logger.w("ImageCache", "Circular avatars require ImageMagick"); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // ------------------------------------------------- + // Cache Key Generation + // ------------------------------------------------- + function generateThumbnailKey(sourcePath, mtime) { + const keyString = sourcePath + "@256x256@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + function generateLargeKey(sourcePath, screenName, width, height, mtime) { + const keyString = sourcePath + "@" + screenName + "@" + width + "x" + height + "@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + function generateNotificationKey(imageUri, appName, summary) { + if (imageUri.startsWith("image://qsimage/")) { + return Checksum.sha256(appName + "|" + summary); + } + return Checksum.sha256(imageUri); + } + + // ------------------------------------------------- + // Request Processing (with coalescing) + // ------------------------------------------------- + function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) { + // Check if already processing this request + if (pendingRequests[cacheKey]) { + pendingRequests[cacheKey].callbacks.push(callback); + Logger.d("ImageCache", "Coalescing request for:", cacheKey); + return; + } + + // Check cache first + checkFileExists(cachedPath, function(exists) { + if (exists) { + Logger.d("ImageCache", "Cache hit:", cachedPath); + callback(cachedPath, true); + cacheHit(cacheKey, cachedPath); + return; + } + + // Re-check pendingRequests (race condition fix) + if (pendingRequests[cacheKey]) { + pendingRequests[cacheKey].callbacks.push(callback); + return; + } + + // Start new processing + Logger.d("ImageCache", "Cache miss, processing:", sourcePath); + cacheMiss(cacheKey); + pendingRequests[cacheKey] = { + callbacks: [callback], + sourcePath: sourcePath + }; + + processFn(); + }); + } + + function notifyCallbacks(cacheKey, path, success) { + const request = pendingRequests[cacheKey]; + if (request) { + request.callbacks.forEach(function(cb) { + cb(path, success); + }); + delete pendingRequests[cacheKey]; + } + + if (success) { + processingComplete(cacheKey, path); + } else { + processingFailed(cacheKey, "Processing failed"); + } + } + + // ------------------------------------------------- + // ImageMagick Processing: Thumbnail + // ------------------------------------------------- + function startThumbnailProcessing(sourcePath, outputPath, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + + const command = `magick -define jpeg:size=512x512 '${srcEsc}' -auto-orient -thumbnail '256x256^' -gravity center -extent 256x256 -quality 85 '${dstEsc}'`; + + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Large + // ------------------------------------------------- + function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + const doubleWidth = width * 2; + const doubleHeight = height * 2; + + const command = `magick -define jpeg:size=${doubleWidth}x${doubleHeight} '${srcEsc}' -auto-orient -thumbnail '${width}x${height}^' -quality 95 '${dstEsc}'`; + + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Circular Avatar + // ------------------------------------------------- + function downloadAndProcessAvatar(url, username, outputPath, cacheKey) { + const tempPath = contributorsDir + username + "_temp.png"; + const tempEsc = tempPath.replace(/'/g, "'\\''"); + const urlEsc = url.replace(/'/g, "'\\''"); + + // Download first + const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`; + + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["bash", "-c", ""] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + + try { + const downloadProcess = Qt.createQmlObject(processString, root, "DownloadProcess_" + cacheKey); + downloadProcess.command = ["bash", "-c", downloadCmd]; + + downloadProcess.exited.connect(function(exitCode) { + downloadProcess.destroy(); + + if (exitCode !== 0) { + Logger.e("ImageCache", "Failed to download avatar for", username); + notifyCallbacks(cacheKey, "", false); + return; + } + + // Now process with ImageMagick + processCircularAvatar(tempPath, outputPath, cacheKey); + }); + + downloadProcess.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create download process:", e); + notifyCallbacks(cacheKey, "", false); + } + } + + function processCircularAvatar(inputPath, outputPath, cacheKey) { + const srcEsc = inputPath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + + // ImageMagick command for circular crop with alpha + const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`; + + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["bash", "-c", ""] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + + try { + const processObj = Qt.createQmlObject(processString, root, "CircularProcess_" + cacheKey); + processObj.command = ["bash", "-c", command]; + + processObj.exited.connect(function(exitCode) { + // Clean up temp file + Quickshell.execDetached(["rm", "-f", inputPath]); + + if (exitCode !== 0) { + Logger.e("ImageCache", "Failed to create circular avatar"); + notifyCallbacks(cacheKey, "", false); + } else { + Logger.d("ImageCache", "Circular avatar created:", outputPath); + notifyCallbacks(cacheKey, outputPath, true); + } + + processObj.destroy(); + }); + + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create circular process:", e); + Quickshell.execDetached(["rm", "-f", inputPath]); + notifyCallbacks(cacheKey, "", false); + } + } + + // ------------------------------------------------- + // Generic Process Runner + // ------------------------------------------------- + function runProcess(command, cacheKey, outputPath, sourcePath) { + const processString = ` + import QtQuick + import Quickshell.Io + Process { + property string cacheKey: "" + property string cachedPath: "" + command: ["bash", "-c", ""] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + + try { + const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + cacheKey); + processObj.cacheKey = cacheKey; + processObj.cachedPath = outputPath; + processObj.command = ["bash", "-c", command]; + + processObj.exited.connect(function(exitCode) { + if (exitCode !== 0) { + const stderrText = processObj.stderr.text || ""; + Logger.e("ImageCache", "Processing failed:", stderrText); + notifyCallbacks(cacheKey, sourcePath, false); + } else { + Logger.d("ImageCache", "Processing complete:", outputPath); + notifyCallbacks(cacheKey, outputPath, true); + } + + processObj.destroy(); + }); + + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create process:", e); + notifyCallbacks(cacheKey, sourcePath, false); + } + } + + // ------------------------------------------------- + // Qt Fallback Renderer + // ------------------------------------------------- + PanelWindow { + id: fallbackRenderer + implicitWidth: 0 + implicitHeight: 0 + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "noctalia-image-cache-renderer" + color: Color.transparent + mask: Region {} + + Image { + id: fallbackImage + property string cacheKey: "" + property string destPath: "" + property int targetSize: 256 + + width: targetSize + height: targetSize + visible: true + cache: false + asynchronous: true + fillMode: Image.PreserveAspectCrop + mipmap: true + antialiasing: true + + onStatusChanged: { + if (!cacheKey) return; + + if (status === Image.Ready) { + grabToImage(function(result) { + if (result.saveToFile(destPath)) { + Logger.d("ImageCache", "Fallback cache created:", destPath); + root.notifyCallbacks(cacheKey, destPath, true); + } else { + Logger.e("ImageCache", "Failed to save fallback cache"); + root.notifyCallbacks(cacheKey, "", false); + } + processNextFallback(); + }); + } else if (status === Image.Error) { + Logger.e("ImageCache", "Fallback image load failed"); + root.notifyCallbacks(cacheKey, "", false); + processNextFallback(); + } + } + + function processNextFallback() { + cacheKey = ""; + destPath = ""; + source = ""; + + if (fallbackQueue.length > 0) { + const next = fallbackQueue.shift(); + cacheKey = next.cacheKey; + destPath = next.destPath; + targetSize = next.size; + source = next.sourcePath; + } else { + fallbackProcessing = false; + } + } + } + } + + function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) { + fallbackQueue.push({ + sourcePath: sourcePath, + destPath: destPath, + cacheKey: cacheKey, + size: size + }); + + if (!fallbackProcessing) { + fallbackProcessing = true; + const item = fallbackQueue.shift(); + fallbackImage.cacheKey = item.cacheKey; + fallbackImage.destPath = item.destPath; + fallbackImage.targetSize = item.size; + fallbackImage.source = item.sourcePath; + } + } + + // ------------------------------------------------- + // Utility Functions + // ------------------------------------------------- + function getMtime(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["stat", "-c", "%Y", "${pathEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + + try { + const processObj = Qt.createQmlObject(processString, root, "MtimeProcess"); + + processObj.exited.connect(function(exitCode) { + const mtime = exitCode === 0 ? processObj.stdout.text.trim() : ""; + processObj.destroy(); + callback(mtime); + }); + + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to get mtime:", e); + callback(""); + } + } + + function checkFileExists(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["test", "-f", "${pathEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + + try { + const processObj = Qt.createQmlObject(processString, root, "FileExistsProcess"); + + processObj.exited.connect(function(exitCode) { + processObj.destroy(); + callback(exitCode === 0); + }); + + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to check file:", e); + callback(false); + } + } + + function getImageDimensions(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + + try { + const processObj = Qt.createQmlObject(processString, root, "IdentifyProcess"); + + processObj.exited.connect(function(exitCode) { + let width = 0, height = 0; + if (exitCode === 0) { + const parts = processObj.stdout.text.trim().split(" "); + if (parts.length >= 2) { + width = parseInt(parts[0], 10) || 0; + height = parseInt(parts[1], 10) || 0; + } + } + processObj.destroy(); + callback(width, height); + }); + + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to get image dimensions:", e); + callback(0, 0); + } + } + + // ------------------------------------------------- + // Cache Invalidation + // ------------------------------------------------- + function invalidateThumbnail(sourcePath) { + Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath); + // Since cache keys include hash, we'd need to track mappings + // For simplicity, clear all thumbnails + clearThumbnails(); + } + + function invalidateLarge(sourcePath, screenName) { + Logger.i("ImageCache", "Invalidating large for:", sourcePath); + clearLarge(); + } + + function invalidateNotification(imageId) { + const path = notificationsDir + imageId + ".png"; + Quickshell.execDetached(["rm", "-f", path]); + } + + function invalidateAvatar(username) { + const path = contributorsDir + username + "_circular.png"; + Quickshell.execDetached(["rm", "-f", path]); + } + + // ------------------------------------------------- + // Clear Cache Functions + // ------------------------------------------------- + function clearAll() { + Logger.i("ImageCache", "Clearing all cache"); + clearThumbnails(); + clearLarge(); + clearNotifications(); + clearContributors(); + } + + function clearThumbnails() { + Logger.i("ImageCache", "Clearing thumbnails cache"); + Quickshell.execDetached(["rm", "-rf", wpThumbDir]); + Quickshell.execDetached(["mkdir", "-p", wpThumbDir]); + } + + function clearLarge() { + Logger.i("ImageCache", "Clearing large cache"); + Quickshell.execDetached(["rm", "-rf", wpLargeDir]); + Quickshell.execDetached(["mkdir", "-p", wpLargeDir]); + } + + function clearNotifications() { + Logger.i("ImageCache", "Clearing notifications cache"); + Quickshell.execDetached(["rm", "-rf", notificationsDir]); + Quickshell.execDetached(["mkdir", "-p", notificationsDir]); + } + + function clearContributors() { + Logger.i("ImageCache", "Clearing contributors cache"); + Quickshell.execDetached(["rm", "-rf", contributorsDir]); + Quickshell.execDetached(["mkdir", "-p", contributorsDir]); + } + + // ------------------------------------------------- + // ImageMagick Detection + // ------------------------------------------------- + Process { + id: checkMagickProcess + command: ["which", "magick"] + running: false + + stdout: StdioCollector {} + stderr: StdioCollector {} + + onExited: function(exitCode) { + root.imageMagickAvailable = (exitCode === 0); + root.initialized = true; + if (root.imageMagickAvailable) { + Logger.i("ImageCache", "ImageMagick available"); + } else { + Logger.w("ImageCache", "ImageMagick not found, using Qt fallback"); + } + } + } +} diff --git a/Services/UI/WallpaperCacheService.qml b/Services/UI/WallpaperCacheService.qml deleted file mode 100644 index 05dde8366..000000000 --- a/Services/UI/WallpaperCacheService.qml +++ /dev/null @@ -1,337 +0,0 @@ -pragma Singleton - -import QtQuick -import Quickshell -import Quickshell.Io -import "../../Helpers/sha256.js" as Checksum -import qs.Commons - -Singleton { - id: root - - readonly property string cacheDir: Settings.cacheDirImagesWallpapers + "preprocessed/" - property bool imageMagickAvailable: false - property bool initialized: false - - // Track pending preprocessing operations - // key: cacheKey, value: { callbacks: [], sourcePath: string, screenName: string } - property var pendingRequests: ({}) - - // Signals - signal preprocessComplete(string cacheKey, string cachedPath, string screenName) - signal preprocessFailed(string cacheKey, string error, string screenName) - - // ------------------------------------------------- - function init() { - Logger.i("WallpaperCache", "Service started"); - Quickshell.execDetached(["mkdir", "-p", cacheDir]); - checkMagickProcess.running = true; - } - - // ------------------------------------------------- - // Main API: Request preprocessed wallpaper - // callback signature: function(cachedPath: string, success: bool) - function getPreprocessed(sourcePath, screenName, width, height, callback) { - if (!sourcePath || sourcePath === "") { - callback("", false); - return; - } - - if (!imageMagickAvailable) { - // Fallback: return original path - Logger.d("WallpaperCache", "ImageMagick not available, using original:", sourcePath); - callback(sourcePath, false); - return; - } - - // Fast dimension check using identify -ping (reads only header, not pixels) - getImageDimensions(sourcePath, function (imgWidth, imgHeight) { - if (imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height) { - // Image is smaller than or equal to screen - no preprocessing needed - Logger.d("WallpaperCache", `Image ${imgWidth}x${imgHeight} <= screen ${width}x${height}, using original`); - callback(sourcePath, false); - return; - } - - // Image is larger - proceed with preprocessing - proceedWithPreprocessing(sourcePath, screenName, width, height, callback); - }); - } - - // ------------------------------------------------- - function proceedWithPreprocessing(sourcePath, screenName, width, height, callback) { - // Get mtime for cache invalidation - getMtime(sourcePath, function (mtime) { - const cacheKey = generateCacheKey(sourcePath, width, height, mtime); - const cachedPath = cacheDir + cacheKey + ".jpg"; - - // Check if already processing this exact request - if (pendingRequests[cacheKey]) { - pendingRequests[cacheKey].callbacks.push({ - callback: callback, - screenName: screenName - }); - Logger.d("WallpaperCache", "Coalescing request for:", cacheKey); - return; - } - - // Check cache first - checkFileExists(cachedPath, function (exists) { - if (exists) { - Logger.d("WallpaperCache", "Cache hit:", cachedPath); - callback(cachedPath, true); - return; - } - - // Re-check pendingRequests in case another request started processing - // while we were checking file existence (race condition fix) - if (pendingRequests[cacheKey]) { - pendingRequests[cacheKey].callbacks.push({ - callback: callback, - screenName: screenName - }); - Logger.d("WallpaperCache", "Coalescing request (late):", cacheKey); - return; - } - - // Start new processing - Logger.d("WallpaperCache", `Preprocessing ${sourcePath} to ${width}x${height}`); - pendingRequests[cacheKey] = { - callbacks: [ - { - callback: callback, - screenName: screenName - } - ], - sourcePath: sourcePath - }; - - startPreprocessing(sourcePath, cachedPath, width, height, cacheKey, screenName); - }); - }); - } - - // ------------------------------------------------- - function generateCacheKey(sourcePath, width, height, mtime) { - const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown"); - return Checksum.sha256(keyString); - } - - // ------------------------------------------------- - function buildCommand(sourcePath, outputPath, width, height) { - // Escape paths for shell - const srcEsc = sourcePath.replace(/'/g, "'\\''"); - const dstEsc = outputPath.replace(/'/g, "'\\''"); - - // Use -define jpeg:size for faster JPEG loading (decoder hint) - // -thumbnail is faster than -resize (strips metadata, uses faster algorithms) - // The ^ ensures image covers target (smaller dimension fits exactly) - // -auto-orient applies EXIF orientation before processing - // Small images are filtered out before this function is called - // The shader will handle actual fill mode (crop/fit/center/stretch) - const doubleWidth = width * 2; - const doubleHeight = height * 2; - return `convert -define jpeg:size=${doubleWidth}x${doubleHeight} '${srcEsc}' -auto-orient -thumbnail '${width}x${height}^' -quality 95 '${dstEsc}'`; - } - - // ------------------------------------------------- - function startPreprocessing(sourcePath, outputPath, width, height, cacheKey, screenName) { - const command = buildCommand(sourcePath, outputPath, width, height); - - // Create dynamic process for this request - const processString = ` - import QtQuick - import Quickshell.Io - Process { - property string cacheKey: "" - property string cachedPath: "" - property string screenName: "" - command: ["bash", "-c", ""] - stdout: StdioCollector {} - stderr: StdioCollector {} - } - `; - - try { - const processObj = Qt.createQmlObject(processString, root, "PreprocessProcess_" + cacheKey); - processObj.cacheKey = cacheKey; - processObj.cachedPath = outputPath; - processObj.screenName = screenName; - processObj.command = ["bash", "-c", command]; - - processObj.exited.connect(function (exitCode) { - handleProcessComplete(processObj, exitCode); - }); - - processObj.running = true; - } catch (e) { - Logger.e("WallpaperCache", "Failed to create process:", e); - notifyCallbacks(cacheKey, sourcePath, false); - } - } - - // ------------------------------------------------- - function handleProcessComplete(processObj, exitCode) { - const cacheKey = processObj.cacheKey; - const cachedPath = processObj.cachedPath; - const screenName = processObj.screenName; - const stderrText = processObj.stderr.text || ""; - - if (exitCode !== 0) { - Logger.e("WallpaperCache", "Preprocessing failed:", stderrText); - const sourcePath = pendingRequests[cacheKey] ? pendingRequests[cacheKey].sourcePath : ""; - notifyCallbacks(cacheKey, sourcePath, false); - preprocessFailed(cacheKey, stderrText, screenName); - } else { - Logger.d("WallpaperCache", "Preprocessing complete:", cachedPath); - notifyCallbacks(cacheKey, cachedPath, true); - preprocessComplete(cacheKey, cachedPath, screenName); - } - - // Clean up - processObj.destroy(); - } - - // ------------------------------------------------- - function notifyCallbacks(cacheKey, path, success) { - const request = pendingRequests[cacheKey]; - if (request) { - request.callbacks.forEach(function (item) { - item.callback(path, success); - }); - delete pendingRequests[cacheKey]; - } - } - - // ------------------------------------------------- - function getMtime(filePath, callback) { - const pathEsc = filePath.replace(/'/g, "'\\''"); - const processString = ` - import QtQuick - import Quickshell.Io - Process { - command: ["stat", "-c", "%Y", "${pathEsc}"] - stdout: StdioCollector {} - stderr: StdioCollector {} - } - `; - - try { - const processObj = Qt.createQmlObject(processString, root, "MtimeProcess"); - - processObj.exited.connect(function (exitCode) { - const mtime = exitCode === 0 ? processObj.stdout.text.trim() : ""; - processObj.destroy(); - callback(mtime); - }); - - processObj.running = true; - } catch (e) { - Logger.e("WallpaperCache", "Failed to get mtime:", e); - callback(""); - } - } - - // ------------------------------------------------- - function checkFileExists(filePath, callback) { - const pathEsc = filePath.replace(/'/g, "'\\''"); - const processString = ` - import QtQuick - import Quickshell.Io - Process { - command: ["test", "-f", "${pathEsc}"] - stdout: StdioCollector {} - stderr: StdioCollector {} - } - `; - - try { - const processObj = Qt.createQmlObject(processString, root, "FileExistsProcess"); - - processObj.exited.connect(function (exitCode) { - processObj.destroy(); - callback(exitCode === 0); - }); - - processObj.running = true; - } catch (e) { - Logger.e("WallpaperCache", "Failed to check file:", e); - callback(false); - } - } - - // ------------------------------------------------- - // Fast image dimension check using identify -ping (reads only header, not pixel data) - function getImageDimensions(filePath, callback) { - const pathEsc = filePath.replace(/'/g, "'\\''"); - const processString = ` - import QtQuick - import Quickshell.Io - Process { - command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"] - stdout: StdioCollector {} - stderr: StdioCollector {} - } - `; - - try { - const processObj = Qt.createQmlObject(processString, root, "IdentifyProcess"); - - processObj.exited.connect(function (exitCode) { - let width = 0, height = 0; - if (exitCode === 0) { - const parts = processObj.stdout.text.trim().split(" "); - if (parts.length >= 2) { - width = parseInt(parts[0], 10) || 0; - height = parseInt(parts[1], 10) || 0; - } - } - processObj.destroy(); - callback(width, height); - }); - - processObj.running = true; - } catch (e) { - Logger.e("WallpaperCache", "Failed to get image dimensions:", e); - callback(0, 0); - } - } - - // ------------------------------------------------- - // Utility: Clear cache for a specific source - function invalidateForSource(sourcePath) { - // Since cache keys include the source path hash, we'd need to track mappings - // For simplicity, this clears the entire cache - Logger.i("WallpaperCache", "Invalidating cache for:", sourcePath); - clearAllCache(); - } - - // ------------------------------------------------- - // Utility: Clear entire cache - function clearAllCache() { - Logger.i("WallpaperCache", "Clearing all cache"); - Quickshell.execDetached(["rm", "-rf", cacheDir]); - Quickshell.execDetached(["mkdir", "-p", cacheDir]); - } - - // ------------------------------------------------- - // Check if ImageMagick is available - Process { - id: checkMagickProcess - command: ["which", "convert"] - running: false - - stdout: StdioCollector {} - stderr: StdioCollector {} - - onExited: function (exitCode) { - root.imageMagickAvailable = (exitCode === 0); - root.initialized = true; - if (root.imageMagickAvailable) { - Logger.i("WallpaperCache", "ImageMagick available"); - } else { - Logger.w("WallpaperCache", "ImageMagick not found, caching disabled"); - } - } - } -} diff --git a/Services/UI/WallpaperService.qml b/Services/UI/WallpaperService.qml index ab0604ff4..130738309 100644 --- a/Services/UI/WallpaperService.qml +++ b/Services/UI/WallpaperService.qml @@ -5,6 +5,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Commons +import qs.Services.UI Singleton { id: root @@ -551,7 +552,7 @@ Singleton { property string currentDirectory: root.getMonitorDirectory(screenName) folder: "file://" + currentDirectory - nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"] + nameFilters: ImageCacheService.imageFilters showDirs: false sortField: FolderListModel.Name diff --git a/Widgets/NImageCached.qml b/Widgets/NImageCached.qml index 7d330bbc7..88035caa7 100644 --- a/Widgets/NImageCached.qml +++ b/Widgets/NImageCached.qml @@ -1,50 +1,38 @@ import QtQuick -import Quickshell -import Quickshell.Io -import "../Helpers/sha256.js" as Checksum import qs.Commons +import qs.Services.UI Image { id: root property string imagePath: "" - property string imageHash: "" - property string cacheFolder: Settings.cacheDirImages - property int maxCacheDimension: 384 - readonly property string cachePath: imageHash ? `${cacheFolder}${imageHash}@${maxCacheDimension}x${maxCacheDimension}.png` : "" + property int maxCacheDimension: 256 asynchronous: true fillMode: Image.PreserveAspectCrop sourceSize.width: maxCacheDimension sourceSize.height: maxCacheDimension smooth: true + onImagePathChanged: { - if (imagePath) { - imageHash = Checksum.sha256(imagePath); - // Logger.i("NImageCached", imagePath, imageHash) - } else { + if (!imagePath) { source = ""; - imageHash = ""; + return; } - } - onCachePathChanged: { - if (imageHash && cachePath) { - // Try to load the cached version, failure will be detected below in onStatusChanged - // Failure is expected and warnings are ok in the console. Don't try to improve without consulting. - source = cachePath; - } - } - onStatusChanged: { - if (source == cachePath && status === Image.Error) { - // Cached image was not available, show the original + + if (!ImageCacheService.initialized) { + // Service not ready yet, use original source = imagePath; - } else if (source == imagePath && status === Image.Ready && imageHash && cachePath) { - // Original image is shown and fully loaded, time to cache it - const grabPath = cachePath; - if (visible && width > 0 && height > 0 && Window.window && Window.window.visible) - grabToImage(res => { - return res.saveToFile(grabPath); - }); + return; } + + ImageCacheService.getThumbnail(imagePath, function(cachedPath, success) { + if (!root) return; // Component was destroyed + if (success) { + root.source = cachedPath; + } else { + root.source = imagePath; + } + }); } } diff --git a/shell.qml b/shell.qml index f64859368..f546ef3b5 100644 --- a/shell.qml +++ b/shell.qml @@ -91,7 +91,7 @@ ShellRoot { Component.onCompleted: { Logger.i("Shell", "---------------------------"); WallpaperService.init(); - WallpaperCacheService.init(); + ImageCacheService.init(); AppThemeService.init(); ColorSchemeService.init(); LocationService.init();