diff --git a/README.md b/README.md index da416b9..6d101c4 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ WallpaperFlow ist eine freie Tauri-2-App für Android, die aus einer frei wählb - zufällige oder fortlaufende Reihenfolge - manueller Wechsel über „Nächstes Motiv“ - automatischer Wechsel alle 5, 15 oder 30 Minuten beziehungsweise alle 1, 3, 6 oder 12 Stunden -- Neustart des Dienstes nach einem Geräte-Neustart, soweit die Android-Version Hintergrundstarts zulässt +- optionaler Wechsel bei jedem Einschalten des Displays +- erneute Planung des automatischen Wechsels nach einem Geräte-Neustart - lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht - optionaler Import aus einem selbst gehosteten Immich-Server über einen eingeschränkten API-Key - vollständig lokale Oberfläche in Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Polnisch, Portugiesisch, Japanisch, Koreanisch und vereinfachtem Chinesisch -Android hält den Zeitgeber über einen Foreground Service aktiv. Solange ein Wechselintervall ausgewählt ist, zeigt das System deshalb eine dauerhafte, stille Benachrichtigung. Auf Geräten mit aggressivem Energiesparen muss LockScreenWallpaper gegebenenfalls von der Akku-Optimierung ausgenommen werden. +Der automatische Wechsel verwendet einen ungenauen Android-Alarm und respektiert Doze sowie den Energiesparmodus. Dadurch gibt es keinen dauerhaft laufenden Dienst und keine permanente Benachrichtigung; Wechsel können im Ruhezustand etwas später erfolgen. Automatische Immich-Downloads erfolgen nur über eine nicht getaktete Verbindung und bei ausreichendem Akkustand. + +Der optionale Modus „Beim Einschalten des Displays“ benötigt dagegen einen kleinen Foreground Service mit permanenter Benachrichtigung, damit Android die Display-Ereignisse zuverlässig zustellt. Das nächste Bild wird bereits beim Ausschalten gesetzt und ist dadurch beim folgenden Einschalten ohne sichtbare Verzögerung vorhanden. Der Dienst hält keinen CPU-Wake-Lock. ## Entwicklung @@ -46,9 +49,9 @@ Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er ## Wichtige Android-Hinweise -- Android erlaubt keinen komplett unsichtbaren, dauerhaft laufenden Zeitgeber. Der automatische Wechsel verwendet deshalb einen Foreground Service. +- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist. - `WallpaperManager.FLAG_LOCK` ist ab Android 7 verfügbar. Auf älteren Geräten setzt die App das allgemeine Hintergrundbild. -- Hersteller können Hintergrunddienste zusätzlich einschränken. Besonders bei Samsung/Xiaomi kann eine Ausnahme von der Akku-Optimierung nötig sein. +- Hersteller können Hintergrundarbeit zusätzlich verzögern. Eine Ausnahme von der Akku-Optimierung ist für den normalen Betrieb nicht vorgesehen. - „Ohne Limit“ bedeutet: kein App-Zähler wie Samsungs 15-Bilder-Grenze. Praktisch begrenzen freier Gerätespeicher und Dateisystem die Sammlung. ## Datenschutz diff --git a/plugins/android/src/main/AndroidManifest.xml b/plugins/android/src/main/AndroidManifest.xml index 035a08a..9fd5d96 100644 --- a/plugins/android/src/main/AndroidManifest.xml +++ b/plugins/android/src/main/AndroidManifest.xml @@ -1,14 +1,14 @@ + - - - + + diff --git a/plugins/android/src/main/java/BootReceiver.kt b/plugins/android/src/main/java/BootReceiver.kt index d61a057..0e96d5b 100644 --- a/plugins/android/src/main/java/BootReceiver.kt +++ b/plugins/android/src/main/java/BootReceiver.kt @@ -6,9 +6,10 @@ import android.content.Intent class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) && WallpaperStore.enabled(context)) { - WallpaperScheduler.scheduleNext(context) - runCatching { WallpaperRotationService.start(context) } + if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return + when { + WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) } + WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context) } } } diff --git a/plugins/android/src/main/java/ImmichClient.kt b/plugins/android/src/main/java/ImmichClient.kt index c4b2f06..2cb334c 100644 --- a/plugins/android/src/main/java/ImmichClient.kt +++ b/plugins/android/src/main/java/ImmichClient.kt @@ -1,6 +1,7 @@ package de.wechselbild.wallpaper import android.content.Context +import android.net.ConnectivityManager import android.graphics.BitmapFactory import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties @@ -33,9 +34,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 CACHE_MAX_BYTES = 512L * 1024 * 1024 private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024 + private const val PROGRESS_STEP_BYTES = 256L * 1024 private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$") private val thumbnailPool = Executors.newFixedThreadPool(4) private val importProgress = AtomicReference(ImportProgress()) @@ -204,6 +205,7 @@ object ImmichClient { val total = connection.contentLengthLong.coerceAtLeast(0) require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." } var downloaded = 0L + var lastProgressUpdate = 0L if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) } connection.inputStream.use { input -> target.outputStream().use { output -> @@ -214,7 +216,10 @@ object ImmichClient { output.write(buffer, 0, count) downloaded += count require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." } - if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) } + if (trackProgress && (downloaded - lastProgressUpdate >= PROGRESS_STEP_BYTES || downloaded == total)) { + lastProgressUpdate = downloaded + importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) } + } } } } @@ -309,6 +314,12 @@ object ImmichClient { return bounds.outWidth > 0 && bounds.outHeight > 0 } + fun automaticDownloadsAllowed(context: Context): Boolean { + val connectivity = context.getSystemService(ConnectivityManager::class.java) + @Suppress("DEPRECATION") + return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered + } + @Synchronized fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? { val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image") @@ -347,13 +358,13 @@ object ImmichClient { } 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() + val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.lastModified() } ?: return + var bytes = files.sumOf { it.length() } + for (file in files) { + if (bytes <= CACHE_MAX_BYTES) break + if (file != protected) { + val length = file.length() + if (file.delete()) bytes -= length } } } diff --git a/plugins/android/src/main/java/WallpaperRotationService.kt b/plugins/android/src/main/java/ScreenOnRotationService.kt similarity index 52% rename from plugins/android/src/main/java/WallpaperRotationService.kt rename to plugins/android/src/main/java/ScreenOnRotationService.kt index d5c8dc5..338f0a9 100644 --- a/plugins/android/src/main/java/WallpaperRotationService.kt +++ b/plugins/android/src/main/java/ScreenOnRotationService.kt @@ -4,37 +4,46 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Build -import android.os.Handler import android.os.IBinder -import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean -class WallpaperRotationService : Service() { +class ScreenOnRotationService : Service() { private val worker = Executors.newSingleThreadExecutor() - private val handler = Handler(Looper.getMainLooper()) - private val rotate = object : Runnable { - override fun run() { - if (!WallpaperStore.enabled(this@WallpaperRotationService)) { - stopSelf() - return - } - worker.execute { - if (WallpaperScheduler.claimIfDue(this@WallpaperRotationService)) { - WallpaperRotationWorker.enqueue(this@WallpaperRotationService) + private val rotating = AtomicBoolean(false) + private val changedWhileScreenOff = AtomicBoolean(false) + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!WallpaperStore.screenOnEnabled(context)) return + when (intent.action) { + Intent.ACTION_SCREEN_OFF -> rotate(context, preparedForScreenOn = true) + Intent.ACTION_SCREEN_ON -> { + if (!changedWhileScreenOff.getAndSet(false)) rotate(context, preparedForScreenOn = false) } - handler.post { scheduleLocalTimer() } } } } + private fun rotate(context: Context, preparedForScreenOn: Boolean) { + if (!rotating.compareAndSet(false, true)) return + worker.execute { + try { + if (WallpaperStore.applyNext(context, automatic = true) && preparedForScreenOn) { + changedWhileScreenOff.set(true) + } + } finally { rotating.set(false) } + } + } + override fun onCreate() { super.onCreate() - runningService = this createChannel() val launch = packageManager.getLaunchIntentForPackage(packageName) val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) @@ -42,34 +51,49 @@ class WallpaperRotationService : Service() { .setSmallIcon(android.R.drawable.ic_menu_gallery) .setContentTitle(getString(R.string.wallpaper_service_title)) .setContentText(getString(R.string.wallpaper_service_text)) - .setOngoing(true).setSilent(true).setContentIntent(pending).build() + .setOngoing(true) + .setSilent(true) + .setContentIntent(pending) + .build() startForeground(NOTIFICATION_ID, notification) - WallpaperScheduler.ensureScheduled(this) - scheduleLocalTimer() + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_SCREEN_ON) + } + if (Build.VERSION.SDK_INT >= 33) { + registerReceiver(screenReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(screenReceiver, filter) + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (!WallpaperStore.enabled(this)) { + if (!WallpaperStore.screenOnEnabled(this)) { stopSelf() return START_NOT_STICKY } - WallpaperScheduler.ensureScheduled(this) - scheduleLocalTimer() return START_STICKY } + override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { - handler.removeCallbacks(rotate) + runCatching { unregisterReceiver(screenReceiver) } worker.shutdown() - if (runningService === this) runningService = null super.onDestroy() } - private fun scheduleLocalTimer() { - handler.removeCallbacks(rotate) - if (WallpaperStore.enabled(this)) { - handler.postDelayed(rotate, WallpaperScheduler.remainingDelay(this)) + companion object { + private const val CHANNEL = "wechselbild_screen_on" + private const val NOTIFICATION_ID = 4217 + + fun start(context: Context) { + ContextCompat.startForegroundService(context, Intent(context, ScreenOnRotationService::class.java)) + } + + fun stop(context: Context) { + context.stopService(Intent(context, ScreenOnRotationService::class.java)) } } @@ -79,27 +103,4 @@ class WallpaperRotationService : Service() { getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } } - - companion object { - private const val CHANNEL = "wechselbild_rotation" - private const val NOTIFICATION_ID = 4217 - @Volatile private var runningService: WallpaperRotationService? = null - - fun rescheduleRunningService() { - val service = runningService ?: return - service.handler.post { service.scheduleLocalTimer() } - } - fun start(context: Context) { - WallpaperScheduler.ensureScheduled(context) - ContextCompat.startForegroundService(context, Intent(context, WallpaperRotationService::class.java)) - } - fun restart(context: Context) { - WallpaperScheduler.scheduleNext(context) - start(context) - } - fun stop(context: Context) { - WallpaperScheduler.cancel(context) - context.stopService(Intent(context, WallpaperRotationService::class.java)) - } - } } diff --git a/plugins/android/src/main/java/WallpaperAlarmReceiver.kt b/plugins/android/src/main/java/WallpaperAlarmReceiver.kt index 2ceed5c..6369565 100644 --- a/plugins/android/src/main/java/WallpaperAlarmReceiver.kt +++ b/plugins/android/src/main/java/WallpaperAlarmReceiver.kt @@ -6,7 +6,7 @@ import android.content.Intent class WallpaperAlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (!WallpaperStore.enabled(context)) { + if (!WallpaperStore.timedEnabled(context)) { WallpaperScheduler.cancel(context) return } @@ -14,6 +14,5 @@ class WallpaperAlarmReceiver : BroadcastReceiver() { WallpaperRotationWorker.enqueue(context) } WallpaperScheduler.ensureScheduled(context) - WallpaperRotationService.rescheduleRunningService() } } diff --git a/plugins/android/src/main/java/WallpaperPlugin.kt b/plugins/android/src/main/java/WallpaperPlugin.kt index 956207d..54252d3 100644 --- a/plugins/android/src/main/java/WallpaperPlugin.kt +++ b/plugins/android/src/main/java/WallpaperPlugin.kt @@ -58,7 +58,6 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) { private val io = Executors.newSingleThreadExecutor() @Command fun getState(invoke: Invoke) { - if (WallpaperStore.enabled(activity)) runCatching { WallpaperRotationService.start(activity) } io.execute { invoke.resolve(WallpaperStore.state(activity)) } } @@ -141,7 +140,10 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) { try { val args = invoke.parseArgs(IntervalArgs::class.java) WallpaperStore.setInterval(activity, args.minutes) - if (args.minutes > 0) WallpaperRotationService.restart(activity) else WallpaperRotationService.stop(activity) + WallpaperScheduler.cancel(activity) + ScreenOnRotationService.stop(activity) + if (args.minutes == -1) ScreenOnRotationService.start(activity) + else if (args.minutes > 0) WallpaperScheduler.scheduleNext(activity) invoke.resolve(WallpaperStore.state(activity)) } catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") } } diff --git a/plugins/android/src/main/java/WallpaperRotationWorker.kt b/plugins/android/src/main/java/WallpaperRotationWorker.kt index 6838d20..a859af6 100644 --- a/plugins/android/src/main/java/WallpaperRotationWorker.kt +++ b/plugins/android/src/main/java/WallpaperRotationWorker.kt @@ -2,6 +2,7 @@ package de.wechselbild.wallpaper import android.content.Context import androidx.work.ExistingWorkPolicy +import androidx.work.Constraints import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker @@ -9,9 +10,9 @@ import androidx.work.WorkerParameters class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { override fun doWork(): Result { - if (!WallpaperStore.enabled(applicationContext)) return Result.success() - if (WallpaperStore.applyNext(applicationContext)) return Result.success() - return if (runAttemptCount < 2) Result.retry() else Result.failure() + if (!WallpaperStore.timedEnabled(applicationContext)) return Result.success() + WallpaperStore.applyNext(applicationContext, automatic = true) + return Result.success() } companion object { @@ -21,7 +22,9 @@ class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : WorkManager.getInstance(context).enqueueUniqueWork( WORK_NAME, ExistingWorkPolicy.KEEP, - OneTimeWorkRequestBuilder().build(), + OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build()) + .build(), ) } } diff --git a/plugins/android/src/main/java/WallpaperScheduler.kt b/plugins/android/src/main/java/WallpaperScheduler.kt index 68e455c..97a1bf7 100644 --- a/plugins/android/src/main/java/WallpaperScheduler.kt +++ b/plugins/android/src/main/java/WallpaperScheduler.kt @@ -4,7 +4,6 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build import android.os.SystemClock object WallpaperScheduler { @@ -13,7 +12,7 @@ object WallpaperScheduler { private const val REQUEST_CODE = 4218 fun ensureScheduled(context: Context) { - if (!WallpaperStore.enabled(context)) { + if (!WallpaperStore.timedEnabled(context)) { cancel(context) return } @@ -23,11 +22,6 @@ object WallpaperScheduler { if (storedTrigger <= now || storedTrigger > now + interval) scheduleNext(context) } - fun remainingDelay(context: Context): Long { - val trigger = preferences(context).getLong(KEY_NEXT_TRIGGER, 0L) - return (trigger - SystemClock.elapsedRealtime()).coerceAtLeast(0L) - } - @Synchronized fun claimIfDue(context: Context): Boolean { val preferences = preferences(context) @@ -47,11 +41,8 @@ object WallpaperScheduler { val trigger = SystemClock.elapsedRealtime() + minutes * 60_000L val alarmManager = context.getSystemService(AlarmManager::class.java) val operation = operation(context) - if (Build.VERSION.SDK_INT >= 23) { - alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation) - } else { - alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation) - } + val window = (minutes * 60_000L / 10).coerceIn(60_000L, 15 * 60_000L) + alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME, trigger, window, operation) preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit() } diff --git a/plugins/android/src/main/java/WallpaperStore.kt b/plugins/android/src/main/java/WallpaperStore.kt index 52d81b8..47ce657 100644 --- a/plugins/android/src/main/java/WallpaperStore.kt +++ b/plugins/android/src/main/java/WallpaperStore.kt @@ -23,6 +23,9 @@ object WallpaperStore { 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 INVALID_PREFIX = "invalid_" + private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024 + private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 1024 * 1024 private const val HOME_PREVIEW_LIMIT = 12 private val thumbnailCache = LruCache(48) @@ -47,6 +50,8 @@ object WallpaperStore { 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 renderedDirectory(context: Context) = File(context.cacheDir, "rendered-wallpapers").apply { mkdirs() } + private fun thumbnailDirectory(context: Context) = File(context.cacheDir, "wallpaper-thumbnails").apply { mkdirs() } private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256") .digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) } @@ -92,7 +97,9 @@ object WallpaperStore { return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0) else if (preferences.getBoolean("enabled", false)) 30 else 0 } - fun enabled(context: Context) = intervalMinutes(context) > 0 + fun enabled(context: Context) = intervalMinutes(context) != 0 + fun timedEnabled(context: Context) = intervalMinutes(context) > 0 + fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1 fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true) fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true) @@ -102,8 +109,8 @@ object WallpaperStore { } fun setInterval(context: Context, minutes: Int) { - require(minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" } - prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply() + require(minutes == -1 || minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" } + prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes != 0).commit() } @Synchronized @@ -124,7 +131,7 @@ object WallpaperStore { previewItems.forEach { ids.put(it.id) } put("imageIds", ids) val previews = JSArray() - if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(it.preview)) } + if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) } put("imageUrls", previews) } } @@ -164,7 +171,7 @@ object WallpaperStore { val crop = crop(context, entry) return JSObject().apply { put("id", entry.id) - put("url", thumbnailDataUrl(entry.preview)) + put("url", thumbnailDataUrl(context, entry.preview)) put("selected", selected) put("cropMode", crop.mode) put("cropZoom", crop.zoom) @@ -294,9 +301,16 @@ object WallpaperStore { return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 }) } - private fun thumbnailDataUrl(file: File): String { + private fun thumbnailDataUrl(context: Context, file: File): String { val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}" thumbnailCache.get(cacheKey)?.let { return it } + val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg") + if (cached.isFile) { + cached.setLastModified(System.currentTimeMillis()) + val result = "data:image/jpeg;base64," + Base64.encodeToString(cached.readBytes(), Base64.NO_WRAP) + thumbnailCache.put(cacheKey, result) + return result + } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.absolutePath, bounds) var sample = 1 @@ -304,7 +318,9 @@ object WallpaperStore { 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() - "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + val bytes = out.toByteArray() + runCatching { cached.writeBytes(bytes); trimDirectory(thumbnailDirectory(context), THUMBNAIL_CACHE_MAX_BYTES, cached) } + "data:image/jpeg;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) } thumbnailCache.put(cacheKey, result) return result @@ -314,15 +330,52 @@ object WallpaperStore { thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}") } + private fun digest(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + + private fun renderedFile(context: Context, entry: Entry): File { + val metrics = context.resources.displayMetrics + val sourceVersion = when (entry) { + is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}" + is Entry.Immich -> entry.assetId + } + val key = "${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}" + return File(renderedDirectory(context), "${digest(key)}.jpg") + } + + private fun sourceVersion(entry: Entry): String = when (entry) { + is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}" + is Entry.Immich -> entry.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 + } + + private fun trimDirectory(directory: File, maxBytes: Long, protected: File) { + val files = directory.listFiles()?.filter { it.isFile }?.sortedBy { it.lastModified() } ?: return + var bytes = files.sumOf { it.length() } + for (file in files) { + if (bytes <= maxBytes) break + if (file != protected) { + val length = file.length() + if (file.delete()) bytes -= length + } + } + } + @Synchronized - fun applyNext(context: Context): Boolean { + fun applyNext(context: Context, automatic: Boolean = false): Boolean { 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) } - return applyCandidates(context, items, candidates) + return applyCandidates(context, items, candidates, automatic) } @Synchronized @@ -330,37 +383,58 @@ object WallpaperStore { val items = entries(context) val index = items.indexOfFirst { it.id == id } if (index < 0) return false - return applyCandidates(context, items, listOf(index)) + return applyCandidates(context, items, listOf(index), automatic = false) } - private fun applyCandidates(context: Context, items: List, candidates: List): Boolean { + private fun applyCandidates(context: Context, items: List, candidates: List, automatic: Boolean): Boolean { val unavailableServers = mutableSetOf() for (index in candidates) { val entry = items[index] - val sourceFile = when (entry) { - is Entry.Local -> entry.file - is Entry.Immich -> { - val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, entry.serverUrl !in unavailableServers) - if (source == null) { - unavailableServers.add(entry.serverUrl) - continue + val renderedFile = renderedFile(context, entry) + var rendered = if (validImage(renderedFile)) { + renderedFile.setLastModified(System.currentTimeMillis()) + BitmapFactory.decodeFile(renderedFile.absolutePath) + } else null + if (rendered == null) { + renderedFile.delete() + if (prefs(context).getString(INVALID_PREFIX + entry.id, null) == sourceVersion(entry)) continue + val sourceFile = when (entry) { + is Entry.Local -> entry.file + is Entry.Immich -> { + val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context)) + val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork) + if (source == null) { + if (allowNetwork) unavailableServers.add(entry.serverUrl) + continue + } + source } - source } + val bitmap = decodeForScreen(context, sourceFile) + if (bitmap == null) { + prefs(context).edit().putString(INVALID_PREFIX + entry.id, sourceVersion(entry)).apply() + continue + } + val generated = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull() + bitmap.recycle() + if (generated == null) continue + runCatching { + renderedFile.outputStream().use { generated.compress(Bitmap.CompressFormat.JPEG, 92, it) } + trimDirectory(renderedDirectory(context), RENDER_CACHE_MAX_BYTES, renderedFile) + } + rendered = generated } - val bitmap = decodeForScreen(context, sourceFile) ?: continue - val rendered = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull() - if (rendered == null) { bitmap.recycle(); continue } + val wallpaper = rendered ?: continue try { val manager = WallpaperManager.getInstance(context) - if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK) - else manager.setBitmap(rendered) - prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply() + if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK) + else manager.setBitmap(wallpaper) + prefs(context).edit().remove(INVALID_PREFIX + entry.id).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() + wallpaper.recycle() } } return false diff --git a/plugins/android/src/main/res/values-de/strings.xml b/plugins/android/src/main/res/values-de/strings.xml index 61c9dbd..7eb2e3f 100644 --- a/plugins/android/src/main/res/values-de/strings.xml +++ b/plugins/android/src/main/res/values-de/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow ist aktiv - Das Motiv wechselt nach dem eingestellten Intervall. - Automatischer Bildwechsel + Das nächste Motiv wird beim Ausschalten des Displays vorbereitet. + Bildwechsel bei Display-Aktivierung diff --git a/plugins/android/src/main/res/values-es/strings.xml b/plugins/android/src/main/res/values-es/strings.xml index eb893e5..d8650a9 100644 --- a/plugins/android/src/main/res/values-es/strings.xml +++ b/plugins/android/src/main/res/values-es/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow está activo - El fondo cambia según el intervalo configurado. - Rotación automática de fondos + El fondo cambia cuando se enciende la pantalla. + Rotación al encender la pantalla diff --git a/plugins/android/src/main/res/values-fr/strings.xml b/plugins/android/src/main/res/values-fr/strings.xml index 043cc13..47752c5 100644 --- a/plugins/android/src/main/res/values-fr/strings.xml +++ b/plugins/android/src/main/res/values-fr/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow est actif - Le fond d’écran change selon l’intervalle défini. - Rotation automatique des fonds d’écran + Le fond d’écran change lorsque l’écran s’allume. + Rotation à l’allumage de l’écran diff --git a/plugins/android/src/main/res/values-it/strings.xml b/plugins/android/src/main/res/values-it/strings.xml index f98b3c5..213842b 100644 --- a/plugins/android/src/main/res/values-it/strings.xml +++ b/plugins/android/src/main/res/values-it/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow è attivo - Lo sfondo cambia all’intervallo configurato. - Rotazione automatica degli sfondi + Lo sfondo cambia quando si accende lo schermo. + Rotazione all’accensione dello schermo diff --git a/plugins/android/src/main/res/values-ja/strings.xml b/plugins/android/src/main/res/values-ja/strings.xml index 2dbe9c4..f926ff3 100644 --- a/plugins/android/src/main/res/values-ja/strings.xml +++ b/plugins/android/src/main/res/values-ja/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow は実行中です - 設定した間隔で壁紙が切り替わります。 - 壁紙の自動切り替え + 画面が点灯すると壁紙が切り替わります。 + 画面点灯時の壁紙切り替え diff --git a/plugins/android/src/main/res/values-ko/strings.xml b/plugins/android/src/main/res/values-ko/strings.xml index 444bc75..ef9c801 100644 --- a/plugins/android/src/main/res/values-ko/strings.xml +++ b/plugins/android/src/main/res/values-ko/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow 실행 중 - 설정한 간격에 따라 배경화면이 변경됩니다. - 자동 배경화면 변경 + 화면이 켜질 때 배경화면이 변경됩니다. + 화면 켜짐 시 배경화면 변경 diff --git a/plugins/android/src/main/res/values-nl/strings.xml b/plugins/android/src/main/res/values-nl/strings.xml index 6547e5e..55fd4f2 100644 --- a/plugins/android/src/main/res/values-nl/strings.xml +++ b/plugins/android/src/main/res/values-nl/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow is actief - De achtergrond wisselt volgens het ingestelde interval. - Automatisch achtergronden wisselen + De achtergrond wisselt wanneer het scherm aangaat. + Wisselen bij scherminschakeling diff --git a/plugins/android/src/main/res/values-pl/strings.xml b/plugins/android/src/main/res/values-pl/strings.xml index ebc3921..3d57fc6 100644 --- a/plugins/android/src/main/res/values-pl/strings.xml +++ b/plugins/android/src/main/res/values-pl/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow jest aktywny - Tapeta zmienia się zgodnie z ustawionym interwałem. - Automatyczna zmiana tapety + Tapeta zmienia się po włączeniu ekranu. + Zmiana po włączeniu ekranu diff --git a/plugins/android/src/main/res/values-pt/strings.xml b/plugins/android/src/main/res/values-pt/strings.xml index 3bb9f3f..721b69f 100644 --- a/plugins/android/src/main/res/values-pt/strings.xml +++ b/plugins/android/src/main/res/values-pt/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow está ativo - O fundo muda de acordo com o intervalo definido. - Rotação automática de fundos + O fundo muda quando o ecrã é ligado. + Rotação ao ligar o ecrã diff --git a/plugins/android/src/main/res/values-zh-rCN/strings.xml b/plugins/android/src/main/res/values-zh-rCN/strings.xml index 0512b61..47ec797 100644 --- a/plugins/android/src/main/res/values-zh-rCN/strings.xml +++ b/plugins/android/src/main/res/values-zh-rCN/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow 正在运行 - 壁纸会按照设定的间隔更换。 - 自动更换壁纸 + 屏幕亮起时会更换壁纸。 + 亮屏时更换壁纸 diff --git a/plugins/android/src/main/res/values/strings.xml b/plugins/android/src/main/res/values/strings.xml index e6dffae..a9aa04a 100644 --- a/plugins/android/src/main/res/values/strings.xml +++ b/plugins/android/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ WallpaperFlow is active - The wallpaper changes at the configured interval. - Automatic wallpaper rotation + The next wallpaper is prepared when the screen turns off. + Screen-on wallpaper rotation diff --git a/plugins/src/commands.rs b/plugins/src/commands.rs index 3102636..3734a3f 100644 --- a/plugins/src/commands.rs +++ b/plugins/src/commands.rs @@ -64,7 +64,7 @@ pub(crate) async fn set_setting( app.wallpaper().set_setting(SettingRequest { name, value }) } #[command] -pub(crate) async fn set_interval(app: AppHandle, minutes: usize) -> Result { +pub(crate) async fn set_interval(app: AppHandle, minutes: i32) -> Result { app.wallpaper().set_interval(IntervalRequest { minutes }) } #[command] diff --git a/plugins/src/desktop.rs b/plugins/src/desktop.rs index 98331b2..4ca0982 100644 --- a/plugins/src/desktop.rs +++ b/plugins/src/desktop.rs @@ -96,7 +96,7 @@ impl Wallpaper { pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result { let mut state = Self::demo(); state.interval_minutes = payload.minutes; - state.enabled = payload.minutes > 0; + state.enabled = payload.minutes != 0; Ok(state) } pub fn next_wallpaper(&self) -> crate::Result { diff --git a/plugins/src/models.rs b/plugins/src/models.rs index d9f206b..014d6d3 100644 --- a/plugins/src/models.rs +++ b/plugins/src/models.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct WallpaperState { pub image_count: usize, pub enabled: bool, - pub interval_minutes: usize, + pub interval_minutes: i32, pub shuffle: bool, pub lock_screen_only: bool, pub current_index: usize, @@ -24,7 +24,7 @@ pub struct SettingRequest { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct IntervalRequest { - pub minutes: usize, + pub minutes: i32, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/App.tsx b/src/App.tsx index eba9bcc..fdebc94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,9 +14,10 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l return
{icon}
{label}
; } -function IntervalRow({ value, onChange, label, paused, everyMinutes }: { value: number; onChange: (value: number) => void; label: string; paused: string; everyMinutes: (minutes: number) => string }) { +function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }: { value: number; onChange: (value: number) => void; label: string; paused: string; screenOn: string; everyMinutes: (minutes: number) => string }) { return ; } @@ -54,11 +55,9 @@ export default function App() { const refresh = () => { void getState().then(setState).catch(() => undefined); }; refresh(); const onVisibilityChange = () => { if (!document.hidden) refresh(); }; - const refreshInterval = window.setInterval(() => { if (!document.hidden) refresh(); }, 15000); document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("focus", refresh); return () => { - window.clearInterval(refreshInterval); document.removeEventListener("visibilitychange", onVisibilityChange); window.removeEventListener("focus", refresh); }; @@ -118,11 +117,11 @@ export default function App() { } async function updateInterval(minutes: number) { - setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes > 0 })); + setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes !== 0 })); try { const saved = await setIntervalMinutes(minutes); setState(saved); - setNotice(minutes > 0 ? t.intervalSaved : t.automaticDisabled); + setNotice(minutes !== 0 ? t.intervalSaved : t.automaticDisabled); } catch { setNotice(t.androidOnly); } } @@ -249,7 +248,7 @@ export default function App() { setNotice(it.importing); const poll = window.setInterval(() => { void getImmichImportProgress().then(setImmichImportProgress).catch(() => undefined); - }, 250); + }, 750); try { setState(await importImmichAssets([...immichSelected])); setImmichSelected(new Set()); @@ -408,7 +407,7 @@ export default function App() {
{t.currentImage}
- + @@ -421,7 +420,7 @@ export default function App() { : tab === "settings" ?

{t.settings}

{t.settingsIntro}

-
} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} />
+
} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} />

