mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Image Caching: full refactor base on ImageMagick and fallback to QML. Allows support for a lot more image formats.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -767,7 +767,6 @@ SmartPanel {
|
||||
NImageCached {
|
||||
id: img
|
||||
imagePath: wallpaperPath
|
||||
cacheFolder: Settings.cacheDirImagesWallpapers
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+18
-30
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user