diff --git a/plugins/android/src/main/java/ImmichClient.kt b/plugins/android/src/main/java/ImmichClient.kt index 3425f06..c4b2f06 100644 --- a/plugins/android/src/main/java/ImmichClient.kt +++ b/plugins/android/src/main/java/ImmichClient.kt @@ -15,6 +15,7 @@ import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.security.KeyStore +import java.security.MessageDigest import java.util.concurrent.Callable import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicReference @@ -32,6 +33,9 @@ object ImmichClient { private const val KEY_API_KEY_DATA = "immich_api_key_data" private const val KEY_API_KEY_IV = "immich_api_key_iv" private const val KEY_ALIAS = "wallpaperflow_immich_api_key" + private const val CACHE_MAX_FILES = 3 + private const val CACHE_MAX_BYTES = 256L * 1024 * 1024 + private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024 private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$") private val thumbnailPool = Executors.newFixedThreadPool(4) private val importProgress = AtomicReference(ImportProgress()) @@ -193,13 +197,14 @@ object ImmichClient { } finally { connection.disconnect() } } - private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File): String { + private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File, trackProgress: Boolean = true): String { val connection = openConnection(serverUrl, apiKey, path) try { if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection)) val total = connection.contentLengthLong.coerceAtLeast(0) + require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." } var downloaded = 0L - importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) } + if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) } connection.inputStream.use { input -> target.outputStream().use { output -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) @@ -208,7 +213,8 @@ object ImmichClient { if (count < 0) break output.write(buffer, 0, count) downloaded += count - importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) } + require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." } + if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) } } } } @@ -275,32 +281,80 @@ object ImmichClient { } } - private fun extension(contentType: String) = when (contentType.lowercase()) { - "image/png" -> "png" - "image/webp" -> "webp" - "image/heic", "image/heif" -> "heic" - else -> "jpg" - } - private fun importOne(context: Context, credentials: Credentials, assetId: String) { require(assetIdPattern.matches(assetId)) { "Ungültige Immich-Bild-ID." } - if (WallpaperStore.files(context).any { it.name.startsWith("immich-$assetId.") }) return + if (WallpaperStore.hasImmich(context, credentials.serverUrl, assetId)) return val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name()) - val directory = WallpaperStore.directory(context) - val temporary = File(directory, ".immich-$assetId.download") - var contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/original", temporary) + val temporary = File(context.cacheDir, ".immich-preview-$assetId.download") + downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary) val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(temporary.absolutePath, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { - temporary.delete() - contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary) - BitmapFactory.decodeFile(temporary.absolutePath, bounds) - require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat kein unterstütztes Bildformat geliefert." } + require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat keine gültige Vorschau geliefert." } + try { WallpaperStore.addImmich(context, credentials.serverUrl, assetId, temporary) } + finally { temporary.delete() } + } + + private fun cacheDirectory(context: Context) = File(context.cacheDir, "immich-wallpaper-originals").apply { mkdirs() } + + private fun cacheKey(serverUrl: String, assetId: String): String { + val server = MessageDigest.getInstance("SHA-256").digest(serverUrl.toByteArray(StandardCharsets.UTF_8)) + .take(6).joinToString("") { "%02x".format(it) } + return "$server-$assetId" + } + + private fun validImage(file: File): Boolean { + if (!file.isFile || file.length() <= 0) return false + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + return bounds.outWidth > 0 && bounds.outHeight > 0 + } + + @Synchronized + fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? { + val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image") + if (validImage(target)) { + target.setLastModified(System.currentTimeMillis()) + return target } - val target = File(directory, "immich-$assetId.${extension(contentType)}") - if (!temporary.renameTo(target)) { - temporary.copyTo(target, overwrite = true) + target.delete() + if (!allowNetwork) return null + val current = runCatching { credentials(context) }.getOrNull() ?: return null + if (current.serverUrl != serverUrl || !assetIdPattern.matches(assetId)) return null + val temporary = File(cacheDirectory(context), ".${cacheKey(serverUrl, assetId)}.download") + val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name()) + return try { + downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/original", temporary, trackProgress = false) + if (!validImage(temporary)) { + temporary.delete() + downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary, trackProgress = false) + } + if (!validImage(temporary)) return null + if (!temporary.renameTo(target)) { temporary.copyTo(target, overwrite = true); temporary.delete() } + target.setLastModified(System.currentTimeMillis()) + trimCache(context, target) + target + } catch (_: Exception) { temporary.delete() + null + } + } + + @Synchronized + fun deleteCachedOriginal(context: Context, serverUrl: String, assetId: String) { + val key = cacheKey(serverUrl, assetId) + File(cacheDirectory(context), "$key.image").delete() + File(cacheDirectory(context), ".$key.download").delete() + } + + private fun trimCache(context: Context, protected: File) { + val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedByDescending { it.lastModified() } ?: return + var bytes = 0L + files.forEachIndexed { index, file -> + bytes += file.length() + if (file != protected && (index >= CACHE_MAX_FILES || bytes > CACHE_MAX_BYTES)) { + bytes -= file.length() + file.delete() + } } } diff --git a/plugins/android/src/main/java/WallpaperPlugin.kt b/plugins/android/src/main/java/WallpaperPlugin.kt index 2347d90..a598759 100644 --- a/plugins/android/src/main/java/WallpaperPlugin.kt +++ b/plugins/android/src/main/java/WallpaperPlugin.kt @@ -38,6 +38,7 @@ class ImageCropArgs { var zoom: Double = 1.0 var positionX: Double = 0.5 var positionY: Double = 0.5 + var rotation: Int = 0 } @InvokeArg @@ -99,7 +100,7 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) { @Command fun setImageCrop(invoke: Invoke) = io.execute { try { val args = invoke.parseArgs(ImageCropArgs::class.java) - val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY) + val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY, args.rotation) ?: throw IllegalArgumentException("Bild wurde nicht gefunden") invoke.resolve(image) } catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") } @@ -143,7 +144,10 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) { } @Command fun nextWallpaper(invoke: Invoke) = io.execute { - if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) else invoke.reject("Bitte wähle zuerst Bilder aus") + try { + if (WallpaperStore.applyNext(activity)) invoke.resolve(WallpaperStore.state(activity)) + else invoke.reject("Kein verfügbares Bild konnte angewendet werden") + } catch (error: Exception) { invoke.reject(error.message ?: "Das Hintergrundbild konnte nicht geändert werden") } } @Command fun getImmichConnection(invoke: Invoke) = io.execute { diff --git a/plugins/android/src/main/java/WallpaperStore.kt b/plugins/android/src/main/java/WallpaperStore.kt index 4129267..ed4fa6c 100644 --- a/plugins/android/src/main/java/WallpaperStore.kt +++ b/plugins/android/src/main/java/WallpaperStore.kt @@ -14,21 +14,79 @@ import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import java.io.ByteArrayOutputStream import java.io.File -import kotlin.random.Random +import java.security.MessageDigest import org.json.JSONObject object WallpaperStore { private const val PREFS = "wechselbild" private const val KEY_INDEX = "current_index" + private const val KEY_CURRENT_ID = "current_entry_id" private const val KEY_INTERVAL = "interval_minutes" private const val CROP_PREFIX = "crop_" private const val HOME_PREVIEW_LIMIT = 12 private val thumbnailCache = LruCache(48) - private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5) + + private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5, val rotation: Int = 0) + private sealed class Entry { + abstract val id: String + abstract val preview: File + data class Local(val file: File) : Entry() { override val id = file.name; override val preview = file } + data class Immich( + override val id: String, + val assetId: String, + val serverUrl: String, + val addedAt: Long, + val metadata: File, + override val preview: File, + ) : Entry() + } + fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() } - fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList() + fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.name } ?: emptyList() + private fun immichRoot(context: Context) = File(context.filesDir, "immich-wallpapers").apply { mkdirs() } + private fun immichEntries(context: Context) = File(immichRoot(context), "entries").apply { mkdirs() } + private fun immichPreviews(context: Context) = File(immichRoot(context), "previews").apply { mkdirs() } private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256") + .digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) } + + private fun virtualKey(serverUrl: String, assetId: String) = "${serverKey(serverUrl)}-$assetId" + private fun virtualId(serverUrl: String, assetId: String) = "immich:${serverKey(serverUrl)}:$assetId" + + private fun entries(context: Context): List { + val local = files(context).map { Entry.Local(it) } + val virtual = immichEntries(context).listFiles()?.filter { it.isFile && it.extension == "json" }?.mapNotNull { metadata -> + runCatching { + val json = JSONObject(metadata.readText()) + val serverUrl = json.getString("serverUrl") + val assetId = json.getString("assetId") + val preview = File(immichPreviews(context), "${virtualKey(serverUrl, assetId)}.preview") + if (!preview.isFile) return@runCatching null + Entry.Immich( + id = virtualId(serverUrl, assetId), + assetId = assetId, + serverUrl = serverUrl, + addedAt = json.optLong("addedAt", metadata.lastModified()), + metadata = metadata, + preview = preview, + ) + }.getOrNull() + }?.filterNotNull()?.sortedWith(compareBy { it.addedAt }.thenBy { it.id }) ?: emptyList() + return local + virtual + } + + private fun currentIndex(context: Context, items: List): Int { + if (items.isEmpty()) return 0 + val preferences = prefs(context) + val currentId = preferences.getString(KEY_CURRENT_ID, null) + val byId = items.indexOfFirst { it.id == currentId } + if (byId >= 0) return byId + val legacy = preferences.getInt(KEY_INDEX, 0).coerceIn(0, items.lastIndex) + preferences.edit().putString(KEY_CURRENT_ID, items[legacy].id).apply() + return legacy + } + fun intervalMinutes(context: Context): Int { val preferences = prefs(context) return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0) @@ -48,45 +106,44 @@ object WallpaperStore { prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply() } + @Synchronized fun state(context: Context, includePreviews: Boolean = true): JSObject { - val originals = files(context) - val index = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) - val previewFiles = if (index < HOME_PREVIEW_LIMIT) originals.take(HOME_PREVIEW_LIMIT) else listOf(originals[index]) + originals.take(HOME_PREVIEW_LIMIT - 1) + val items = entries(context) + val index = currentIndex(context, items) + val previewItems = if (index < HOME_PREVIEW_LIMIT) items.take(HOME_PREVIEW_LIMIT) else listOf(items[index]) + items.take(HOME_PREVIEW_LIMIT - 1) val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0 return JSObject().apply { - put("imageCount", originals.size) + put("imageCount", items.size) put("enabled", enabled(context)) put("intervalMinutes", intervalMinutes(context)) put("shuffle", shuffle(context)) put("lockScreenOnly", lockOnly(context)) put("currentIndex", previewIndex) val previews = JSArray() - if (includePreviews) previewFiles.forEach { previews.put(thumbnailDataUrl(it)) } + if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) } put("imageUrls", previews) } } + @Synchronized fun gallery(context: Context, offset: Int, limit: Int): JSObject { - val originals = files(context) - val selectedIndex = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) - val safeOffset = offset.coerceAtLeast(0).coerceAtMost(originals.size) + val items = entries(context) + val selectedIndex = currentIndex(context, items) + val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size) val safeLimit = limit.coerceIn(1, 100) - val items = JSArray() - originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file -> - items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex)) - } - return JSObject().apply { - put("total", originals.size) - put("items", items) + val page = JSArray() + items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry -> + page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex)) } + return JSObject().apply { put("total", items.size); put("items", page) } } - fun imageIds(context: Context) = files(context).map { it.name } + @Synchronized + fun imageIds(context: Context) = entries(context).map { it.id } private fun cropKey(id: String) = CROP_PREFIX + id - - private fun crop(context: Context, file: File): CropSettings { - val raw = prefs(context).getString(cropKey(file.name), null) ?: return CropSettings() + private fun crop(context: Context, entry: Entry): CropSettings { + val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings() return try { val json = JSONObject(raw) CropSettings( @@ -94,68 +151,103 @@ object WallpaperStore { zoom = json.optDouble("zoom", 1.0).coerceIn(1.0, 3.0), x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0), y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0), + rotation = json.optInt("rotation", 0).let { ((it % 360) + 360) % 360 }.let { if (it % 90 == 0) it else 0 }, ) } catch (_: Exception) { CropSettings() } } - private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject { - val crop = crop(context, file) + private fun galleryImage(context: Context, entry: Entry, selected: Boolean): JSObject { + val crop = crop(context, entry) return JSObject().apply { - put("id", file.name) - put("url", thumbnailDataUrl(file)) + put("id", entry.id) + put("url", thumbnailDataUrl(entry.preview)) put("selected", selected) put("cropMode", crop.mode) put("cropZoom", crop.zoom) put("cropPositionX", crop.x) put("cropPositionY", crop.y) + put("cropRotation", crop.rotation) } } @Synchronized - fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double): JSObject? { - if (id.isBlank() || File(id).name != id) return null - val file = files(context).firstOrNull { it.name == id } ?: return null + fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double, rotation: Int): JSObject? { + val items = entries(context) + val entry = items.firstOrNull { it.id == id } ?: return null val normalized = CropSettings( mode = if (mode == "contain") "contain" else "cover", - zoom = zoom.coerceIn(1.0, 3.0), - x = x.coerceIn(0.0, 1.0), - y = y.coerceIn(0.0, 1.0), + zoom = zoom.coerceIn(1.0, 3.0), x = x.coerceIn(0.0, 1.0), y = y.coerceIn(0.0, 1.0), + rotation = ((rotation % 360) + 360) % 360, ) + require(normalized.rotation % 90 == 0) { "Ungültige Bilddrehung" } val json = JSONObject().apply { - put("mode", normalized.mode) - put("zoom", normalized.zoom) - put("x", normalized.x) - put("y", normalized.y) + put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation) } prefs(context).edit().putString(cropKey(id), json.toString()).apply() - val selectedIndex = prefs(context).getInt(KEY_INDEX, 0) - return galleryImage(context, file, files(context).indexOf(file) == selectedIndex) + return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items)) } @Synchronized - fun delete(context: Context, id: String): Boolean { - return deleteMany(context, listOf(id)) == 1 + fun addImmich(context: Context, serverUrl: String, assetId: String, previewSource: File): Boolean { + val key = virtualKey(serverUrl, assetId) + val metadata = File(immichEntries(context), "$key.json") + val preview = File(immichPreviews(context), "$key.preview") + if (metadata.isFile && preview.isFile) return false + val previewTemp = File(immichPreviews(context), ".$key.preview.tmp") + val metadataTemp = File(immichEntries(context), ".$key.json.tmp") + try { + previewSource.copyTo(previewTemp, overwrite = true) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(previewTemp.absolutePath, bounds) + require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich-Vorschau konnte nicht gelesen werden." } + if (!previewTemp.renameTo(preview)) { previewTemp.copyTo(preview, overwrite = true); previewTemp.delete() } + metadataTemp.writeText(JSONObject().apply { + put("version", 1); put("serverUrl", serverUrl); put("assetId", assetId); put("addedAt", System.currentTimeMillis()) + }.toString()) + if (!metadataTemp.renameTo(metadata)) { metadataTemp.copyTo(metadata, overwrite = true); metadataTemp.delete() } + return true + } catch (error: Exception) { + previewTemp.delete() + metadataTemp.delete() + if (!metadata.isFile) preview.delete() + throw error + } } + fun hasImmich(context: Context, serverUrl: String, assetId: String) = File(immichEntries(context), "${virtualKey(serverUrl, assetId)}.json").isFile + + @Synchronized + fun delete(context: Context, id: String) = deleteMany(context, listOf(id)) == 1 + @Synchronized fun deleteMany(context: Context, ids: List): Int { - val requested = ids.filter { it.isNotBlank() && File(it).name == it }.toSet() - if (requested.isEmpty()) return 0 - val originals = files(context) - val preferences = prefs(context) - val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) - val currentName = originals.getOrNull(previousIndex)?.name - val deleted = originals.filter { it.name in requested && it.delete() } + val requested = ids.toSet() + val items = entries(context) + val current = items.getOrNull(currentIndex(context, items)) + val deleted = items.filter { it.id in requested }.filter { entry -> + when (entry) { + is Entry.Local -> entry.file.delete().also { if (it) removeThumbnail(entry.preview) } + is Entry.Immich -> { + entry.metadata.delete().also { removed -> + if (removed) { + removeThumbnail(entry.preview) + entry.preview.delete() + ImmichClient.deleteCachedOriginal(context, entry.serverUrl, entry.assetId) + } + } + } + } + } if (deleted.isEmpty()) return 0 - val remaining = files(context) - val retainedCurrent = currentName?.let { name -> remaining.indexOfFirst { it.name == name } } ?: -1 - val nextIndex = when { - remaining.isEmpty() -> 0 - retainedCurrent >= 0 -> retainedCurrent - else -> previousIndex.coerceAtMost(remaining.lastIndex) + val editor = prefs(context).edit() + deleted.forEach { editor.remove(cropKey(it.id)) } + val remaining = entries(context) + val retained = current?.let { item -> remaining.indexOfFirst { it.id == item.id } } ?: -1 + if (remaining.isEmpty()) editor.remove(KEY_CURRENT_ID).putInt(KEY_INDEX, 0) + else { + val next = if (retained >= 0) retained else currentIndex(context, items).coerceAtMost(remaining.lastIndex) + editor.putString(KEY_CURRENT_ID, remaining[next].id).putInt(KEY_INDEX, next) } - val editor = preferences.edit().putInt(KEY_INDEX, nextIndex) - deleted.forEach { editor.remove(cropKey(it.name)) } editor.apply() return deleted.size } @@ -164,21 +256,25 @@ object WallpaperStore { val metrics = context.resources.displayMetrics val targetWidth = metrics.widthPixels.coerceAtLeast(1) val targetHeight = metrics.heightPixels.coerceAtLeast(1) - val widthScale = targetWidth.toDouble() / source.width - val heightScale = targetHeight.toDouble() / source.height + val quarterTurn = crop.rotation == 90 || crop.rotation == 270 + val rotatedWidth = if (quarterTurn) source.height else source.width + val rotatedHeight = if (quarterTurn) source.width else source.height + val widthScale = targetWidth.toDouble() / rotatedWidth + val heightScale = targetHeight.toDouble() / rotatedHeight val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale) val scale = (baseScale * crop.zoom).toFloat() - val scaledWidth = source.width * scale - val scaledHeight = source.height * scale + val scaledWidth = rotatedWidth * scale + val scaledHeight = rotatedHeight * scale val left = ((targetWidth - scaledWidth) * crop.x).toFloat() val top = ((targetHeight - scaledHeight) * crop.y).toFloat() - return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output -> val canvas = Canvas(output) canvas.drawColor(Color.BLACK) val matrix = Matrix().apply { - setScale(scale, scale) - postTranslate(left, top) + postTranslate(-source.width / 2f, -source.height / 2f) + postRotate(crop.rotation.toFloat()) + postScale(scale, scale) + postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f) } canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)) } @@ -188,14 +284,10 @@ object WallpaperStore { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.absolutePath, bounds) if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - val metrics = context.resources.displayMetrics - val targetLongSide = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1) var sample = 1 while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2 - return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { - inSampleSize = sample - inPreferredConfig = Bitmap.Config.ARGB_8888 - }) + return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 }) } private fun thumbnailDataUrl(file: File): String { @@ -207,34 +299,54 @@ object WallpaperStore { while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2 val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return "" val result = ByteArrayOutputStream().use { out -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out) - bitmap.recycle() + bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle() "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) } thumbnailCache.put(cacheKey, result) return result } + private fun removeThumbnail(file: File) { + thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}") + } + @Synchronized fun applyNext(context: Context): Boolean { - val images = files(context) - if (images.isEmpty()) return false - val preferences = prefs(context) - val previous = preferences.getInt(KEY_INDEX, -1) - val index = if (shuffle(context) && images.size > 1) { - generateSequence { Random.nextInt(images.size) }.first { it != previous } - } else (previous + 1).mod(images.size) - val bitmap = decodeForScreen(context, images[index]) ?: return false - val rendered = renderForScreen(context, bitmap, crop(context, images[index])) - try { - val manager = WallpaperManager.getInstance(context) - if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK) - else manager.setBitmap(rendered) - preferences.edit().putInt(KEY_INDEX, index).apply() - return true - } finally { - rendered.recycle() - bitmap.recycle() + val items = entries(context) + if (items.isEmpty()) return false + val previous = currentIndex(context, items) + val candidates = if (shuffle(context) && items.size > 1) { + items.indices.filter { it != previous }.shuffled() + previous + } else (1..items.size).map { (previous + it).mod(items.size) } + val unavailableServers = mutableSetOf() + for (index in candidates) { + val entry = items[index] + val sourceFile = when (entry) { + is Entry.Local -> entry.file + is Entry.Immich -> { + val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, entry.serverUrl !in unavailableServers) + if (source == null) { + unavailableServers.add(entry.serverUrl) + continue + } + source + } + } + val bitmap = decodeForScreen(context, sourceFile) ?: continue + val rendered = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull() + if (rendered == null) { bitmap.recycle(); continue } + try { + val manager = WallpaperManager.getInstance(context) + if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK) + else manager.setBitmap(rendered) + prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply() + return true + } catch (_: Exception) { + // Try the next usable entry without changing the current selection. + } finally { + rendered.recycle(); bitmap.recycle() + } } + return false } } diff --git a/plugins/src/commands.rs b/plugins/src/commands.rs index 87018c2..24fac1d 100644 --- a/plugins/src/commands.rs +++ b/plugins/src/commands.rs @@ -44,6 +44,7 @@ pub(crate) async fn set_image_crop( zoom: f64, position_x: f64, position_y: f64, + rotation: i32, ) -> Result { app.wallpaper().set_image_crop(ImageCropRequest { id, @@ -51,6 +52,7 @@ pub(crate) async fn set_image_crop( zoom, position_x, position_y, + rotation, }) } #[command] diff --git a/plugins/src/desktop.rs b/plugins/src/desktop.rs index c1d465a..9c5c029 100644 --- a/plugins/src/desktop.rs +++ b/plugins/src/desktop.rs @@ -47,6 +47,7 @@ impl Wallpaper { crop_zoom: 1.0, crop_position_x: 0.5, crop_position_y: 0.5, + crop_rotation: 0, }) .collect(); Ok(GalleryPage { total: 3, items }) @@ -77,6 +78,7 @@ impl Wallpaper { crop_zoom: payload.zoom, crop_position_x: payload.position_x, crop_position_y: payload.position_y, + crop_rotation: payload.rotation, }) } pub fn set_setting(&self, payload: SettingRequest) -> crate::Result { diff --git a/plugins/src/models.rs b/plugins/src/models.rs index e564b2c..c79f919 100644 --- a/plugins/src/models.rs +++ b/plugins/src/models.rs @@ -58,6 +58,7 @@ pub struct ImageCropRequest { pub zoom: f64, pub position_x: f64, pub position_y: f64, + pub rotation: i32, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -70,6 +71,7 @@ pub struct GalleryImage { pub crop_zoom: f64, pub crop_position_x: f64, pub crop_position_y: f64, + pub crop_rotation: i32, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] diff --git a/src/App.tsx b/src/App.tsx index 028fc1f..caaad24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react"; +import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react"; import { connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getImmichImportProgress, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImmichAlbum, type ImmichAsset, type ImmichConnection, type ImmichImportProgress, type WallpaperState } from "./native"; import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local"; import { immichTranslations } from "./immich-i18n"; @@ -278,8 +278,11 @@ export default function App() { if (!editing || !preview || !image) return null; if (!image.naturalWidth || !image.naturalHeight) return null; const bounds = preview.getBoundingClientRect(); - const scaleX = bounds.width / image.naturalWidth; - const scaleY = bounds.height / image.naturalHeight; + const quarterTurn = editing.cropRotation === 90 || editing.cropRotation === 270; + const imageWidth = quarterTurn ? image.naturalHeight : image.naturalWidth; + const imageHeight = quarterTurn ? image.naturalWidth : image.naturalHeight; + const scaleX = bounds.width / imageWidth; + const scaleY = bounds.height / imageHeight; const baseScale = editing.cropMode === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY); return { ...gestureGeometry(), @@ -290,11 +293,29 @@ export default function App() { top: bounds.top, width: bounds.width, height: bounds.height, - baseWidth: image.naturalWidth * baseScale, - baseHeight: image.naturalHeight * baseScale, + baseWidth: imageWidth * baseScale, + baseHeight: imageHeight * baseScale, }; } + function rotateEditing() { + setEditing(previous => previous ? { + ...previous, + cropRotation: (previous.cropRotation + 90) % 360, + cropZoom: 1, + cropPositionX: 0.5, + cropPositionY: 0.5, + } : previous); + } + + function previewPosition(image: GalleryImage) { + const { cropPositionX: x, cropPositionY: y } = image; + if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`; + if (image.cropRotation === 180) return `${(1 - x) * 100}% ${(1 - y) * 100}%`; + if (image.cropRotation === 270) return `${(1 - y) * 100}% ${x * 100}%`; + return `${x * 100}% ${y * 100}%`; + } + function beginPreviewGesture(event: React.PointerEvent) { if (!editing) return; event.preventDefault(); @@ -407,16 +428,17 @@ export default function App() { {!galleryLoading && gallery.length < galleryTotal && } : editing &&
- {t.cropPreview} +
{t.cropPreview}
12:34{previewDate}
+
{t.adjustOnScreen}{editing.cropZoom.toFixed(2)}×

{t.gestureHelp}

- +
} diff --git a/src/i18n-local.ts b/src/i18n-local.ts index e12de26..4b684c5 100644 --- a/src/i18n-local.ts +++ b/src/i18n-local.ts @@ -20,7 +20,7 @@ const en = { galleryLoading: "Loading gallery …", loadMore: "Load more images", editCrop: "Edit image crop", gestureLabel: "Move the image with one finger and zoom with two fingers", cropPreview: "Image crop preview", imageFit: "Image fit", fill: "Fill", fit: "Fit", adjustOnScreen: "Adjust directly on screen", gestureHelp: "Drag inside the display or pinch with two fingers", - zoom: "Zoom", reset: "Reset", deleteConfirm: "Do you really want to delete this image from your collection?", + zoom: "Zoom", rotate: "Rotate 90°", reset: "Reset", deleteConfirm: "Do you really want to delete this image from your collection?", galleryLoadFailed: "The gallery could not be loaded", automaticDisabled: "Automatic rotation is paused", settingSaved: "Setting saved", intervalSaved: "Rotation interval saved", androidOnly: "This feature is available on Android", imagesAdded: "Images added to your collection", selectionCancelled: "Selection cancelled", imageSelectionAndroid: "Image selection is available on Android", imageDeleted: "Image deleted", @@ -46,7 +46,7 @@ const copies: Record, Copy> = { galleryLoading: "Galerie wird geladen …", loadMore: "Weitere Bilder laden", editCrop: "Bildausschnitt bearbeiten", gestureLabel: "Bild mit einem Finger verschieben und mit zwei Fingern zoomen", cropPreview: "Vorschau des Bildausschnitts", imageFit: "Bildanpassung", fill: "Ausfüllen", fit: "Einpassen", adjustOnScreen: "Direkt im Bildschirm anpassen", gestureHelp: "Im Display ziehen oder mit zwei Fingern zoomen", - zoom: "Zoom", reset: "Zurücksetzen", deleteConfirm: "Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?", + zoom: "Zoom", rotate: "Um 90° drehen", reset: "Zurücksetzen", deleteConfirm: "Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?", galleryLoadFailed: "Galerie konnte nicht geladen werden", automaticDisabled: "Automatischer Wechsel pausiert", settingSaved: "Einstellung gespeichert", intervalSaved: "Wechselintervall gespeichert", androidOnly: "Diese Funktion ist auf Android verfügbar", imagesAdded: "Bilder wurden zur Sammlung hinzugefügt", selectionCancelled: "Auswahl abgebrochen", imageSelectionAndroid: "Bildauswahl ist auf Android verfügbar", imageDeleted: "Bild wurde gelöscht", @@ -67,7 +67,7 @@ const copies: Record, Copy> = { galleryLoading: "Chargement de la galerie…", loadMore: "Charger plus d’images", editCrop: "Modifier le cadrage", gestureLabel: "Déplacez l’image avec un doigt et zoomez avec deux doigts", cropPreview: "Aperçu du cadrage", imageFit: "Ajustement de l’image", fill: "Remplir", fit: "Adapter", adjustOnScreen: "Ajuster directement à l’écran", gestureHelp: "Faites glisser dans l’écran ou pincez avec deux doigts", - zoom: "Zoom", reset: "Réinitialiser", deleteConfirm: "Voulez-vous vraiment supprimer cette image de votre collection ?", + zoom: "Zoom", rotate: "Tourner de 90°", reset: "Réinitialiser", deleteConfirm: "Voulez-vous vraiment supprimer cette image de votre collection ?", galleryLoadFailed: "Impossible de charger la galerie", automaticDisabled: "La rotation automatique est en pause", settingSaved: "Paramètre enregistré", intervalSaved: "Intervalle enregistré", androidOnly: "Cette fonction est disponible sur Android", imagesAdded: "Images ajoutées à votre collection", selectionCancelled: "Sélection annulée", imageSelectionAndroid: "La sélection d’images est disponible sur Android", imageDeleted: "Image supprimée", @@ -88,7 +88,7 @@ const copies: Record, Copy> = { galleryLoading: "Cargando galería…", loadMore: "Cargar más imágenes", editCrop: "Editar recorte", gestureLabel: "Mueve la imagen con un dedo y amplía con dos", cropPreview: "Vista previa del recorte", imageFit: "Ajuste de imagen", fill: "Rellenar", fit: "Encajar", adjustOnScreen: "Ajustar directamente en pantalla", gestureHelp: "Arrastra dentro de la pantalla o pellizca con dos dedos", - zoom: "Zoom", reset: "Restablecer", deleteConfirm: "¿Quieres eliminar esta imagen de tu colección?", + zoom: "Zoom", rotate: "Girar 90°", reset: "Restablecer", deleteConfirm: "¿Quieres eliminar esta imagen de tu colección?", galleryLoadFailed: "No se pudo cargar la galería", automaticDisabled: "La rotación automática está en pausa", settingSaved: "Ajuste guardado", intervalSaved: "Intervalo guardado", androidOnly: "Esta función está disponible en Android", imagesAdded: "Imágenes añadidas a tu colección", selectionCancelled: "Selección cancelada", imageSelectionAndroid: "La selección de imágenes está disponible en Android", imageDeleted: "Imagen eliminada", @@ -109,7 +109,7 @@ const copies: Record, Copy> = { galleryLoading: "Caricamento galleria…", loadMore: "Carica altre immagini", editCrop: "Modifica ritaglio", gestureLabel: "Sposta l’immagine con un dito e ingrandisci con due", cropPreview: "Anteprima ritaglio", imageFit: "Adattamento immagine", fill: "Riempi", fit: "Adatta", adjustOnScreen: "Regola direttamente sullo schermo", gestureHelp: "Trascina nello schermo o pizzica con due dita", - zoom: "Zoom", reset: "Ripristina", deleteConfirm: "Vuoi davvero eliminare questa immagine dalla raccolta?", + zoom: "Zoom", rotate: "Ruota di 90°", reset: "Ripristina", deleteConfirm: "Vuoi davvero eliminare questa immagine dalla raccolta?", galleryLoadFailed: "Impossibile caricare la galleria", automaticDisabled: "La rotazione automatica è in pausa", settingSaved: "Impostazione salvata", intervalSaved: "Intervallo salvato", androidOnly: "Questa funzione è disponibile su Android", imagesAdded: "Immagini aggiunte alla raccolta", selectionCancelled: "Selezione annullata", imageSelectionAndroid: "La selezione immagini è disponibile su Android", imageDeleted: "Immagine eliminata", @@ -130,7 +130,7 @@ const copies: Record, Copy> = { galleryLoading: "Galerij laden…", loadMore: "Meer afbeeldingen laden", editCrop: "Uitsnede bewerken", gestureLabel: "Verplaats met één vinger en zoom met twee vingers", cropPreview: "Voorbeeld van uitsnede", imageFit: "Afbeelding passend maken", fill: "Vullen", fit: "Passend", adjustOnScreen: "Direct op het scherm aanpassen", gestureHelp: "Sleep in het scherm of knijp met twee vingers", - zoom: "Zoom", reset: "Herstellen", deleteConfirm: "Wil je deze afbeelding echt uit je collectie verwijderen?", + zoom: "Zoom", rotate: "90° draaien", reset: "Herstellen", deleteConfirm: "Wil je deze afbeelding echt uit je collectie verwijderen?", galleryLoadFailed: "De galerij kon niet worden geladen", automaticDisabled: "Automatisch wisselen is gepauzeerd", settingSaved: "Instelling opgeslagen", intervalSaved: "Wisselinterval opgeslagen", androidOnly: "Deze functie is beschikbaar op Android", imagesAdded: "Afbeeldingen aan je collectie toegevoegd", selectionCancelled: "Selectie geannuleerd", imageSelectionAndroid: "Afbeeldingen kiezen is beschikbaar op Android", imageDeleted: "Afbeelding verwijderd", @@ -151,7 +151,7 @@ const copies: Record, Copy> = { galleryLoading: "Wczytywanie galerii…", loadMore: "Wczytaj więcej obrazów", editCrop: "Edytuj kadr", gestureLabel: "Przesuwaj jednym palcem i powiększaj dwoma", cropPreview: "Podgląd kadru", imageFit: "Dopasowanie obrazu", fill: "Wypełnij", fit: "Dopasuj", adjustOnScreen: "Dopasuj bezpośrednio na ekranie", gestureHelp: "Przeciągnij na ekranie lub uszczypnij dwoma palcami", - zoom: "Powiększenie", reset: "Resetuj", deleteConfirm: "Czy na pewno usunąć ten obraz z kolekcji?", + zoom: "Powiększenie", rotate: "Obróć o 90°", reset: "Resetuj", deleteConfirm: "Czy na pewno usunąć ten obraz z kolekcji?", galleryLoadFailed: "Nie udało się wczytać galerii", automaticDisabled: "Automatyczna zmiana jest wstrzymana", settingSaved: "Ustawienie zapisane", intervalSaved: "Interwał zapisany", androidOnly: "Ta funkcja jest dostępna na Androidzie", imagesAdded: "Obrazy dodano do kolekcji", selectionCancelled: "Anulowano wybór", imageSelectionAndroid: "Wybór obrazów jest dostępny na Androidzie", imageDeleted: "Obraz usunięty", @@ -172,7 +172,7 @@ const copies: Record, Copy> = { galleryLoading: "A carregar galeria…", loadMore: "Carregar mais imagens", editCrop: "Editar recorte", gestureLabel: "Mova a imagem com um dedo e amplie com dois", cropPreview: "Pré-visualização do recorte", imageFit: "Ajuste da imagem", fill: "Preencher", fit: "Ajustar", adjustOnScreen: "Ajustar diretamente no ecrã", gestureHelp: "Arraste no ecrã ou aproxime dois dedos", - zoom: "Zoom", reset: "Repor", deleteConfirm: "Pretende mesmo eliminar esta imagem da coleção?", + zoom: "Zoom", rotate: "Rodar 90°", reset: "Repor", deleteConfirm: "Pretende mesmo eliminar esta imagem da coleção?", galleryLoadFailed: "Não foi possível carregar a galeria", automaticDisabled: "A rotação automática está em pausa", settingSaved: "Definição guardada", intervalSaved: "Intervalo guardado", androidOnly: "Esta função está disponível no Android", imagesAdded: "Imagens adicionadas à coleção", selectionCancelled: "Seleção cancelada", imageSelectionAndroid: "A seleção de imagens está disponível no Android", imageDeleted: "Imagem eliminada", @@ -193,7 +193,7 @@ const copies: Record, Copy> = { galleryLoading: "ギャラリーを読み込み中…", loadMore: "さらに読み込む", editCrop: "切り抜きを編集", gestureLabel: "1本指で移動し、2本指でズームします", cropPreview: "切り抜きプレビュー", imageFit: "画像の配置", fill: "画面を埋める", fit: "全体を表示", adjustOnScreen: "画面上で直接調整", gestureHelp: "画面内をドラッグするか、2本指でピンチします", - zoom: "ズーム", reset: "リセット", deleteConfirm: "この画像をコレクションから削除しますか?", + zoom: "ズーム", rotate: "90°回転", reset: "リセット", deleteConfirm: "この画像をコレクションから削除しますか?", galleryLoadFailed: "ギャラリーを読み込めませんでした", automaticDisabled: "自動切り替えは一時停止中です", settingSaved: "設定を保存しました", intervalSaved: "切り替え間隔を保存しました", androidOnly: "この機能は Android で利用できます", imagesAdded: "画像をコレクションに追加しました", selectionCancelled: "選択をキャンセルしました", imageSelectionAndroid: "画像選択は Android で利用できます", imageDeleted: "画像を削除しました", @@ -214,7 +214,7 @@ const copies: Record, Copy> = { galleryLoading: "갤러리 불러오는 중…", loadMore: "이미지 더 불러오기", editCrop: "자르기 편집", gestureLabel: "한 손가락으로 이동하고 두 손가락으로 확대하세요", cropPreview: "자르기 미리보기", imageFit: "이미지 맞춤", fill: "채우기", fit: "맞추기", adjustOnScreen: "화면에서 직접 조정", gestureHelp: "화면 안에서 드래그하거나 두 손가락으로 확대하세요", - zoom: "확대", reset: "초기화", deleteConfirm: "이 이미지를 컬렉션에서 삭제할까요?", + zoom: "확대", rotate: "90° 회전", reset: "초기화", deleteConfirm: "이 이미지를 컬렉션에서 삭제할까요?", galleryLoadFailed: "갤러리를 불러올 수 없습니다", automaticDisabled: "자동 변경이 일시 중지되었습니다", settingSaved: "설정을 저장했습니다", intervalSaved: "변경 간격을 저장했습니다", androidOnly: "이 기능은 Android에서 사용할 수 있습니다", imagesAdded: "컬렉션에 이미지를 추가했습니다", selectionCancelled: "선택을 취소했습니다", imageSelectionAndroid: "이미지 선택은 Android에서 사용할 수 있습니다", imageDeleted: "이미지를 삭제했습니다", @@ -235,7 +235,7 @@ const copies: Record, Copy> = { galleryLoading: "正在加载图库…", loadMore: "加载更多图片", editCrop: "编辑裁剪", gestureLabel: "单指移动图片,双指缩放", cropPreview: "裁剪预览", imageFit: "图片适配", fill: "填充", fit: "适应", adjustOnScreen: "直接在屏幕中调整", gestureHelp: "在屏幕内拖动或双指缩放", - zoom: "缩放", reset: "重置", deleteConfirm: "确定要从收藏中删除此图片吗?", + zoom: "缩放", rotate: "旋转 90°", reset: "重置", deleteConfirm: "确定要从收藏中删除此图片吗?", galleryLoadFailed: "无法加载图库", automaticDisabled: "自动更换已暂停", settingSaved: "设置已保存", intervalSaved: "更换间隔已保存", androidOnly: "此功能可在 Android 上使用", imagesAdded: "图片已添加到收藏", selectionCancelled: "已取消选择", imageSelectionAndroid: "图片选择功能可在 Android 上使用", imageDeleted: "图片已删除", diff --git a/src/immich-i18n.ts b/src/immich-i18n.ts index ec97640..905c7f2 100644 --- a/src/immich-i18n.ts +++ b/src/immich-i18n.ts @@ -27,7 +27,7 @@ export type ImmichText = { export const immichTranslations: Record = { de: { immich: "Immich", - immichIntro: "Importiere Bilder aus deinem eigenen Immich-Server.", + immichIntro: "Füge Immich-Bilder virtuell hinzu. Vorschauen und zuletzt verwendete Originale bleiben lokal verfügbar.", serverUrl: "Server-URL", apiKey: "API-Key", apiKeyPlaceholder: "Nur beim Verbinden erforderlich", @@ -42,9 +42,9 @@ export const immichTranslations: Record = { httpWarning: "HTTP ist unverschlüsselt. Verwende außerhalb deines Heimnetzes HTTPS.", allPhotos: "Alle Fotos", selectImmichPhotos: "Wähle Bilder zum Importieren aus", - importSelected: "Auswahl importieren", + importSelected: "Zur Sammlung hinzufügen", importing: "Bilder werden importiert…", - importSuccess: "Immich-Bilder wurden importiert", + importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt", immichLoadFailed: "Immich-Bilder konnten nicht geladen werden", noImmichPhotos: "Keine Bilder gefunden", }, diff --git a/src/native.ts b/src/native.ts index 9567b89..c4ea917 100644 --- a/src/native.ts +++ b/src/native.ts @@ -18,6 +18,7 @@ export type GalleryImage = { cropZoom: number; cropPositionX: number; cropPositionY: number; + cropRotation: number; }; export type GalleryPage = { @@ -70,8 +71,8 @@ const demoState: WallpaperState = { }; const inTauri = () => "__TAURI_INTERNALS__" in window; -const demoCrops = new Map>(); -const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 }; +const demoCrops = new Map>(); +const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 }; let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" }; export async function getState(): Promise { @@ -126,6 +127,7 @@ export async function setImageCrop(image: GalleryImage): Promise { zoom: image.cropZoom, positionX: image.cropPositionX, positionY: image.cropPositionY, + rotation: image.cropRotation, }; if (inTauri()) return invoke("plugin:wallpaper|set_image_crop", payload); demoCrops.set(image.id, { @@ -133,6 +135,7 @@ export async function setImageCrop(image: GalleryImage): Promise { cropZoom: image.cropZoom, cropPositionX: image.cropPositionX, cropPositionY: image.cropPositionY, + cropRotation: image.cropRotation, }); return { ...image }; } diff --git a/src/styles.css b/src/styles.css index bc7caa2..3b8c694 100644 --- a/src/styles.css +++ b/src/styles.css @@ -153,10 +153,12 @@ nav svg { width: 21px; } .save-crop { background: var(--green); color: white; } .crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); } .phone-preview { position: relative; width: min(72vw, 270px); aspect-ratio: 9 / 19.5; margin: 0 auto 22px; overflow: hidden; border: 7px solid #202621; border-radius: 34px; background: #090b09; box-shadow: 0 18px 38px rgba(20,35,24,.22); } -.phone-preview > img { width: 100%; height: 100%; display: block; transition: transform .16s ease, object-position .16s ease; } +.preview-image-frame { position: absolute; inset: 0; transform-origin: center; transition: transform .16s ease; } +.preview-image-frame.quarter-turn { inset: 26.923% -58.333%; } +.preview-image-frame > img { width: 100%; height: 100%; display: block; transition: object-position .16s ease; } .phone-preview.interactive { touch-action: none; user-select: none; cursor: grab; } .phone-preview.interactive:active { cursor: grabbing; } -.phone-preview.interactive > img { pointer-events: none; user-select: none; transition: none; } +.phone-preview.interactive .preview-image-frame, .phone-preview.interactive img { pointer-events: none; user-select: none; transition: none; } .crop-grid { position: absolute; z-index: 2; inset: 0; pointer-events: none; opacity: .48; } .crop-grid i { position: absolute; display: block; background: rgba(255,255,255,.72); box-shadow: 0 0 2px rgba(0,0,0,.38); } .crop-grid i:nth-child(1), .crop-grid i:nth-child(2) { top: 0; bottom: 0; width: 1px; } @@ -171,6 +173,9 @@ nav svg { width: 21px; } .fit-toggle { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; margin-bottom: 17px; border-radius: 13px; background: #edf2eb; } .fit-toggle button { height: 38px; border: 0; border-radius: 10px; color: #68736b; background: transparent; font-size: 13px; font-weight: 750; } .fit-toggle button.active { color: white; background: var(--green); box-shadow: 0 3px 9px rgba(20,93,50,.18); } +.rotate-image { width: 100%; height: 42px; margin-bottom: 15px; padding: 0 13px; border: 0; border-radius: 12px; color: var(--green); background: var(--sage); display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 750; } +.rotate-image svg { width: 17px; } +.rotate-image strong { margin-left: auto; } .crop-controls label { display: block; margin-top: 13px; color: #58635b; font-size: 12px; font-weight: 700; } .crop-controls label span { display: flex; justify-content: space-between; } .crop-controls label strong { color: var(--green); }