feat(android): switch to screen-on rotation service and updated scheduler

The rotation service now responds to screen on/off events via a
dedicated ScreenOnRotationService, reducing background work and
avoiding perpetual wakeful states. Manifest and receiver logic were
adjusted to align with the new behavior and energy constraints.

Scheduling and enablement now consistently use timed-based rules and
screen-on preferences, with the boot and alarm paths updated to honor
these settings. This avoids unnecessary foreground services and keeps
the app compliant with Doze and battery optimizations.

Immich image handling improvements include throttled progress updates,
larger cache allowances, and a connectivity-aware automatic download
check to avoid unwanted network usage.

- Use ScreenOnRotationService for screen-on based rotation scheduling
- Centralize enablement via timed-enabled paths and AlarmManager windowing
- Improve Immich cache and progress reporting with connectivity checks
This commit is contained in:
2026-08-22 20:04:42 +02:00
parent 73f8599c7f
commit 75c79a8344
26 changed files with 245 additions and 161 deletions
+7 -4
View File
@@ -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 - zufällige oder fortlaufende Reihenfolge
- manueller Wechsel über „Nächstes Motiv“ - manueller Wechsel über „Nächstes Motiv“
- automatischer Wechsel alle 5, 15 oder 30 Minuten beziehungsweise alle 1, 3, 6 oder 12 Stunden - 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 - lokale Vorschaubilder; die Originalbilder verlassen das Gerät nicht
- optionaler Import aus einem selbst gehosteten Immich-Server über einen eingeschränkten API-Key - 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 - 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 ## Entwicklung
@@ -46,9 +49,9 @@ Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er
## Wichtige Android-Hinweise ## 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. - `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. - „Ohne Limit“ bedeutet: kein App-Zähler wie Samsungs 15-Bilder-Grenze. Praktisch begrenzen freier Gerätespeicher und Dateisystem die Sammlung.
## Datenschutz ## Datenschutz
+3 -3
View File
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.SET_WALLPAPER" /> <uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application> <application>
<service android:name="de.wechselbild.wallpaper.WallpaperRotationService" android:exported="false" android:foregroundServiceType="specialUse"> <service android:name="de.wechselbild.wallpaper.ScreenOnRotationService" android:exported="false" android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper at the configured interval" /> <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Changes the user-selected lock-screen wallpaper when the screen turns on" />
</service> </service>
<receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true"> <receiver android:name="de.wechselbild.wallpaper.BootReceiver" android:enabled="true" android:exported="true">
<intent-filter> <intent-filter>
@@ -6,9 +6,10 @@ import android.content.Intent
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) && WallpaperStore.enabled(context)) { if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return
WallpaperScheduler.scheduleNext(context) when {
runCatching { WallpaperRotationService.start(context) } WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
} }
} }
} }
+21 -10
View File
@@ -1,6 +1,7 @@
package de.wechselbild.wallpaper package de.wechselbild.wallpaper
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties 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_DATA = "immich_api_key_data"
private const val KEY_API_KEY_IV = "immich_api_key_iv" private const val KEY_API_KEY_IV = "immich_api_key_iv"
private const val KEY_ALIAS = "wallpaperflow_immich_api_key" private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
private const val CACHE_MAX_FILES = 3 private const val CACHE_MAX_BYTES = 512L * 1024 * 1024
private const val CACHE_MAX_BYTES = 256L * 1024 * 1024
private const val DOWNLOAD_MAX_BYTES = 128L * 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 assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
private val thumbnailPool = Executors.newFixedThreadPool(4) private val thumbnailPool = Executors.newFixedThreadPool(4)
private val importProgress = AtomicReference(ImportProgress()) private val importProgress = AtomicReference(ImportProgress())
@@ -204,6 +205,7 @@ object ImmichClient {
val total = connection.contentLengthLong.coerceAtLeast(0) val total = connection.contentLengthLong.coerceAtLeast(0)
require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." } require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
var downloaded = 0L var downloaded = 0L
var lastProgressUpdate = 0L
if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) } if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
connection.inputStream.use { input -> connection.inputStream.use { input ->
target.outputStream().use { output -> target.outputStream().use { output ->
@@ -214,7 +216,10 @@ object ImmichClient {
output.write(buffer, 0, count) output.write(buffer, 0, count)
downloaded += count downloaded += count
require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." } 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 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 @Synchronized
fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? { fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? {
val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image") val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image")
@@ -347,13 +358,13 @@ object ImmichClient {
} }
private fun trimCache(context: Context, protected: File) { private fun trimCache(context: Context, protected: File) {
val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedByDescending { it.lastModified() } ?: return val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedBy { it.lastModified() } ?: return
var bytes = 0L var bytes = files.sumOf { it.length() }
files.forEachIndexed { index, file -> for (file in files) {
bytes += file.length() if (bytes <= CACHE_MAX_BYTES) break
if (file != protected && (index >= CACHE_MAX_FILES || bytes > CACHE_MAX_BYTES)) { if (file != protected) {
bytes -= file.length() val length = file.length()
file.delete() if (file.delete()) bytes -= length
} }
} }
} }
@@ -4,37 +4,46 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class WallpaperRotationService : Service() { class ScreenOnRotationService : Service() {
private val worker = Executors.newSingleThreadExecutor() private val worker = Executors.newSingleThreadExecutor()
private val handler = Handler(Looper.getMainLooper()) private val rotating = AtomicBoolean(false)
private val rotate = object : Runnable { private val changedWhileScreenOff = AtomicBoolean(false)
override fun run() { private val screenReceiver = object : BroadcastReceiver() {
if (!WallpaperStore.enabled(this@WallpaperRotationService)) { override fun onReceive(context: Context, intent: Intent) {
stopSelf() if (!WallpaperStore.screenOnEnabled(context)) return
return when (intent.action) {
Intent.ACTION_SCREEN_OFF -> rotate(context, preparedForScreenOn = true)
Intent.ACTION_SCREEN_ON -> {
if (!changedWhileScreenOff.getAndSet(false)) rotate(context, preparedForScreenOn = false)
} }
}
}
}
private fun rotate(context: Context, preparedForScreenOn: Boolean) {
if (!rotating.compareAndSet(false, true)) return
worker.execute { worker.execute {
if (WallpaperScheduler.claimIfDue(this@WallpaperRotationService)) { try {
WallpaperRotationWorker.enqueue(this@WallpaperRotationService) if (WallpaperStore.applyNext(context, automatic = true) && preparedForScreenOn) {
} changedWhileScreenOff.set(true)
handler.post { scheduleLocalTimer() }
} }
} finally { rotating.set(false) }
} }
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
runningService = this
createChannel() createChannel()
val launch = packageManager.getLaunchIntentForPackage(packageName) val launch = packageManager.getLaunchIntentForPackage(packageName)
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) 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) .setSmallIcon(android.R.drawable.ic_menu_gallery)
.setContentTitle(getString(R.string.wallpaper_service_title)) .setContentTitle(getString(R.string.wallpaper_service_title))
.setContentText(getString(R.string.wallpaper_service_text)) .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) startForeground(NOTIFICATION_ID, notification)
WallpaperScheduler.ensureScheduled(this) val filter = IntentFilter().apply {
scheduleLocalTimer() 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!WallpaperStore.enabled(this)) { if (!WallpaperStore.screenOnEnabled(this)) {
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
WallpaperScheduler.ensureScheduled(this)
scheduleLocalTimer()
return START_STICKY return START_STICKY
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { override fun onDestroy() {
handler.removeCallbacks(rotate) runCatching { unregisterReceiver(screenReceiver) }
worker.shutdown() worker.shutdown()
if (runningService === this) runningService = null
super.onDestroy() super.onDestroy()
} }
private fun scheduleLocalTimer() { companion object {
handler.removeCallbacks(rotate) private const val CHANNEL = "wechselbild_screen_on"
if (WallpaperStore.enabled(this)) { private const val NOTIFICATION_ID = 4217
handler.postDelayed(rotate, WallpaperScheduler.remainingDelay(this))
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) 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))
}
}
} }
@@ -6,7 +6,7 @@ import android.content.Intent
class WallpaperAlarmReceiver : BroadcastReceiver() { class WallpaperAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (!WallpaperStore.enabled(context)) { if (!WallpaperStore.timedEnabled(context)) {
WallpaperScheduler.cancel(context) WallpaperScheduler.cancel(context)
return return
} }
@@ -14,6 +14,5 @@ class WallpaperAlarmReceiver : BroadcastReceiver() {
WallpaperRotationWorker.enqueue(context) WallpaperRotationWorker.enqueue(context)
} }
WallpaperScheduler.ensureScheduled(context) WallpaperScheduler.ensureScheduled(context)
WallpaperRotationService.rescheduleRunningService()
} }
} }
@@ -58,7 +58,6 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
private val io = Executors.newSingleThreadExecutor() private val io = Executors.newSingleThreadExecutor()
@Command fun getState(invoke: Invoke) { @Command fun getState(invoke: Invoke) {
if (WallpaperStore.enabled(activity)) runCatching { WallpaperRotationService.start(activity) }
io.execute { invoke.resolve(WallpaperStore.state(activity)) } io.execute { invoke.resolve(WallpaperStore.state(activity)) }
} }
@@ -141,7 +140,10 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
try { try {
val args = invoke.parseArgs(IntervalArgs::class.java) val args = invoke.parseArgs(IntervalArgs::class.java)
WallpaperStore.setInterval(activity, args.minutes) 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)) invoke.resolve(WallpaperStore.state(activity))
} catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") } } catch (error: Exception) { invoke.reject(error.message ?: "Wechselintervall konnte nicht gespeichert werden") }
} }
@@ -2,6 +2,7 @@ package de.wechselbild.wallpaper
import android.content.Context import android.content.Context
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.Constraints
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.Worker import androidx.work.Worker
@@ -9,9 +10,9 @@ import androidx.work.WorkerParameters
class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
override fun doWork(): Result { override fun doWork(): Result {
if (!WallpaperStore.enabled(applicationContext)) return Result.success() if (!WallpaperStore.timedEnabled(applicationContext)) return Result.success()
if (WallpaperStore.applyNext(applicationContext)) return Result.success() WallpaperStore.applyNext(applicationContext, automatic = true)
return if (runAttemptCount < 2) Result.retry() else Result.failure() return Result.success()
} }
companion object { companion object {
@@ -21,7 +22,9 @@ class WallpaperRotationWorker(context: Context, parameters: WorkerParameters) :
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME, WORK_NAME,
ExistingWorkPolicy.KEEP, ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<WallpaperRotationWorker>().build(), OneTimeWorkRequestBuilder<WallpaperRotationWorker>()
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
.build(),
) )
} }
} }
@@ -4,7 +4,6 @@ import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.SystemClock import android.os.SystemClock
object WallpaperScheduler { object WallpaperScheduler {
@@ -13,7 +12,7 @@ object WallpaperScheduler {
private const val REQUEST_CODE = 4218 private const val REQUEST_CODE = 4218
fun ensureScheduled(context: Context) { fun ensureScheduled(context: Context) {
if (!WallpaperStore.enabled(context)) { if (!WallpaperStore.timedEnabled(context)) {
cancel(context) cancel(context)
return return
} }
@@ -23,11 +22,6 @@ object WallpaperScheduler {
if (storedTrigger <= now || storedTrigger > now + interval) scheduleNext(context) 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 @Synchronized
fun claimIfDue(context: Context): Boolean { fun claimIfDue(context: Context): Boolean {
val preferences = preferences(context) val preferences = preferences(context)
@@ -47,11 +41,8 @@ object WallpaperScheduler {
val trigger = SystemClock.elapsedRealtime() + minutes * 60_000L val trigger = SystemClock.elapsedRealtime() + minutes * 60_000L
val alarmManager = context.getSystemService(AlarmManager::class.java) val alarmManager = context.getSystemService(AlarmManager::class.java)
val operation = operation(context) val operation = operation(context)
if (Build.VERSION.SDK_INT >= 23) { val window = (minutes * 60_000L / 10).coerceIn(60_000L, 15 * 60_000L)
alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation) alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME, trigger, window, operation)
} else {
alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, trigger, operation)
}
preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit() preferences(context).edit().putLong(KEY_NEXT_TRIGGER, trigger).commit()
} }
+94 -20
View File
@@ -23,6 +23,9 @@ object WallpaperStore {
private const val KEY_CURRENT_ID = "current_entry_id" private const val KEY_CURRENT_ID = "current_entry_id"
private const val KEY_INTERVAL = "interval_minutes" private const val KEY_INTERVAL = "interval_minutes"
private const val CROP_PREFIX = "crop_" private const val CROP_PREFIX = "crop_"
private const val 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 const val HOME_PREVIEW_LIMIT = 12
private val thumbnailCache = LruCache<String, String>(48) private val thumbnailCache = LruCache<String, String>(48)
@@ -47,6 +50,8 @@ object WallpaperStore {
private fun immichEntries(context: Context) = File(immichRoot(context), "entries").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 immichPreviews(context: Context) = File(immichRoot(context), "previews").apply { mkdirs() }
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun 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") private fun serverKey(serverUrl: String): String = MessageDigest.getInstance("SHA-256")
.digest(serverUrl.toByteArray(Charsets.UTF_8)).take(6).joinToString("") { "%02x".format(it) } .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) return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
else if (preferences.getBoolean("enabled", false)) 30 else 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 shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true) fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
@@ -102,8 +109,8 @@ object WallpaperStore {
} }
fun setInterval(context: Context, minutes: Int) { fun setInterval(context: Context, minutes: Int) {
require(minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" } require(minutes == -1 || minutes == 0 || minutes in 5..720) { "Ungültiges Wechselintervall" }
prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes > 0).apply() prefs(context).edit().putInt(KEY_INTERVAL, minutes).putBoolean("enabled", minutes != 0).commit()
} }
@Synchronized @Synchronized
@@ -124,7 +131,7 @@ object WallpaperStore {
previewItems.forEach { ids.put(it.id) } previewItems.forEach { ids.put(it.id) }
put("imageIds", ids) put("imageIds", ids)
val previews = JSArray() 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) put("imageUrls", previews)
} }
} }
@@ -164,7 +171,7 @@ object WallpaperStore {
val crop = crop(context, entry) val crop = crop(context, entry)
return JSObject().apply { return JSObject().apply {
put("id", entry.id) put("id", entry.id)
put("url", thumbnailDataUrl(entry.preview)) put("url", thumbnailDataUrl(context, entry.preview))
put("selected", selected) put("selected", selected)
put("cropMode", crop.mode) put("cropMode", crop.mode)
put("cropZoom", crop.zoom) 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 }) 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()}" val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}"
thumbnailCache.get(cacheKey)?.let { return it } 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 } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds) BitmapFactory.decodeFile(file.absolutePath, bounds)
var sample = 1 var sample = 1
@@ -304,7 +318,9 @@ object WallpaperStore {
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return "" val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
val result = ByteArrayOutputStream().use { out -> val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle() 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) thumbnailCache.put(cacheKey, result)
return result return result
@@ -314,15 +330,52 @@ object WallpaperStore {
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}") 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 @Synchronized
fun applyNext(context: Context): Boolean { fun applyNext(context: Context, automatic: Boolean = false): Boolean {
val items = entries(context) val items = entries(context)
if (items.isEmpty()) return false if (items.isEmpty()) return false
val previous = currentIndex(context, items) val previous = currentIndex(context, items)
val candidates = if (shuffle(context) && items.size > 1) { val candidates = if (shuffle(context) && items.size > 1) {
items.indices.filter { it != previous }.shuffled() + previous items.indices.filter { it != previous }.shuffled() + previous
} else (1..items.size).map { (previous + it).mod(items.size) } } else (1..items.size).map { (previous + it).mod(items.size) }
return applyCandidates(context, items, candidates) return applyCandidates(context, items, candidates, automatic)
} }
@Synchronized @Synchronized
@@ -330,37 +383,58 @@ object WallpaperStore {
val items = entries(context) val items = entries(context)
val index = items.indexOfFirst { it.id == id } val index = items.indexOfFirst { it.id == id }
if (index < 0) return false 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<Entry>, candidates: List<Int>): Boolean { private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>, automatic: Boolean): Boolean {
val unavailableServers = mutableSetOf<String>() val unavailableServers = mutableSetOf<String>()
for (index in candidates) { for (index in candidates) {
val entry = items[index] val entry = items[index]
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) { val sourceFile = when (entry) {
is Entry.Local -> entry.file is Entry.Local -> entry.file
is Entry.Immich -> { is Entry.Immich -> {
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, entry.serverUrl !in unavailableServers) val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context))
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
if (source == null) { if (source == null) {
unavailableServers.add(entry.serverUrl) if (allowNetwork) unavailableServers.add(entry.serverUrl)
continue continue
} }
source source
} }
} }
val bitmap = decodeForScreen(context, sourceFile) ?: continue val bitmap = decodeForScreen(context, sourceFile)
val rendered = runCatching { renderForScreen(context, bitmap, crop(context, entry)) }.getOrNull() if (bitmap == null) {
if (rendered == null) { bitmap.recycle(); continue } 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 wallpaper = rendered ?: continue
try { try {
val manager = WallpaperManager.getInstance(context) val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK) if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(rendered) else manager.setBitmap(wallpaper)
prefs(context).edit().putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply() prefs(context).edit().remove(INVALID_PREFIX + entry.id).putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
return true return true
} catch (_: Exception) { } catch (_: Exception) {
// Try the next usable entry without changing the current selection. // Try the next usable entry without changing the current selection.
} finally { } finally {
rendered.recycle(); bitmap.recycle() wallpaper.recycle()
} }
} }
return false return false
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string> <string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
<string name="wallpaper_service_text">Das Motiv wechselt nach dem eingestellten Intervall.</string> <string name="wallpaper_service_text">Das nächste Motiv wird beim Ausschalten des Displays vorbereitet.</string>
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string> <string name="wallpaper_channel_name">Bildwechsel bei Display-Aktivierung</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow está activo</string> <string name="wallpaper_service_title">WallpaperFlow está activo</string>
<string name="wallpaper_service_text">El fondo cambia según el intervalo configurado.</string> <string name="wallpaper_service_text">El fondo cambia cuando se enciende la pantalla.</string>
<string name="wallpaper_channel_name">Rotación automática de fondos</string> <string name="wallpaper_channel_name">Rotación al encender la pantalla</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow est actif</string> <string name="wallpaper_service_title">WallpaperFlow est actif</string>
<string name="wallpaper_service_text">Le fond d’écran change selon lintervalle défini.</string> <string name="wallpaper_service_text">Le fond d’écran change lorsque l’écran sallume.</string>
<string name="wallpaper_channel_name">Rotation automatique des fonds d’écran</string> <string name="wallpaper_channel_name">Rotation à lallumage de l’écran</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow è attivo</string> <string name="wallpaper_service_title">WallpaperFlow è attivo</string>
<string name="wallpaper_service_text">Lo sfondo cambia allintervallo configurato.</string> <string name="wallpaper_service_text">Lo sfondo cambia quando si accende lo schermo.</string>
<string name="wallpaper_channel_name">Rotazione automatica degli sfondi</string> <string name="wallpaper_channel_name">Rotazione allaccensione dello schermo</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow は実行中です</string> <string name="wallpaper_service_title">WallpaperFlow は実行中です</string>
<string name="wallpaper_service_text">設定した間隔で壁紙が切り替わります。</string> <string name="wallpaper_service_text">画面が点灯すると壁紙が切り替わります。</string>
<string name="wallpaper_channel_name">壁紙の自動切り替え</string> <string name="wallpaper_channel_name">画面点灯時の壁紙切り替え</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow 실행 중</string> <string name="wallpaper_service_title">WallpaperFlow 실행 중</string>
<string name="wallpaper_service_text">설정한 간격에 따라 배경화면이 변경됩니다.</string> <string name="wallpaper_service_text">화면이 켜질 때 배경화면이 변경됩니다.</string>
<string name="wallpaper_channel_name">자동 배경화면 변경</string> <string name="wallpaper_channel_name">화면 켜짐 시 배경화면 변경</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow is actief</string> <string name="wallpaper_service_title">WallpaperFlow is actief</string>
<string name="wallpaper_service_text">De achtergrond wisselt volgens het ingestelde interval.</string> <string name="wallpaper_service_text">De achtergrond wisselt wanneer het scherm aangaat.</string>
<string name="wallpaper_channel_name">Automatisch achtergronden wisselen</string> <string name="wallpaper_channel_name">Wisselen bij scherminschakeling</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow jest aktywny</string> <string name="wallpaper_service_title">WallpaperFlow jest aktywny</string>
<string name="wallpaper_service_text">Tapeta zmienia się zgodnie z ustawionym interwałem.</string> <string name="wallpaper_service_text">Tapeta zmienia się po włączeniu ekranu.</string>
<string name="wallpaper_channel_name">Automatyczna zmiana tapety</string> <string name="wallpaper_channel_name">Zmiana po włączeniu ekranu</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow está ativo</string> <string name="wallpaper_service_title">WallpaperFlow está ativo</string>
<string name="wallpaper_service_text">O fundo muda de acordo com o intervalo definido.</string> <string name="wallpaper_service_text">O fundo muda quando o ecrã é ligado.</string>
<string name="wallpaper_channel_name">Rotação automática de fundos</string> <string name="wallpaper_channel_name">Rotação ao ligar o ecrã</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow 正在运行</string> <string name="wallpaper_service_title">WallpaperFlow 正在运行</string>
<string name="wallpaper_service_text">壁纸会按照设定的间隔更换</string> <string name="wallpaper_service_text">屏幕亮起时会更换壁纸。</string>
<string name="wallpaper_channel_name">自动更换壁纸</string> <string name="wallpaper_channel_name">亮屏时更换壁纸</string>
</resources> </resources>
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="wallpaper_service_title">WallpaperFlow is active</string> <string name="wallpaper_service_title">WallpaperFlow is active</string>
<string name="wallpaper_service_text">The wallpaper changes at the configured interval.</string> <string name="wallpaper_service_text">The next wallpaper is prepared when the screen turns off.</string>
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string> <string name="wallpaper_channel_name">Screen-on wallpaper rotation</string>
</resources> </resources>
+1 -1
View File
@@ -64,7 +64,7 @@ pub(crate) async fn set_setting<R: Runtime>(
app.wallpaper().set_setting(SettingRequest { name, value }) app.wallpaper().set_setting(SettingRequest { name, value })
} }
#[command] #[command]
pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: usize) -> Result<WallpaperState> { pub(crate) async fn set_interval<R: Runtime>(app: AppHandle<R>, minutes: i32) -> Result<WallpaperState> {
app.wallpaper().set_interval(IntervalRequest { minutes }) app.wallpaper().set_interval(IntervalRequest { minutes })
} }
#[command] #[command]
+1 -1
View File
@@ -96,7 +96,7 @@ impl<R: Runtime> Wallpaper<R> {
pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result<WallpaperState> { pub fn set_interval(&self, payload: IntervalRequest) -> crate::Result<WallpaperState> {
let mut state = Self::demo(); let mut state = Self::demo();
state.interval_minutes = payload.minutes; state.interval_minutes = payload.minutes;
state.enabled = payload.minutes > 0; state.enabled = payload.minutes != 0;
Ok(state) Ok(state)
} }
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> { pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
+2 -2
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct WallpaperState { pub struct WallpaperState {
pub image_count: usize, pub image_count: usize,
pub enabled: bool, pub enabled: bool,
pub interval_minutes: usize, pub interval_minutes: i32,
pub shuffle: bool, pub shuffle: bool,
pub lock_screen_only: bool, pub lock_screen_only: bool,
pub current_index: usize, pub current_index: usize,
@@ -24,7 +24,7 @@ pub struct SettingRequest {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct IntervalRequest { pub struct IntervalRequest {
pub minutes: usize, pub minutes: i32,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
+7 -8
View File
@@ -14,9 +14,10 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l
return <div className="setting-row"><div className="setting-icon">{icon}</div><span>{label}</span><Switch checked={value} onChange={onChange} label={label} /></div>; return <div className="setting-row"><div className="setting-icon">{icon}</div><span>{label}</span><Switch checked={value} onChange={onChange} label={label} /></div>;
} }
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 <label className="setting-row interval-row"><div className="setting-icon"><Smartphone /></div><span>{label}</span><select value={value} onChange={event => onChange(Number(event.target.value))} aria-label={label}> return <label className="setting-row interval-row"><div className="setting-icon"><Smartphone /></div><span>{label}</span><select value={value} onChange={event => onChange(Number(event.target.value))} aria-label={label}>
<option value={0}>{paused}</option> <option value={0}>{paused}</option>
<option value={-1}>{screenOn}</option>
{[5, 15, 30, 60, 180, 360, 720].map(minutes => <option value={minutes} key={minutes}>{everyMinutes(minutes)}</option>)} {[5, 15, 30, 60, 180, 360, 720].map(minutes => <option value={minutes} key={minutes}>{everyMinutes(minutes)}</option>)}
</select></label>; </select></label>;
} }
@@ -54,11 +55,9 @@ export default function App() {
const refresh = () => { void getState().then(setState).catch(() => undefined); }; const refresh = () => { void getState().then(setState).catch(() => undefined); };
refresh(); refresh();
const onVisibilityChange = () => { if (!document.hidden) refresh(); }; const onVisibilityChange = () => { if (!document.hidden) refresh(); };
const refreshInterval = window.setInterval(() => { if (!document.hidden) refresh(); }, 15000);
document.addEventListener("visibilitychange", onVisibilityChange); document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", refresh); window.addEventListener("focus", refresh);
return () => { return () => {
window.clearInterval(refreshInterval);
document.removeEventListener("visibilitychange", onVisibilityChange); document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", refresh); window.removeEventListener("focus", refresh);
}; };
@@ -118,11 +117,11 @@ export default function App() {
} }
async function updateInterval(minutes: number) { async function updateInterval(minutes: number) {
setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes > 0 })); setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes !== 0 }));
try { try {
const saved = await setIntervalMinutes(minutes); const saved = await setIntervalMinutes(minutes);
setState(saved); setState(saved);
setNotice(minutes > 0 ? t.intervalSaved : t.automaticDisabled); setNotice(minutes !== 0 ? t.intervalSaved : t.automaticDisabled);
} }
catch { setNotice(t.androidOnly); } catch { setNotice(t.androidOnly); }
} }
@@ -249,7 +248,7 @@ export default function App() {
setNotice(it.importing); setNotice(it.importing);
const poll = window.setInterval(() => { const poll = window.setInterval(() => {
void getImmichImportProgress().then(setImmichImportProgress).catch(() => undefined); void getImmichImportProgress().then(setImmichImportProgress).catch(() => undefined);
}, 250); }, 750);
try { try {
setState(await importImmichAssets([...immichSelected])); setState(await importImmichAssets([...immichSelected]));
setImmichSelected(new Set()); setImmichSelected(new Set());
@@ -408,7 +407,7 @@ export default function App() {
<div className="hero-meta"><span><Sparkles size={16} /> {t.currentImage}</span><button onClick={next} disabled={busy}>{t.nextImage} <ChevronRight size={18} /></button></div> <div className="hero-meta"><span><Sparkles size={16} /> {t.currentImage}</span><button onClick={next} disabled={busy}>{t.nextImage} <ChevronRight size={18} /></button></div>
</section> </section>
<IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} /> <IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} screenOn={t.screenOn} everyMinutes={t.everyMinutes} />
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button> <button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
@@ -421,7 +420,7 @@ export default function App() {
</> : tab === "settings" ? <section className="settings-page"> </> : tab === "settings" ? <section className="settings-page">
<h2>{t.settings}</h2><p>{t.settingsIntro}</p> <h2>{t.settings}</h2><p>{t.settingsIntro}</p>
<label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label> <label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label>
<div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div> <div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} screenOn={t.screenOn} everyMinutes={t.everyMinutes} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div>
<section className="immich-settings"> <section className="immich-settings">
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div> <div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
{immichConnection.configured ? <> {immichConnection.configured ? <>
+11 -11
View File
@@ -9,7 +9,7 @@ export const languageNames: Record<Language, string> = {
const en = { const en = {
automaticActive: "Your wallpapers change automatically", automaticPaused: "Automatic rotation is paused", automaticActive: "Your wallpapers change automatically", automaticPaused: "Automatic rotation is paused",
settings: "Settings", currentWallpaper: "Current wallpaper", currentImage: "Current image", nextImage: "Next image", 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", 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", 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", settingsIntro: "Choose how WallpaperFlow works in the background.", language: "Language", noImageLimit: "No image limit",
@@ -35,7 +35,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
de: { de: {
automaticActive: "Deine Motive wechseln automatisch", automaticPaused: "Automatischer Wechsel ist pausiert", automaticActive: "Deine Motive wechseln automatisch", automaticPaused: "Automatischer Wechsel ist pausiert",
settings: "Einstellungen", currentWallpaper: "Aktuelles Hintergrundbild", currentImage: "Aktuelles Motiv", nextImage: "Nächstes Motiv", 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", 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", 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", settingsIntro: "Lege fest, wie WallpaperFlow im Hintergrund arbeitet.", language: "Sprache", noImageLimit: "Ohne Bilderlimit",
@@ -56,7 +56,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
fr: { fr: {
automaticActive: "Vos fonds d’écran changent automatiquement", automaticPaused: "La rotation automatique est en pause", 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", settings: "Paramètres", currentWallpaper: "Fond d’écran actuel", currentImage: "Image actuelle", nextImage: "Image suivante",
changeInterval: "Changer limage", paused: "En pause", selectImages: "Sélectionner des images", pleaseWait: "Veuillez patienter…", changeInterval: "Changer limage", paused: "En pause", screenOn: "À lallumage 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", collection: "Ma collection", image: "image", images: "images", motif: "Image", noImages: "Aucune image sélectionnée",
collectionHint: "Touchez le nombre dimages pour ouvrir votre galerie.", shuffle: "Ordre aléatoire", lockScreenOnly: "Écran de verrouillage uniquement", collectionHint: "Touchez le nombre dimages 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 dimages", settingsIntro: "Choisissez comment WallpaperFlow fonctionne en arrière-plan.", language: "Langue", noImageLimit: "Aucune limite dimages",
@@ -77,7 +77,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
es: { es: {
automaticActive: "Tus fondos cambian automáticamente", automaticPaused: "La rotación automática está en pausa", 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", 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", 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", 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", 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<Exclude<Language, "en">, Copy> = {
it: { it: {
automaticActive: "Gli sfondi cambiano automaticamente", automaticPaused: "La rotazione automatica è in pausa", automaticActive: "Gli sfondi cambiano automaticamente", automaticPaused: "La rotazione automatica è in pausa",
settings: "Impostazioni", currentWallpaper: "Sfondo attuale", currentImage: "Immagine attuale", nextImage: "Immagine successiva", 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: "Allaccensione dello schermo", selectImages: "Seleziona immagini", pleaseWait: "Attendi…",
collection: "La mia raccolta", image: "immagine", images: "immagini", motif: "Immagine", noImages: "Nessuna immagine selezionata", 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", 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", settingsIntro: "Scegli come funziona WallpaperFlow in background.", language: "Lingua", noImageLimit: "Nessun limite di immagini",
@@ -119,7 +119,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
nl: { nl: {
automaticActive: "Je achtergronden wisselen automatisch", automaticPaused: "Automatisch wisselen is gepauzeerd", automaticActive: "Je achtergronden wisselen automatisch", automaticPaused: "Automatisch wisselen is gepauzeerd",
settings: "Instellingen", currentWallpaper: "Huidige achtergrond", currentImage: "Huidige afbeelding", nextImage: "Volgende afbeelding", 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", 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", 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", settingsIntro: "Kies hoe WallpaperFlow op de achtergrond werkt.", language: "Taal", noImageLimit: "Geen afbeeldingslimiet",
@@ -140,7 +140,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
pl: { pl: {
automaticActive: "Tapety zmieniają się automatycznie", automaticPaused: "Automatyczna zmiana jest wstrzymana", automaticActive: "Tapety zmieniają się automatycznie", automaticPaused: "Automatyczna zmiana jest wstrzymana",
settings: "Ustawienia", currentWallpaper: "Bieżąca tapeta", currentImage: "Bieżący obraz", nextImage: "Następny obraz", 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", 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", 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", settingsIntro: "Wybierz sposób działania WallpaperFlow w tle.", language: "Język", noImageLimit: "Bez limitu obrazów",
@@ -161,7 +161,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
pt: { pt: {
automaticActive: "Os seus fundos mudam automaticamente", automaticPaused: "A rotação automática está em pausa", 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", 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", 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", 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", settingsIntro: "Escolha como o WallpaperFlow funciona em segundo plano.", language: "Idioma", noImageLimit: "Sem limite de imagens",
@@ -182,7 +182,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
ja: { ja: {
automaticActive: "壁紙は自動的に切り替わります", automaticPaused: "自動切り替えは一時停止中です", automaticActive: "壁紙は自動的に切り替わります", automaticPaused: "自動切り替えは一時停止中です",
settings: "設定", currentWallpaper: "現在の壁紙", currentImage: "現在の画像", nextImage: "次の画像", settings: "設定", currentWallpaper: "現在の壁紙", currentImage: "現在の画像", nextImage: "次の画像",
changeInterval: "画像を切り替え", paused: "一時停止", selectImages: "画像を選択", pleaseWait: "お待ちください…", changeInterval: "画像を切り替え", paused: "一時停止", screenOn: "画面点灯時", selectImages: "画像を選択", pleaseWait: "お待ちください…",
collection: "マイコレクション", image: "枚", images: "枚", motif: "画像", noImages: "画像がまだ選択されていません", collection: "マイコレクション", image: "枚", images: "枚", motif: "画像", noImages: "画像がまだ選択されていません",
collectionHint: "画像数をタップしてギャラリーを開きます。", shuffle: "ランダムな順序", lockScreenOnly: "ロック画面のみ", collectionHint: "画像数をタップしてギャラリーを開きます。", shuffle: "ランダムな順序", lockScreenOnly: "ロック画面のみ",
settingsIntro: "WallpaperFlow のバックグラウンド動作を設定します。", language: "言語", noImageLimit: "画像数の制限なし", settingsIntro: "WallpaperFlow のバックグラウンド動作を設定します。", language: "言語", noImageLimit: "画像数の制限なし",
@@ -203,7 +203,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
ko: { ko: {
automaticActive: "배경화면이 자동으로 변경됩니다", automaticPaused: "자동 변경이 일시 중지되었습니다", automaticActive: "배경화면이 자동으로 변경됩니다", automaticPaused: "자동 변경이 일시 중지되었습니다",
settings: "설정", currentWallpaper: "현재 배경화면", currentImage: "현재 이미지", nextImage: "다음 이미지", settings: "설정", currentWallpaper: "현재 배경화면", currentImage: "현재 이미지", nextImage: "다음 이미지",
changeInterval: "이미지 변경", paused: "일시 중지", selectImages: "이미지 선택", pleaseWait: "잠시 기다려 주세요…", changeInterval: "이미지 변경", paused: "일시 중지", screenOn: "화면이 켜질 때", selectImages: "이미지 선택", pleaseWait: "잠시 기다려 주세요…",
collection: "내 컬렉션", image: "이미지", images: "이미지", motif: "이미지", noImages: "선택한 이미지가 없습니다", collection: "내 컬렉션", image: "이미지", images: "이미지", motif: "이미지", noImages: "선택한 이미지가 없습니다",
collectionHint: "이미지 수를 탭하여 갤러리를 여세요.", shuffle: "무작위 순서", lockScreenOnly: "잠금 화면만", collectionHint: "이미지 수를 탭하여 갤러리를 여세요.", shuffle: "무작위 순서", lockScreenOnly: "잠금 화면만",
settingsIntro: "WallpaperFlow의 백그라운드 작동 방식을 선택하세요.", language: "언어", noImageLimit: "이미지 수 제한 없음", settingsIntro: "WallpaperFlow의 백그라운드 작동 방식을 선택하세요.", language: "언어", noImageLimit: "이미지 수 제한 없음",
@@ -224,7 +224,7 @@ const copies: Record<Exclude<Language, "en">, Copy> = {
"zh-CN": { "zh-CN": {
automaticActive: "壁纸会自动更换", automaticPaused: "自动更换已暂停", automaticActive: "壁纸会自动更换", automaticPaused: "自动更换已暂停",
settings: "设置", currentWallpaper: "当前壁纸", currentImage: "当前图片", nextImage: "下一张图片", settings: "设置", currentWallpaper: "当前壁纸", currentImage: "当前图片", nextImage: "下一张图片",
changeInterval: "更换图片", paused: "已暂停", selectImages: "选择图片", pleaseWait: "请稍候…", changeInterval: "更换图片", paused: "已暂停", screenOn: "屏幕亮起时", selectImages: "选择图片", pleaseWait: "请稍候…",
collection: "我的收藏", image: "张图片", images: "张图片", motif: "图片", noImages: "尚未选择图片", collection: "我的收藏", image: "张图片", images: "张图片", motif: "图片", noImages: "尚未选择图片",
collectionHint: "点击图片数量打开图库。", shuffle: "随机顺序", lockScreenOnly: "仅锁屏", collectionHint: "点击图片数量打开图库。", shuffle: "随机顺序", lockScreenOnly: "仅锁屏",
settingsIntro: "选择 WallpaperFlow 在后台的工作方式。", language: "语言", noImageLimit: "不限图片数量", settingsIntro: "选择 WallpaperFlow 在后台的工作方式。", language: "语言", noImageLimit: "不限图片数量",