feat(wallpapers): add Immich integration with caching and rotation

This patch adds Immich integration for remote images with local
caching and pruning.
It adds image rotation to the crop flow and stores rotation
with the crop state.
It adds progress tracking and size checks during image
download to avoid oversized assets.

- Add Immich caching with size limits and automatic pruning
- Extend crop handling to support rotation and persist it
- Improve download progress and error messaging
This commit is contained in:
2026-08-21 22:01:42 +02:00
parent 789b58398a
commit e494b17117
11 changed files with 343 additions and 137 deletions
+76 -22
View File
@@ -15,6 +15,7 @@ import java.net.URI
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.KeyStore import java.security.KeyStore
import java.security.MessageDigest
import java.util.concurrent.Callable import java.util.concurrent.Callable
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference 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_DATA = "immich_api_key_data"
private const val KEY_API_KEY_IV = "immich_api_key_iv" private const val KEY_API_KEY_IV = "immich_api_key_iv"
private const val KEY_ALIAS = "wallpaperflow_immich_api_key" 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 assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
private val thumbnailPool = Executors.newFixedThreadPool(4) private val thumbnailPool = Executors.newFixedThreadPool(4)
private val importProgress = AtomicReference(ImportProgress()) private val importProgress = AtomicReference(ImportProgress())
@@ -193,13 +197,14 @@ object ImmichClient {
} finally { connection.disconnect() } } 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) val connection = openConnection(serverUrl, apiKey, path)
try { try {
if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection)) if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
val total = connection.contentLengthLong.coerceAtLeast(0) val total = connection.contentLengthLong.coerceAtLeast(0)
require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
var downloaded = 0L 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 -> connection.inputStream.use { input ->
target.outputStream().use { output -> target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
@@ -208,7 +213,8 @@ object ImmichClient {
if (count < 0) break if (count < 0) break
output.write(buffer, 0, count) output.write(buffer, 0, count)
downloaded += 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) { private fun importOne(context: Context, credentials: Credentials, assetId: String) {
require(assetIdPattern.matches(assetId)) { "Ungültige Immich-Bild-ID." } 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 encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
val directory = WallpaperStore.directory(context) val temporary = File(context.cacheDir, ".immich-preview-$assetId.download")
val temporary = File(directory, ".immich-$assetId.download") downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary)
var contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/original", temporary)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(temporary.absolutePath, bounds) BitmapFactory.decodeFile(temporary.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat keine gültige Vorschau geliefert." }
temporary.delete() try { WallpaperStore.addImmich(context, credentials.serverUrl, assetId, temporary) }
contentType = downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary) finally { temporary.delete() }
BitmapFactory.decodeFile(temporary.absolutePath, bounds)
require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat kein unterstütztes Bildformat geliefert." }
} }
val target = File(directory, "immich-$assetId.${extension(contentType)}")
if (!temporary.renameTo(target)) { private fun cacheDirectory(context: Context) = File(context.cacheDir, "immich-wallpaper-originals").apply { mkdirs() }
temporary.copyTo(target, overwrite = true)
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
}
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() 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()
}
} }
} }
@@ -38,6 +38,7 @@ class ImageCropArgs {
var zoom: Double = 1.0 var zoom: Double = 1.0
var positionX: Double = 0.5 var positionX: Double = 0.5
var positionY: Double = 0.5 var positionY: Double = 0.5
var rotation: Int = 0
} }
@InvokeArg @InvokeArg
@@ -99,7 +100,7 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
@Command fun setImageCrop(invoke: Invoke) = io.execute { @Command fun setImageCrop(invoke: Invoke) = io.execute {
try { try {
val args = invoke.parseArgs(ImageCropArgs::class.java) 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") ?: throw IllegalArgumentException("Bild wurde nicht gefunden")
invoke.resolve(image) invoke.resolve(image)
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") } } 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 { @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 { @Command fun getImmichConnection(invoke: Invoke) = io.execute {
+194 -82
View File
@@ -14,21 +14,79 @@ import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import kotlin.random.Random import java.security.MessageDigest
import org.json.JSONObject import org.json.JSONObject
object WallpaperStore { object WallpaperStore {
private const val PREFS = "wechselbild" private const val PREFS = "wechselbild"
private const val KEY_INDEX = "current_index" 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 KEY_INTERVAL = "interval_minutes"
private const val CROP_PREFIX = "crop_" private const val CROP_PREFIX = "crop_"
private const val HOME_PREVIEW_LIMIT = 12 private const val HOME_PREVIEW_LIMIT = 12
private val thumbnailCache = LruCache<String, String>(48) private val thumbnailCache = LruCache<String, String>(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 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 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<Entry> {
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<Entry.Immich> { it.addedAt }.thenBy { it.id }) ?: emptyList()
return local + virtual
}
private fun currentIndex(context: Context, items: List<Entry>): 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 { fun intervalMinutes(context: Context): Int {
val preferences = prefs(context) val preferences = prefs(context)
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0) 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() prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply()
} }
@Synchronized
fun state(context: Context, includePreviews: Boolean = true): JSObject { fun state(context: Context, includePreviews: Boolean = true): JSObject {
val originals = files(context) val items = entries(context)
val index = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) val index = currentIndex(context, items)
val previewFiles = if (index < HOME_PREVIEW_LIMIT) originals.take(HOME_PREVIEW_LIMIT) else listOf(originals[index]) + originals.take(HOME_PREVIEW_LIMIT - 1) 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 val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0
return JSObject().apply { return JSObject().apply {
put("imageCount", originals.size) put("imageCount", items.size)
put("enabled", enabled(context)) put("enabled", enabled(context))
put("intervalMinutes", intervalMinutes(context)) put("intervalMinutes", intervalMinutes(context))
put("shuffle", shuffle(context)) put("shuffle", shuffle(context))
put("lockScreenOnly", lockOnly(context)) put("lockScreenOnly", lockOnly(context))
put("currentIndex", previewIndex) put("currentIndex", previewIndex)
val previews = JSArray() val previews = JSArray()
if (includePreviews) previewFiles.forEach { previews.put(thumbnailDataUrl(it)) } if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) }
put("imageUrls", previews) put("imageUrls", previews)
} }
} }
@Synchronized
fun gallery(context: Context, offset: Int, limit: Int): JSObject { fun gallery(context: Context, offset: Int, limit: Int): JSObject {
val originals = files(context) val items = entries(context)
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) val selectedIndex = currentIndex(context, items)
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(originals.size) val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size)
val safeLimit = limit.coerceIn(1, 100) val safeLimit = limit.coerceIn(1, 100)
val items = JSArray() val page = JSArray()
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file -> items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry ->
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex)) page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex))
}
return JSObject().apply {
put("total", originals.size)
put("items", items)
} }
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 cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, entry: Entry): CropSettings {
private fun crop(context: Context, file: File): CropSettings { val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
val raw = prefs(context).getString(cropKey(file.name), null) ?: return CropSettings()
return try { return try {
val json = JSONObject(raw) val json = JSONObject(raw)
CropSettings( CropSettings(
@@ -94,68 +151,103 @@ object WallpaperStore {
zoom = json.optDouble("zoom", 1.0).coerceIn(1.0, 3.0), zoom = json.optDouble("zoom", 1.0).coerceIn(1.0, 3.0),
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0), x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
y = json.optDouble("y", 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() } } catch (_: Exception) { CropSettings() }
} }
private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject { private fun galleryImage(context: Context, entry: Entry, selected: Boolean): JSObject {
val crop = crop(context, file) val crop = crop(context, entry)
return JSObject().apply { return JSObject().apply {
put("id", file.name) put("id", entry.id)
put("url", thumbnailDataUrl(file)) put("url", thumbnailDataUrl(entry.preview))
put("selected", selected) put("selected", selected)
put("cropMode", crop.mode) put("cropMode", crop.mode)
put("cropZoom", crop.zoom) put("cropZoom", crop.zoom)
put("cropPositionX", crop.x) put("cropPositionX", crop.x)
put("cropPositionY", crop.y) put("cropPositionY", crop.y)
put("cropRotation", crop.rotation)
} }
} }
@Synchronized @Synchronized
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double): JSObject? { fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double, rotation: Int): JSObject? {
if (id.isBlank() || File(id).name != id) return null val items = entries(context)
val file = files(context).firstOrNull { it.name == id } ?: return null val entry = items.firstOrNull { it.id == id } ?: return null
val normalized = CropSettings( val normalized = CropSettings(
mode = if (mode == "contain") "contain" else "cover", mode = if (mode == "contain") "contain" else "cover",
zoom = zoom.coerceIn(1.0, 3.0), zoom = zoom.coerceIn(1.0, 3.0), x = x.coerceIn(0.0, 1.0), y = y.coerceIn(0.0, 1.0),
x = x.coerceIn(0.0, 1.0), rotation = ((rotation % 360) + 360) % 360,
y = y.coerceIn(0.0, 1.0),
) )
require(normalized.rotation % 90 == 0) { "Ungültige Bilddrehung" }
val json = JSONObject().apply { val json = JSONObject().apply {
put("mode", normalized.mode) put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation)
put("zoom", normalized.zoom)
put("x", normalized.x)
put("y", normalized.y)
} }
prefs(context).edit().putString(cropKey(id), json.toString()).apply() prefs(context).edit().putString(cropKey(id), json.toString()).apply()
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0) return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items))
return galleryImage(context, file, files(context).indexOf(file) == selectedIndex)
} }
@Synchronized @Synchronized
fun delete(context: Context, id: String): Boolean { fun addImmich(context: Context, serverUrl: String, assetId: String, previewSource: File): Boolean {
return deleteMany(context, listOf(id)) == 1 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 @Synchronized
fun deleteMany(context: Context, ids: List<String>): Int { fun deleteMany(context: Context, ids: List<String>): Int {
val requested = ids.filter { it.isNotBlank() && File(it).name == it }.toSet() val requested = ids.toSet()
if (requested.isEmpty()) return 0 val items = entries(context)
val originals = files(context) val current = items.getOrNull(currentIndex(context, items))
val preferences = prefs(context) val deleted = items.filter { it.id in requested }.filter { entry ->
val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0)) when (entry) {
val currentName = originals.getOrNull(previousIndex)?.name is Entry.Local -> entry.file.delete().also { if (it) removeThumbnail(entry.preview) }
val deleted = originals.filter { it.name in requested && it.delete() } 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 if (deleted.isEmpty()) return 0
val remaining = files(context) val editor = prefs(context).edit()
val retainedCurrent = currentName?.let { name -> remaining.indexOfFirst { it.name == name } } ?: -1 deleted.forEach { editor.remove(cropKey(it.id)) }
val nextIndex = when { val remaining = entries(context)
remaining.isEmpty() -> 0 val retained = current?.let { item -> remaining.indexOfFirst { it.id == item.id } } ?: -1
retainedCurrent >= 0 -> retainedCurrent if (remaining.isEmpty()) editor.remove(KEY_CURRENT_ID).putInt(KEY_INDEX, 0)
else -> previousIndex.coerceAtMost(remaining.lastIndex) 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() editor.apply()
return deleted.size return deleted.size
} }
@@ -164,21 +256,25 @@ object WallpaperStore {
val metrics = context.resources.displayMetrics val metrics = context.resources.displayMetrics
val targetWidth = metrics.widthPixels.coerceAtLeast(1) val targetWidth = metrics.widthPixels.coerceAtLeast(1)
val targetHeight = metrics.heightPixels.coerceAtLeast(1) val targetHeight = metrics.heightPixels.coerceAtLeast(1)
val widthScale = targetWidth.toDouble() / source.width val quarterTurn = crop.rotation == 90 || crop.rotation == 270
val heightScale = targetHeight.toDouble() / source.height 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 baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
val scale = (baseScale * crop.zoom).toFloat() val scale = (baseScale * crop.zoom).toFloat()
val scaledWidth = source.width * scale val scaledWidth = rotatedWidth * scale
val scaledHeight = source.height * scale val scaledHeight = rotatedHeight * scale
val left = ((targetWidth - scaledWidth) * crop.x).toFloat() val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
val top = ((targetHeight - scaledHeight) * crop.y).toFloat() val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output -> return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
val canvas = Canvas(output) val canvas = Canvas(output)
canvas.drawColor(Color.BLACK) canvas.drawColor(Color.BLACK)
val matrix = Matrix().apply { val matrix = Matrix().apply {
setScale(scale, scale) postTranslate(-source.width / 2f, -source.height / 2f)
postTranslate(left, top) 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)) 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 } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds) BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val metrics = context.resources.displayMetrics val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1)
val targetLongSide = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
var sample = 1 var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2 while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 })
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
})
} }
private fun thumbnailDataUrl(file: File): String { private fun thumbnailDataUrl(file: File): String {
@@ -207,34 +299,54 @@ object WallpaperStore {
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2 while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return "" val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
val result = ByteArrayOutputStream().use { out -> val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out) bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
bitmap.recycle()
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} }
thumbnailCache.put(cacheKey, result) thumbnailCache.put(cacheKey, result)
return result return result
} }
private fun removeThumbnail(file: File) {
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}")
}
@Synchronized @Synchronized
fun applyNext(context: Context): Boolean { fun applyNext(context: Context): Boolean {
val images = files(context) val items = entries(context)
if (images.isEmpty()) return false if (items.isEmpty()) return false
val preferences = prefs(context) val previous = currentIndex(context, items)
val previous = preferences.getInt(KEY_INDEX, -1) val candidates = if (shuffle(context) && items.size > 1) {
val index = if (shuffle(context) && images.size > 1) { items.indices.filter { it != previous }.shuffled() + previous
generateSequence { Random.nextInt(images.size) }.first { it != previous } } else (1..items.size).map { (previous + it).mod(items.size) }
} else (previous + 1).mod(images.size) val unavailableServers = mutableSetOf<String>()
val bitmap = decodeForScreen(context, images[index]) ?: return false for (index in candidates) {
val rendered = renderForScreen(context, bitmap, crop(context, images[index])) 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 { try {
val manager = WallpaperManager.getInstance(context) val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK) if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(rendered) else manager.setBitmap(rendered)
preferences.edit().putInt(KEY_INDEX, index).apply() prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
return true return true
} catch (_: Exception) {
// Try the next usable entry without changing the current selection.
} finally { } finally {
rendered.recycle() rendered.recycle(); bitmap.recycle()
bitmap.recycle()
} }
} }
return false
}
} }
+2
View File
@@ -44,6 +44,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
zoom: f64, zoom: f64,
position_x: f64, position_x: f64,
position_y: f64, position_y: f64,
rotation: i32,
) -> Result<GalleryImage> { ) -> Result<GalleryImage> {
app.wallpaper().set_image_crop(ImageCropRequest { app.wallpaper().set_image_crop(ImageCropRequest {
id, id,
@@ -51,6 +52,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
zoom, zoom,
position_x, position_x,
position_y, position_y,
rotation,
}) })
} }
#[command] #[command]
+2
View File
@@ -47,6 +47,7 @@ impl<R: Runtime> Wallpaper<R> {
crop_zoom: 1.0, crop_zoom: 1.0,
crop_position_x: 0.5, crop_position_x: 0.5,
crop_position_y: 0.5, crop_position_y: 0.5,
crop_rotation: 0,
}) })
.collect(); .collect();
Ok(GalleryPage { total: 3, items }) Ok(GalleryPage { total: 3, items })
@@ -77,6 +78,7 @@ impl<R: Runtime> Wallpaper<R> {
crop_zoom: payload.zoom, crop_zoom: payload.zoom,
crop_position_x: payload.position_x, crop_position_x: payload.position_x,
crop_position_y: payload.position_y, crop_position_y: payload.position_y,
crop_rotation: payload.rotation,
}) })
} }
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> { pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> {
+2
View File
@@ -58,6 +58,7 @@ pub struct ImageCropRequest {
pub zoom: f64, pub zoom: f64,
pub position_x: f64, pub position_x: f64,
pub position_y: f64, pub position_y: f64,
pub rotation: i32,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -70,6 +71,7 @@ pub struct GalleryImage {
pub crop_zoom: f64, pub crop_zoom: f64,
pub crop_position_x: f64, pub crop_position_x: f64,
pub crop_position_y: f64, pub crop_position_y: f64,
pub crop_rotation: i32,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
+29 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { 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 { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
import { immichTranslations } from "./immich-i18n"; import { immichTranslations } from "./immich-i18n";
@@ -278,8 +278,11 @@ export default function App() {
if (!editing || !preview || !image) return null; if (!editing || !preview || !image) return null;
if (!image.naturalWidth || !image.naturalHeight) return null; if (!image.naturalWidth || !image.naturalHeight) return null;
const bounds = preview.getBoundingClientRect(); const bounds = preview.getBoundingClientRect();
const scaleX = bounds.width / image.naturalWidth; const quarterTurn = editing.cropRotation === 90 || editing.cropRotation === 270;
const scaleY = bounds.height / image.naturalHeight; 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); const baseScale = editing.cropMode === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
return { return {
...gestureGeometry(), ...gestureGeometry(),
@@ -290,11 +293,29 @@ export default function App() {
top: bounds.top, top: bounds.top,
width: bounds.width, width: bounds.width,
height: bounds.height, height: bounds.height,
baseWidth: image.naturalWidth * baseScale, baseWidth: imageWidth * baseScale,
baseHeight: image.naturalHeight * 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) { function beginPreviewGesture(event: React.PointerEvent) {
if (!editing) return; if (!editing) return;
event.preventDefault(); event.preventDefault();
@@ -407,16 +428,17 @@ export default function App() {
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>} {!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}> </section> : editing && <section className="crop-editor" aria-label={t.editCrop}>
<div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}> <div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
<img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%`, transform: `scale(${editing.cropZoom})`, transformOrigin: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%` }} /> <div className={`preview-image-frame ${editing.cropRotation === 90 || editing.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${editing.cropRotation}deg) scale(${editing.cropZoom})` }}><img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: previewPosition(editing) }} /></div>
<div className="crop-grid" aria-hidden="true"><i /><i /><i /><i /></div> <div className="crop-grid" aria-hidden="true"><i /><i /><i /><i /></div>
<div className="preview-clock">12:34<span>{previewDate}</span></div> <div className="preview-clock">12:34<span>{previewDate}</span></div>
</div> </div>
<div className="crop-controls"> <div className="crop-controls">
<div className="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fit}</button></div> <div className="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fit}</button></div>
<button className="rotate-image" onClick={rotateEditing}><RotateCw /> {t.rotate} <strong>{editing.cropRotation}°</strong></button>
<div className="direct-crop-heading"><span><Crop /> {t.adjustOnScreen}</span><strong>{editing.cropZoom.toFixed(2)}×</strong></div> <div className="direct-crop-heading"><span><Crop /> {t.adjustOnScreen}</span><strong>{editing.cropZoom.toFixed(2)}×</strong></div>
<p className="direct-crop-help">{t.gestureHelp}</p> <p className="direct-crop-help">{t.gestureHelp}</p>
<label className="zoom-control"><span>{t.zoom}</span><input type="range" min="1" max="3" step="0.01" value={editing.cropZoom} onChange={event => setEditing({ ...editing, cropZoom: Number(event.target.value) })} /></label> <label className="zoom-control"><span>{t.zoom}</span><input type="range" min="1" max="3" step="0.01" value={editing.cropZoom} onChange={event => setEditing({ ...editing, cropZoom: Number(event.target.value) })} /></label>
<button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}><RotateCcw /> {t.reset}</button> <button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 })}><RotateCcw /> {t.reset}</button>
</div> </div>
</section>} </section>}
</div> </div>
+11 -11
View File
@@ -20,7 +20,7 @@ const en = {
galleryLoading: "Loading gallery …", loadMore: "Load more images", editCrop: "Edit image crop", 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", 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", 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", 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", 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", selectionCancelled: "Selection cancelled", imageSelectionAndroid: "Image selection is available on Android", imageDeleted: "Image deleted",
@@ -46,7 +46,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "Galerie wird geladen …", loadMore: "Weitere Bilder laden", editCrop: "Bildausschnitt bearbeiten", 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", 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", 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", 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", 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", selectionCancelled: "Auswahl abgebrochen", imageSelectionAndroid: "Bildauswahl ist auf Android verfügbar", imageDeleted: "Bild wurde gelöscht",
@@ -67,7 +67,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "Chargement de la galerie…", loadMore: "Charger plus dimages", editCrop: "Modifier le cadrage", galleryLoading: "Chargement de la galerie…", loadMore: "Charger plus dimages", editCrop: "Modifier le cadrage",
gestureLabel: "Déplacez limage avec un doigt et zoomez avec deux doigts", cropPreview: "Aperçu du cadrage", imageFit: "Ajustement de limage", gestureLabel: "Déplacez limage avec un doigt et zoomez avec deux doigts", cropPreview: "Aperçu du cadrage", imageFit: "Ajustement de limage",
fill: "Remplir", fit: "Adapter", adjustOnScreen: "Ajuster directement à l’écran", gestureHelp: "Faites glisser dans l’écran ou pincez avec deux doigts", 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é", 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", 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 dimages est disponible sur Android", imageDeleted: "Image supprimée", selectionCancelled: "Sélection annulée", imageSelectionAndroid: "La sélection dimages est disponible sur Android", imageDeleted: "Image supprimée",
@@ -88,7 +88,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "Cargando galería…", loadMore: "Cargar más imágenes", editCrop: "Editar recorte", 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", 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", 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", 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", 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", 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<Exclude<Language, "en">, Copy> = {
galleryLoading: "Caricamento galleria…", loadMore: "Carica altre immagini", editCrop: "Modifica ritaglio", galleryLoading: "Caricamento galleria…", loadMore: "Carica altre immagini", editCrop: "Modifica ritaglio",
gestureLabel: "Sposta limmagine con un dito e ingrandisci con due", cropPreview: "Anteprima ritaglio", imageFit: "Adattamento immagine", gestureLabel: "Sposta limmagine 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", 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", 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", 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", selectionCancelled: "Selezione annullata", imageSelectionAndroid: "La selezione immagini è disponibile su Android", imageDeleted: "Immagine eliminata",
@@ -130,7 +130,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "Galerij laden…", loadMore: "Meer afbeeldingen laden", editCrop: "Uitsnede bewerken", 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", 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", 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", 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", 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", selectionCancelled: "Selectie geannuleerd", imageSelectionAndroid: "Afbeeldingen kiezen is beschikbaar op Android", imageDeleted: "Afbeelding verwijderd",
@@ -151,7 +151,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "Wczytywanie galerii…", loadMore: "Wczytaj więcej obrazów", editCrop: "Edytuj kadr", 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", 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", 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", 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", 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", 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<Exclude<Language, "en">, Copy> = {
galleryLoading: "A carregar galeria…", loadMore: "Carregar mais imagens", editCrop: "Editar recorte", 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", 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", 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", 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", 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", 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<Exclude<Language, "en">, Copy> = {
galleryLoading: "ギャラリーを読み込み中…", loadMore: "さらに読み込む", editCrop: "切り抜きを編集", galleryLoading: "ギャラリーを読み込み中…", loadMore: "さらに読み込む", editCrop: "切り抜きを編集",
gestureLabel: "1本指で移動し、2本指でズームします", cropPreview: "切り抜きプレビュー", imageFit: "画像の配置", gestureLabel: "1本指で移動し、2本指でズームします", cropPreview: "切り抜きプレビュー", imageFit: "画像の配置",
fill: "画面を埋める", fit: "全体を表示", adjustOnScreen: "画面上で直接調整", gestureHelp: "画面内をドラッグするか、2本指でピンチします", fill: "画面を埋める", fit: "全体を表示", adjustOnScreen: "画面上で直接調整", gestureHelp: "画面内をドラッグするか、2本指でピンチします",
zoom: "ズーム", reset: "リセット", deleteConfirm: "この画像をコレクションから削除しますか?", zoom: "ズーム", rotate: "90°回転", reset: "リセット", deleteConfirm: "この画像をコレクションから削除しますか?",
galleryLoadFailed: "ギャラリーを読み込めませんでした", automaticDisabled: "自動切り替えは一時停止中です", settingSaved: "設定を保存しました", galleryLoadFailed: "ギャラリーを読み込めませんでした", automaticDisabled: "自動切り替えは一時停止中です", settingSaved: "設定を保存しました",
intervalSaved: "切り替え間隔を保存しました", androidOnly: "この機能は Android で利用できます", imagesAdded: "画像をコレクションに追加しました", intervalSaved: "切り替え間隔を保存しました", androidOnly: "この機能は Android で利用できます", imagesAdded: "画像をコレクションに追加しました",
selectionCancelled: "選択をキャンセルしました", imageSelectionAndroid: "画像選択は Android で利用できます", imageDeleted: "画像を削除しました", selectionCancelled: "選択をキャンセルしました", imageSelectionAndroid: "画像選択は Android で利用できます", imageDeleted: "画像を削除しました",
@@ -214,7 +214,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "갤러리 불러오는 중…", loadMore: "이미지 더 불러오기", editCrop: "자르기 편집", galleryLoading: "갤러리 불러오는 중…", loadMore: "이미지 더 불러오기", editCrop: "자르기 편집",
gestureLabel: "한 손가락으로 이동하고 두 손가락으로 확대하세요", cropPreview: "자르기 미리보기", imageFit: "이미지 맞춤", gestureLabel: "한 손가락으로 이동하고 두 손가락으로 확대하세요", cropPreview: "자르기 미리보기", imageFit: "이미지 맞춤",
fill: "채우기", fit: "맞추기", adjustOnScreen: "화면에서 직접 조정", gestureHelp: "화면 안에서 드래그하거나 두 손가락으로 확대하세요", fill: "채우기", fit: "맞추기", adjustOnScreen: "화면에서 직접 조정", gestureHelp: "화면 안에서 드래그하거나 두 손가락으로 확대하세요",
zoom: "확대", reset: "초기화", deleteConfirm: "이 이미지를 컬렉션에서 삭제할까요?", zoom: "확대", rotate: "90° 회전", reset: "초기화", deleteConfirm: "이 이미지를 컬렉션에서 삭제할까요?",
galleryLoadFailed: "갤러리를 불러올 수 없습니다", automaticDisabled: "자동 변경이 일시 중지되었습니다", settingSaved: "설정을 저장했습니다", galleryLoadFailed: "갤러리를 불러올 수 없습니다", automaticDisabled: "자동 변경이 일시 중지되었습니다", settingSaved: "설정을 저장했습니다",
intervalSaved: "변경 간격을 저장했습니다", androidOnly: "이 기능은 Android에서 사용할 수 있습니다", imagesAdded: "컬렉션에 이미지를 추가했습니다", intervalSaved: "변경 간격을 저장했습니다", androidOnly: "이 기능은 Android에서 사용할 수 있습니다", imagesAdded: "컬렉션에 이미지를 추가했습니다",
selectionCancelled: "선택을 취소했습니다", imageSelectionAndroid: "이미지 선택은 Android에서 사용할 수 있습니다", imageDeleted: "이미지를 삭제했습니다", selectionCancelled: "선택을 취소했습니다", imageSelectionAndroid: "이미지 선택은 Android에서 사용할 수 있습니다", imageDeleted: "이미지를 삭제했습니다",
@@ -235,7 +235,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
galleryLoading: "正在加载图库…", loadMore: "加载更多图片", editCrop: "编辑裁剪", galleryLoading: "正在加载图库…", loadMore: "加载更多图片", editCrop: "编辑裁剪",
gestureLabel: "单指移动图片,双指缩放", cropPreview: "裁剪预览", imageFit: "图片适配", gestureLabel: "单指移动图片,双指缩放", cropPreview: "裁剪预览", imageFit: "图片适配",
fill: "填充", fit: "适应", adjustOnScreen: "直接在屏幕中调整", gestureHelp: "在屏幕内拖动或双指缩放", fill: "填充", fit: "适应", adjustOnScreen: "直接在屏幕中调整", gestureHelp: "在屏幕内拖动或双指缩放",
zoom: "缩放", reset: "重置", deleteConfirm: "确定要从收藏中删除此图片吗?", zoom: "缩放", rotate: "旋转 90°", reset: "重置", deleteConfirm: "确定要从收藏中删除此图片吗?",
galleryLoadFailed: "无法加载图库", automaticDisabled: "自动更换已暂停", settingSaved: "设置已保存", galleryLoadFailed: "无法加载图库", automaticDisabled: "自动更换已暂停", settingSaved: "设置已保存",
intervalSaved: "更换间隔已保存", androidOnly: "此功能可在 Android 上使用", imagesAdded: "图片已添加到收藏", intervalSaved: "更换间隔已保存", androidOnly: "此功能可在 Android 上使用", imagesAdded: "图片已添加到收藏",
selectionCancelled: "已取消选择", imageSelectionAndroid: "图片选择功能可在 Android 上使用", imageDeleted: "图片已删除", selectionCancelled: "已取消选择", imageSelectionAndroid: "图片选择功能可在 Android 上使用", imageDeleted: "图片已删除",
+3 -3
View File
@@ -27,7 +27,7 @@ export type ImmichText = {
export const immichTranslations: Record<Language, ImmichText> = { export const immichTranslations: Record<Language, ImmichText> = {
de: { de: {
immich: "Immich", 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", serverUrl: "Server-URL",
apiKey: "API-Key", apiKey: "API-Key",
apiKeyPlaceholder: "Nur beim Verbinden erforderlich", apiKeyPlaceholder: "Nur beim Verbinden erforderlich",
@@ -42,9 +42,9 @@ export const immichTranslations: Record<Language, ImmichText> = {
httpWarning: "HTTP ist unverschlüsselt. Verwende außerhalb deines Heimnetzes HTTPS.", httpWarning: "HTTP ist unverschlüsselt. Verwende außerhalb deines Heimnetzes HTTPS.",
allPhotos: "Alle Fotos", allPhotos: "Alle Fotos",
selectImmichPhotos: "Wähle Bilder zum Importieren aus", selectImmichPhotos: "Wähle Bilder zum Importieren aus",
importSelected: "Auswahl importieren", importSelected: "Zur Sammlung hinzufügen",
importing: "Bilder werden importiert…", importing: "Bilder werden importiert…",
importSuccess: "Immich-Bilder wurden importiert", importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt",
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden", immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
noImmichPhotos: "Keine Bilder gefunden", noImmichPhotos: "Keine Bilder gefunden",
}, },
+5 -2
View File
@@ -18,6 +18,7 @@ export type GalleryImage = {
cropZoom: number; cropZoom: number;
cropPositionX: number; cropPositionX: number;
cropPositionY: number; cropPositionY: number;
cropRotation: number;
}; };
export type GalleryPage = { export type GalleryPage = {
@@ -70,8 +71,8 @@ const demoState: WallpaperState = {
}; };
const inTauri = () => "__TAURI_INTERNALS__" in window; const inTauri = () => "__TAURI_INTERNALS__" in window;
const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY">>(); const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY" | "cropRotation">>();
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 }; const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 };
let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" }; let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" };
export async function getState(): Promise<WallpaperState> { export async function getState(): Promise<WallpaperState> {
@@ -126,6 +127,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
zoom: image.cropZoom, zoom: image.cropZoom,
positionX: image.cropPositionX, positionX: image.cropPositionX,
positionY: image.cropPositionY, positionY: image.cropPositionY,
rotation: image.cropRotation,
}; };
if (inTauri()) return invoke<GalleryImage>("plugin:wallpaper|set_image_crop", payload); if (inTauri()) return invoke<GalleryImage>("plugin:wallpaper|set_image_crop", payload);
demoCrops.set(image.id, { demoCrops.set(image.id, {
@@ -133,6 +135,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
cropZoom: image.cropZoom, cropZoom: image.cropZoom,
cropPositionX: image.cropPositionX, cropPositionX: image.cropPositionX,
cropPositionY: image.cropPositionY, cropPositionY: image.cropPositionY,
cropRotation: image.cropRotation,
}); });
return { ...image }; return { ...image };
} }
+7 -2
View File
@@ -153,10 +153,12 @@ nav svg { width: 21px; }
.save-crop { background: var(--green); color: white; } .save-crop { background: var(--green); color: white; }
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); } .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 { 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 { touch-action: none; user-select: none; cursor: grab; }
.phone-preview.interactive:active { cursor: grabbing; } .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 { 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 { 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; } .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 { 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 { 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); } .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 { 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 span { display: flex; justify-content: space-between; }
.crop-controls label strong { color: var(--green); } .crop-controls label strong { color: var(--green); }