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:
@@ -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() }
|
||||
}
|
||||
val target = File(directory, "immich-$assetId.${extension(contentType)}")
|
||||
if (!temporary.renameTo(target)) {
|
||||
temporary.copyTo(target, overwrite = true)
|
||||
|
||||
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
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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 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<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 {
|
||||
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<String>): 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]))
|
||||
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<String>()
|
||||
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)
|
||||
preferences.edit().putInt(KEY_INDEX, index).apply()
|
||||
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()
|
||||
rendered.recycle(); bitmap.recycle()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
|
||||
zoom: f64,
|
||||
position_x: f64,
|
||||
position_y: f64,
|
||||
rotation: i32,
|
||||
) -> Result<GalleryImage> {
|
||||
app.wallpaper().set_image_crop(ImageCropRequest {
|
||||
id,
|
||||
@@ -51,6 +52,7 @@ pub(crate) async fn set_image_crop<R: Runtime>(
|
||||
zoom,
|
||||
position_x,
|
||||
position_y,
|
||||
rotation,
|
||||
})
|
||||
}
|
||||
#[command]
|
||||
|
||||
@@ -47,6 +47,7 @@ impl<R: Runtime> Wallpaper<R> {
|
||||
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<R: Runtime> Wallpaper<R> {
|
||||
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<WallpaperState> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+29
-7
@@ -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 && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
|
||||
</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}>
|
||||
<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="preview-clock">12:34<span>{previewDate}</span></div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</section>}
|
||||
</div>
|
||||
|
||||
+11
-11
@@ -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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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<Exclude<Language, "en">, 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: "图片已删除",
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ export type ImmichText = {
|
||||
export const immichTranslations: Record<Language, ImmichText> = {
|
||||
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<Language, ImmichText> = {
|
||||
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",
|
||||
},
|
||||
|
||||
+5
-2
@@ -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<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY">>();
|
||||
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 };
|
||||
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, cropRotation: 0 };
|
||||
let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" };
|
||||
|
||||
export async function getState(): Promise<WallpaperState> {
|
||||
@@ -126,6 +127,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
|
||||
zoom: image.cropZoom,
|
||||
positionX: image.cropPositionX,
|
||||
positionY: image.cropPositionY,
|
||||
rotation: image.cropRotation,
|
||||
};
|
||||
if (inTauri()) return invoke<GalleryImage>("plugin:wallpaper|set_image_crop", payload);
|
||||
demoCrops.set(image.id, {
|
||||
@@ -133,6 +135,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
|
||||
cropZoom: image.cropZoom,
|
||||
cropPositionX: image.cropPositionX,
|
||||
cropPositionY: image.cropPositionY,
|
||||
cropRotation: image.cropRotation,
|
||||
});
|
||||
return { ...image };
|
||||
}
|
||||
|
||||
+7
-2
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user