{it.immich}

{it.immichIntro}

{immichConnection.configured ? <> diff --git a/src/i18n-local.ts b/src/i18n-local.ts index 4b684c5..ad3ab83 100644 --- a/src/i18n-local.ts +++ b/src/i18n-local.ts @@ -9,7 +9,7 @@ export const languageNames: Record = { const en = { automaticActive: "Your wallpapers change automatically", automaticPaused: "Automatic rotation is paused", settings: "Settings", currentWallpaper: "Current wallpaper", currentImage: "Current image", nextImage: "Next image", - changeInterval: "Change image", paused: "Paused", selectImages: "Select images", pleaseWait: "Please wait …", + changeInterval: "Change image", paused: "Paused", screenOn: "When screen turns on", selectImages: "Select images", pleaseWait: "Please wait …", collection: "My collection", image: "image", images: "images", motif: "Image", noImages: "No images selected yet", collectionHint: "Tap the image count to open your gallery.", shuffle: "Shuffle order", lockScreenOnly: "Lock screen only", settingsIntro: "Choose how WallpaperFlow works in the background.", language: "Language", noImageLimit: "No image limit", @@ -35,7 +35,7 @@ const copies: Record, Copy> = { de: { automaticActive: "Deine Motive wechseln automatisch", automaticPaused: "Automatischer Wechsel ist pausiert", settings: "Einstellungen", currentWallpaper: "Aktuelles Hintergrundbild", currentImage: "Aktuelles Motiv", nextImage: "Nächstes Motiv", - changeInterval: "Bild wechseln", paused: "Pausiert", selectImages: "Bilder auswählen", pleaseWait: "Bitte warten …", + changeInterval: "Bild wechseln", paused: "Pausiert", screenOn: "Beim Einschalten des Displays", selectImages: "Bilder auswählen", pleaseWait: "Bitte warten …", collection: "Meine Sammlung", image: "Bild", images: "Bilder", motif: "Motiv", noImages: "Noch keine Bilder ausgewählt", collectionHint: "Tippe auf die Bildanzahl, um deine Galerie zu öffnen.", shuffle: "Zufällige Reihenfolge", lockScreenOnly: "Nur Sperrbildschirm", settingsIntro: "Lege fest, wie WallpaperFlow im Hintergrund arbeitet.", language: "Sprache", noImageLimit: "Ohne Bilderlimit", @@ -56,7 +56,7 @@ const copies: Record, Copy> = { fr: { automaticActive: "Vos fonds d’écran changent automatiquement", automaticPaused: "La rotation automatique est en pause", settings: "Paramètres", currentWallpaper: "Fond d’écran actuel", currentImage: "Image actuelle", nextImage: "Image suivante", - changeInterval: "Changer l’image", paused: "En pause", selectImages: "Sélectionner des images", pleaseWait: "Veuillez patienter…", + changeInterval: "Changer l’image", paused: "En pause", screenOn: "À l’allumage de l’écran", selectImages: "Sélectionner des images", pleaseWait: "Veuillez patienter…", collection: "Ma collection", image: "image", images: "images", motif: "Image", noImages: "Aucune image sélectionnée", collectionHint: "Touchez le nombre d’images pour ouvrir votre galerie.", shuffle: "Ordre aléatoire", lockScreenOnly: "Écran de verrouillage uniquement", settingsIntro: "Choisissez comment WallpaperFlow fonctionne en arrière-plan.", language: "Langue", noImageLimit: "Aucune limite d’images", @@ -77,7 +77,7 @@ const copies: Record, Copy> = { es: { automaticActive: "Tus fondos cambian automáticamente", automaticPaused: "La rotación automática está en pausa", settings: "Ajustes", currentWallpaper: "Fondo actual", currentImage: "Imagen actual", nextImage: "Imagen siguiente", - changeInterval: "Cambiar imagen", paused: "En pausa", selectImages: "Seleccionar imágenes", pleaseWait: "Espera…", + changeInterval: "Cambiar imagen", paused: "En pausa", screenOn: "Al encender la pantalla", selectImages: "Seleccionar imágenes", pleaseWait: "Espera…", collection: "Mi colección", image: "imagen", images: "imágenes", motif: "Imagen", noImages: "Aún no hay imágenes seleccionadas", collectionHint: "Toca el número de imágenes para abrir la galería.", shuffle: "Orden aleatorio", lockScreenOnly: "Solo pantalla de bloqueo", settingsIntro: "Elige cómo funciona WallpaperFlow en segundo plano.", language: "Idioma", noImageLimit: "Sin límite de imágenes", @@ -98,7 +98,7 @@ const copies: Record, Copy> = { it: { automaticActive: "Gli sfondi cambiano automaticamente", automaticPaused: "La rotazione automatica è in pausa", settings: "Impostazioni", currentWallpaper: "Sfondo attuale", currentImage: "Immagine attuale", nextImage: "Immagine successiva", - changeInterval: "Cambia immagine", paused: "In pausa", selectImages: "Seleziona immagini", pleaseWait: "Attendi…", + changeInterval: "Cambia immagine", paused: "In pausa", screenOn: "All’accensione dello schermo", selectImages: "Seleziona immagini", pleaseWait: "Attendi…", collection: "La mia raccolta", image: "immagine", images: "immagini", motif: "Immagine", noImages: "Nessuna immagine selezionata", collectionHint: "Tocca il numero di immagini per aprire la galleria.", shuffle: "Ordine casuale", lockScreenOnly: "Solo schermata di blocco", settingsIntro: "Scegli come funziona WallpaperFlow in background.", language: "Lingua", noImageLimit: "Nessun limite di immagini", @@ -119,7 +119,7 @@ const copies: Record, Copy> = { nl: { automaticActive: "Je achtergronden wisselen automatisch", automaticPaused: "Automatisch wisselen is gepauzeerd", settings: "Instellingen", currentWallpaper: "Huidige achtergrond", currentImage: "Huidige afbeelding", nextImage: "Volgende afbeelding", - changeInterval: "Afbeelding wisselen", paused: "Gepauzeerd", selectImages: "Afbeeldingen kiezen", pleaseWait: "Even geduld…", + changeInterval: "Afbeelding wisselen", paused: "Gepauzeerd", screenOn: "Wanneer het scherm aangaat", selectImages: "Afbeeldingen kiezen", pleaseWait: "Even geduld…", collection: "Mijn collectie", image: "afbeelding", images: "afbeeldingen", motif: "Afbeelding", noImages: "Nog geen afbeeldingen gekozen", collectionHint: "Tik op het aantal afbeeldingen om je galerij te openen.", shuffle: "Willekeurige volgorde", lockScreenOnly: "Alleen vergrendelscherm", settingsIntro: "Kies hoe WallpaperFlow op de achtergrond werkt.", language: "Taal", noImageLimit: "Geen afbeeldingslimiet", @@ -140,7 +140,7 @@ const copies: Record, Copy> = { pl: { automaticActive: "Tapety zmieniają się automatycznie", automaticPaused: "Automatyczna zmiana jest wstrzymana", settings: "Ustawienia", currentWallpaper: "Bieżąca tapeta", currentImage: "Bieżący obraz", nextImage: "Następny obraz", - changeInterval: "Zmieniaj obraz", paused: "Wstrzymano", selectImages: "Wybierz obrazy", pleaseWait: "Proszę czekać…", + changeInterval: "Zmieniaj obraz", paused: "Wstrzymano", screenOn: "Po włączeniu ekranu", selectImages: "Wybierz obrazy", pleaseWait: "Proszę czekać…", collection: "Moja kolekcja", image: "obraz", images: "obrazy", motif: "Obraz", noImages: "Nie wybrano jeszcze obrazów", collectionHint: "Dotknij liczby obrazów, aby otworzyć galerię.", shuffle: "Losowa kolejność", lockScreenOnly: "Tylko ekran blokady", settingsIntro: "Wybierz sposób działania WallpaperFlow w tle.", language: "Język", noImageLimit: "Bez limitu obrazów", @@ -161,7 +161,7 @@ const copies: Record, Copy> = { pt: { automaticActive: "Os seus fundos mudam automaticamente", automaticPaused: "A rotação automática está em pausa", settings: "Definições", currentWallpaper: "Fundo atual", currentImage: "Imagem atual", nextImage: "Imagem seguinte", - changeInterval: "Mudar imagem", paused: "Em pausa", selectImages: "Selecionar imagens", pleaseWait: "Aguarde…", + changeInterval: "Mudar imagem", paused: "Em pausa", screenOn: "Ao ligar o ecrã", selectImages: "Selecionar imagens", pleaseWait: "Aguarde…", collection: "A minha coleção", image: "imagem", images: "imagens", motif: "Imagem", noImages: "Ainda não há imagens selecionadas", collectionHint: "Toque no número de imagens para abrir a galeria.", shuffle: "Ordem aleatória", lockScreenOnly: "Apenas ecrã de bloqueio", settingsIntro: "Escolha como o WallpaperFlow funciona em segundo plano.", language: "Idioma", noImageLimit: "Sem limite de imagens", @@ -182,7 +182,7 @@ const copies: Record, Copy> = { ja: { automaticActive: "壁紙は自動的に切り替わります", automaticPaused: "自動切り替えは一時停止中です", settings: "設定", currentWallpaper: "現在の壁紙", currentImage: "現在の画像", nextImage: "次の画像", - changeInterval: "画像を切り替え", paused: "一時停止", selectImages: "画像を選択", pleaseWait: "お待ちください…", + changeInterval: "画像を切り替え", paused: "一時停止", screenOn: "画面点灯時", selectImages: "画像を選択", pleaseWait: "お待ちください…", collection: "マイコレクション", image: "枚", images: "枚", motif: "画像", noImages: "画像がまだ選択されていません", collectionHint: "画像数をタップしてギャラリーを開きます。", shuffle: "ランダムな順序", lockScreenOnly: "ロック画面のみ", settingsIntro: "WallpaperFlow のバックグラウンド動作を設定します。", language: "言語", noImageLimit: "画像数の制限なし", @@ -203,7 +203,7 @@ const copies: Record, Copy> = { ko: { automaticActive: "배경화면이 자동으로 변경됩니다", automaticPaused: "자동 변경이 일시 중지되었습니다", settings: "설정", currentWallpaper: "현재 배경화면", currentImage: "현재 이미지", nextImage: "다음 이미지", - changeInterval: "이미지 변경", paused: "일시 중지", selectImages: "이미지 선택", pleaseWait: "잠시 기다려 주세요…", + changeInterval: "이미지 변경", paused: "일시 중지", screenOn: "화면이 켜질 때", selectImages: "이미지 선택", pleaseWait: "잠시 기다려 주세요…", collection: "내 컬렉션", image: "이미지", images: "이미지", motif: "이미지", noImages: "선택한 이미지가 없습니다", collectionHint: "이미지 수를 탭하여 갤러리를 여세요.", shuffle: "무작위 순서", lockScreenOnly: "잠금 화면만", settingsIntro: "WallpaperFlow의 백그라운드 작동 방식을 선택하세요.", language: "언어", noImageLimit: "이미지 수 제한 없음", @@ -224,7 +224,7 @@ const copies: Record, Copy> = { "zh-CN": { automaticActive: "壁纸会自动更换", automaticPaused: "自动更换已暂停", settings: "设置", currentWallpaper: "当前壁纸", currentImage: "当前图片", nextImage: "下一张图片", - changeInterval: "更换图片", paused: "已暂停", selectImages: "选择图片", pleaseWait: "请稍候…", + changeInterval: "更换图片", paused: "已暂停", screenOn: "屏幕亮起时", selectImages: "选择图片", pleaseWait: "请稍候…", collection: "我的收藏", image: "张图片", images: "张图片", motif: "图片", noImages: "尚未选择图片", collectionHint: "点击图片数量打开图库。", shuffle: "随机顺序", lockScreenOnly: "仅锁屏", settingsIntro: "选择 WallpaperFlow 在后台的工作方式。", language: "语言", noImageLimit: "不限图片数量",