feat(wallpapers): add image crop support and i18n strings

Adds per-image image-crop support with persistent settings.
Rendering adapts to crop mode, zoom and position per image.
Locales the UI strings and notifications; app name updated.

- Cropping workflow with per-image crop state and rendering
- Localizes strings and notifications for en and de
- Introduces set_image_crop command and its permission schema
This commit is contained in:
2026-08-20 23:26:15 +02:00
parent 2cfc7c5dcb
commit 197090ddd2
23 changed files with 622 additions and 74 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f8faf7" />
<title>LockScreenWallpaper</title>
<title>WallpaperFlow</title>
</head>
<body>
<div id="root"></div>
@@ -23,6 +23,15 @@ class GalleryArgs { var offset: Int = 0; var limit: Int = 48 }
@InvokeArg
class DeleteImageArgs { lateinit var id: String }
@InvokeArg
class ImageCropArgs {
lateinit var id: String
lateinit var mode: String
var zoom: Double = 1.0
var positionX: Double = 0.5
var positionY: Double = 0.5
}
@TauriPlugin
class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
private val io = Executors.newSingleThreadExecutor()
@@ -54,6 +63,15 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
} catch (error: Exception) { invoke.reject(error.message ?: "Bild konnte nicht gelöscht werden") }
}
@Command fun setImageCrop(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(ImageCropArgs::class.java)
val image = WallpaperStore.setCrop(activity, args.id, args.mode, args.zoom, args.positionX, args.positionY)
?: throw IllegalArgumentException("Bild wurde nicht gefunden")
invoke.resolve(image)
} catch (error: Exception) { invoke.reject(error.message ?: "Bildausschnitt konnte nicht gespeichert werden") }
}
@ActivityCallback
fun selectedImages(invoke: Invoke, result: ActivityResult) {
if (result.resultCode != Activity.RESULT_OK) { invoke.reject("Bildauswahl abgebrochen"); return }
@@ -29,8 +29,8 @@ class WallpaperRotationService : Service() {
val pending = PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val notification = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_gallery)
.setContentTitle("LockScreenWallpaper ist aktiv")
.setContentText("Das Motiv wechselt beim Aktivieren des Displays.")
.setContentTitle(getString(R.string.wallpaper_service_title))
.setContentText(getString(R.string.wallpaper_service_text))
.setOngoing(true).setSilent(true).setContentIntent(pending).build()
startForeground(NOTIFICATION_ID, notification)
ContextCompat.registerReceiver(this, screenReceiver, IntentFilter(Intent.ACTION_SCREEN_ON), ContextCompat.RECEIVER_NOT_EXPORTED)
@@ -42,7 +42,7 @@ class WallpaperRotationService : Service() {
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val channel = NotificationChannel(CHANNEL, "Automatischer Bildwechsel", NotificationManager.IMPORTANCE_LOW)
val channel = NotificationChannel(CHANNEL, getString(R.string.wallpaper_channel_name), NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
+104 -10
View File
@@ -4,16 +4,23 @@ import android.app.WallpaperManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.util.Base64
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.random.Random
import org.json.JSONObject
object WallpaperStore {
private const val PREFS = "wechselbild"
private const val KEY_INDEX = "current_index"
private const val CROP_PREFIX = "crop_"
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5)
fun directory(context: Context) = File(context.filesDir, "wallpapers").apply { mkdirs() }
fun files(context: Context) = directory(context).listFiles()?.filter { it.isFile }?.sortedBy { it.name } ?: emptyList()
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -51,11 +58,7 @@ object WallpaperStore {
val safeLimit = limit.coerceIn(1, 100)
val items = JSArray()
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file ->
items.put(JSObject().apply {
put("id", file.name)
put("url", thumbnailDataUrl(file))
put("selected", safeOffset + pageIndex == selectedIndex)
})
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex))
}
return JSObject().apply {
put("total", originals.size)
@@ -63,6 +66,55 @@ object WallpaperStore {
}
}
private fun cropKey(id: String) = CROP_PREFIX + id
private fun crop(context: Context, file: File): CropSettings {
val raw = prefs(context).getString(cropKey(file.name), null) ?: return CropSettings()
return try {
val json = JSONObject(raw)
CropSettings(
mode = if (json.optString("mode") == "contain") "contain" else "cover",
zoom = json.optDouble("zoom", 1.0).coerceIn(0.35, 3.0),
x = json.optDouble("x", 0.5).coerceIn(0.0, 1.0),
y = json.optDouble("y", 0.5).coerceIn(0.0, 1.0),
)
} catch (_: Exception) { CropSettings() }
}
private fun galleryImage(context: Context, file: File, selected: Boolean): JSObject {
val crop = crop(context, file)
return JSObject().apply {
put("id", file.name)
put("url", thumbnailDataUrl(file))
put("selected", selected)
put("cropMode", crop.mode)
put("cropZoom", crop.zoom)
put("cropPositionX", crop.x)
put("cropPositionY", crop.y)
}
}
@Synchronized
fun setCrop(context: Context, id: String, mode: String, zoom: Double, x: Double, y: Double): JSObject? {
if (id.isBlank() || File(id).name != id) return null
val file = files(context).firstOrNull { it.name == id } ?: return null
val normalized = CropSettings(
mode = if (mode == "contain") "contain" else "cover",
zoom = zoom.coerceIn(0.35, 3.0),
x = x.coerceIn(0.0, 1.0),
y = y.coerceIn(0.0, 1.0),
)
val json = JSONObject().apply {
put("mode", normalized.mode)
put("zoom", normalized.zoom)
put("x", normalized.x)
put("y", normalized.y)
}
prefs(context).edit().putString(cropKey(id), json.toString()).apply()
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0)
return galleryImage(context, file, files(context).indexOf(file) == selectedIndex)
}
@Synchronized
fun delete(context: Context, id: String): Boolean {
if (id.isBlank() || File(id).name != id) return false
@@ -79,10 +131,48 @@ object WallpaperStore {
previousIndex >= remaining -> remaining - 1
else -> previousIndex
}
preferences.edit().putInt(KEY_INDEX, nextIndex).apply()
preferences.edit().remove(cropKey(id)).putInt(KEY_INDEX, nextIndex).apply()
return true
}
private fun renderForScreen(context: Context, source: Bitmap, crop: CropSettings): Bitmap {
val metrics = context.resources.displayMetrics
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
val widthScale = targetWidth.toDouble() / source.width
val heightScale = targetHeight.toDouble() / source.height
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
val scale = (baseScale * crop.zoom).toFloat()
val scaledWidth = source.width * scale
val scaledHeight = source.height * scale
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
val canvas = Canvas(output)
canvas.drawColor(Color.BLACK)
val matrix = Matrix().apply {
setScale(scale, scale)
postTranslate(left, top)
}
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
}
}
private fun decodeForScreen(context: Context, file: File): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val metrics = context.resources.displayMetrics
val targetLongSide = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2
return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
})
}
private fun thumbnailDataUrl(file: File): String {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
@@ -105,13 +195,17 @@ object WallpaperStore {
val index = if (shuffle(context) && images.size > 1) {
generateSequence { Random.nextInt(images.size) }.first { it != previous }
} else (previous + 1).mod(images.size)
val bitmap = BitmapFactory.decodeFile(images[index].absolutePath) ?: return false
val bitmap = decodeForScreen(context, images[index]) ?: return false
val rendered = renderForScreen(context, bitmap, crop(context, images[index]))
try {
val manager = WallpaperManager.getInstance(context)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(bitmap, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(bitmap)
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(rendered, null, true, WallpaperManager.FLAG_LOCK)
else manager.setBitmap(rendered)
preferences.edit().putInt(KEY_INDEX, index).apply()
return true
} finally { bitmap.recycle() }
} finally {
rendered.recycle()
bitmap.recycle()
}
}
}
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow ist aktiv</string>
<string name="wallpaper_service_text">Das Motiv wechselt beim Aktivieren des Displays.</string>
<string name="wallpaper_channel_name">Automatischer Bildwechsel</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="wallpaper_service_title">WallpaperFlow is active</string>
<string name="wallpaper_service_text">The wallpaper changes whenever the screen wakes.</string>
<string name="wallpaper_channel_name">Automatic wallpaper rotation</string>
</resources>
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-image-crop"
description = "Enables the set_image_crop command without any pre-configured scope."
commands.allow = ["set_image_crop"]
[[permission]]
identifier = "deny-set-image-crop"
description = "Denies the set_image_crop command without any pre-configured scope."
commands.deny = ["set_image_crop"]
@@ -8,6 +8,7 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
- `allow-get-gallery`
- `allow-select-images`
- `allow-delete-image`
- `allow-set-image-crop`
- `allow-set-setting`
- `allow-next-wallpaper`
@@ -153,6 +154,32 @@ Denies the select_images command without any pre-configured scope.
<tr>
<td>
`wallpaper:allow-set-image-crop`
</td>
<td>
Enables the set_image_crop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-set-image-crop`
</td>
<td>
Denies the set_image_crop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-set-setting`
</td>
+14 -2
View File
@@ -354,6 +354,18 @@
"const": "deny-select-images",
"markdownDescription": "Denies the select_images command without any pre-configured scope."
},
{
"description": "Enables the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "allow-set-image-crop",
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
},
{
"description": "Denies the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "deny-set-image-crop",
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
},
{
"description": "Enables the set_setting command without any pre-configured scope.",
"type": "string",
@@ -367,10 +379,10 @@
"markdownDescription": "Denies the set_setting command without any pre-configured scope."
},
{
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"type": "string",
"const": "default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="116" fill="#145d32"/>
<path d="M142 128h228a42 42 0 0 1 42 42v210a42 42 0 0 1-42 42H142a42 42 0 0 1-42-42V170a42 42 0 0 1 42-42Z" fill="#f8faf7" opacity=".22"/>
<path d="M172 92h190a38 38 0 0 1 38 38v214a38 38 0 0 1-38 38H172a38 38 0 0 1-38-38V130a38 38 0 0 1 38-38Z" fill="#f8faf7"/>
<circle cx="321" cy="170" r="31" fill="#a9cda7"/>
<path d="m157 319 72-91 53 62 37-42 65 79v17a17 17 0 0 1-17 17H168a17 17 0 0 1-17-17v-15Z" fill="#145d32"/>
<path d="M366 112a122 122 0 0 1 39 61" fill="none" stroke="#a9cda7" stroke-width="18" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">WallpaperFlow</string>
<string name="main_activity_title">WallpaperFlow</string>
</resources>
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">"LockScreenWallpaper"</string>
<string name="main_activity_title">"LockScreenWallpaper"</string>
<string name="app_name">"WallpaperFlow"</string>
<string name="main_activity_title">"WallpaperFlow"</string>
</resources>
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -2193,10 +2193,10 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"type": "string",
"const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
},
{
"description": "Enables the delete_image command without any pre-configured scope.",
@@ -2228,6 +2228,12 @@
"const": "wallpaper:allow-select-images",
"markdownDescription": "Enables the select_images command without any pre-configured scope."
},
{
"description": "Enables the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-set-image-crop",
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
},
{
"description": "Enables the set_setting command without any pre-configured scope.",
"type": "string",
@@ -2264,6 +2270,12 @@
"const": "wallpaper:deny-select-images",
"markdownDescription": "Denies the select_images command without any pre-configured scope."
},
{
"description": "Denies the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-set-image-crop",
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
},
{
"description": "Denies the set_setting command without any pre-configured scope.",
"type": "string",
+14 -2
View File
@@ -2193,10 +2193,10 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"type": "string",
"const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
},
{
"description": "Enables the delete_image command without any pre-configured scope.",
@@ -2228,6 +2228,12 @@
"const": "wallpaper:allow-select-images",
"markdownDescription": "Enables the select_images command without any pre-configured scope."
},
{
"description": "Enables the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-set-image-crop",
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
},
{
"description": "Enables the set_setting command without any pre-configured scope.",
"type": "string",
@@ -2264,6 +2270,12 @@
"const": "wallpaper:deny-select-images",
"markdownDescription": "Denies the select_images command without any pre-configured scope."
},
{
"description": "Denies the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-set-image-crop",
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
},
{
"description": "Denies the set_setting command without any pre-configured scope.",
"type": "string",
+14 -2
View File
@@ -2193,10 +2193,10 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"type": "string",
"const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
},
{
"description": "Enables the delete_image command without any pre-configured scope.",
@@ -2228,6 +2228,12 @@
"const": "wallpaper:allow-select-images",
"markdownDescription": "Enables the select_images command without any pre-configured scope."
},
{
"description": "Enables the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-set-image-crop",
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
},
{
"description": "Enables the set_setting command without any pre-configured scope.",
"type": "string",
@@ -2264,6 +2270,12 @@
"const": "wallpaper:deny-select-images",
"markdownDescription": "Denies the select_images command without any pre-configured scope."
},
{
"description": "Denies the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-set-image-crop",
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
},
{
"description": "Denies the set_setting command without any pre-configured scope.",
"type": "string",
+14 -2
View File
@@ -2193,10 +2193,10 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"description": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`",
"type": "string",
"const": "wallpaper:default",
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
"markdownDescription": "Allow the LockScreenWallpaper app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-get-gallery`\n- `allow-select-images`\n- `allow-delete-image`\n- `allow-set-image-crop`\n- `allow-set-setting`\n- `allow-next-wallpaper`"
},
{
"description": "Enables the delete_image command without any pre-configured scope.",
@@ -2228,6 +2228,12 @@
"const": "wallpaper:allow-select-images",
"markdownDescription": "Enables the select_images command without any pre-configured scope."
},
{
"description": "Enables the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-set-image-crop",
"markdownDescription": "Enables the set_image_crop command without any pre-configured scope."
},
{
"description": "Enables the set_setting command without any pre-configured scope.",
"type": "string",
@@ -2264,6 +2270,12 @@
"const": "wallpaper:deny-select-images",
"markdownDescription": "Denies the select_images command without any pre-configured scope."
},
{
"description": "Denies the set_image_crop command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-set-image-crop",
"markdownDescription": "Denies the set_image_crop command without any pre-configured scope."
},
{
"description": "Denies the set_setting command without any pre-configured scope.",
"type": "string",
+3 -3
View File
@@ -1,9 +1,9 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LockScreenWallpaper",
"productName": "WallpaperFlow",
"version": "0.1.0",
"identifier": "de.wechselbild.app",
"build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" },
"app": { "windows": [{ "title": "LockScreenWallpaper", "width": 420, "height": 860, "resizable": true }], "security": { "csp": null } },
"bundle": { "active": true, "targets": "all", "icon": [] }
"app": { "windows": [{ "title": "WallpaperFlow", "width": 420, "height": 860, "resizable": true }], "security": { "csp": null } },
"bundle": { "active": true, "targets": "all", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico", "icons/icon.png"] }
}
+130 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Check, ChevronRight, Home, Images, LockKeyhole, Plus, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
import { deleteImage, getGallery, getState, nextWallpaper, selectImages, setSetting, type GalleryImage, type WallpaperState } from "./native";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Check, ChevronRight, Crop, Home, Images, Languages, LockKeyhole, Plus, RotateCcw, Save, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
import { deleteImage, getGallery, getState, nextWallpaper, selectImages, setImageCrop, setSetting, type GalleryImage, type WallpaperState } from "./native";
import { initialLanguage, translations, type Language } from "./i18n";
const initial: WallpaperState = { imageCount: 0, enabled: false, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
@@ -14,15 +15,31 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l
export default function App() {
const [state, setState] = useState(initial);
const [tab, setTab] = useState<"home" | "settings" | "gallery">("home");
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor">("home");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [language, setLanguage] = useState<Language>(initialLanguage);
const [gallery, setGallery] = useState<GalleryImage[]>([]);
const [galleryTotal, setGalleryTotal] = useState(0);
const [galleryLoading, setGalleryLoading] = useState(false);
const [deletingId, setDeletingId] = useState("");
const [editing, setEditing] = useState<GalleryImage | null>(null);
const phonePreviewRef = useRef<HTMLDivElement>(null);
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number }>(null);
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(""), 3000);
return () => window.clearTimeout(timeout);
}, [notice]);
useEffect(() => {
window.localStorage.setItem("wallpaperflow-language", language);
document.documentElement.lang = language;
document.title = "WallpaperFlow";
}, [language]);
const t = translations[language];
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
@@ -32,13 +49,13 @@ export default function App() {
const page = await getGallery(offset, 48);
setGallery(previous => append ? [...previous, ...page.items] : page.items);
setGalleryTotal(page.total);
} catch { setNotice("Galerie konnte nicht geladen werden"); }
} catch { setNotice(t.galleryLoadFailed); }
finally { setGalleryLoading(false); }
}
async function update(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
setState(prev => ({ ...prev, [name]: value }));
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? "Automatischer Wechsel ist aktiv" : "Automatischer Wechsel pausiert") : "Einstellung gespeichert"); } catch { setNotice("Diese Funktion ist auf Android verfügbar"); }
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? t.automaticEnabled : t.automaticDisabled) : t.settingSaved); } catch { setNotice(t.androidOnly); }
}
async function choose() {
@@ -46,61 +63,145 @@ export default function App() {
try {
setState(await selectImages());
if (tab === "gallery") await loadGallery();
setNotice("Bilder wurden zur Sammlung hinzugefügt");
} catch (error) { setNotice(String(error).includes("cancel") ? "Auswahl abgebrochen" : "Bildauswahl ist auf Android verfügbar"); }
setNotice(t.imagesAdded);
} catch (error) { setNotice(String(error).includes("cancel") ? t.selectionCancelled : t.imageSelectionAndroid); }
finally { setBusy(false); }
}
async function remove(image: GalleryImage) {
if (!window.confirm("Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?")) return;
if (!window.confirm(t.deleteConfirm)) return;
setDeletingId(image.id);
try {
setState(await deleteImage(image.id));
setGallery(previous => previous.filter(item => item.id !== image.id));
setGalleryTotal(previous => Math.max(0, previous - 1));
setNotice("Bild wurde gelöscht");
} catch { setNotice("Bild konnte nicht gelöscht werden"); }
setNotice(t.imageDeleted);
} catch { setNotice(t.imageDeleteFailed); }
finally { setDeletingId(""); }
}
function openEditor(image: GalleryImage) {
setEditing({ ...image });
setTab("editor");
}
async function saveCrop() {
if (!editing) return;
setBusy(true);
try {
const saved = await setImageCrop(editing);
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
setEditing(saved);
setNotice(t.cropSaved);
setTab("gallery");
} catch { setNotice(t.cropSaveFailed); }
finally { setBusy(false); }
}
function gestureGeometry() {
const points = [...previewPointers.current.values()];
if (!points.length) return { centerX: 0, centerY: 0, distance: 0 };
if (points.length === 1) return { centerX: points[0].x, centerY: points[0].y, distance: 0 };
const [first, second] = points;
return {
centerX: (first.x + second.x) / 2,
centerY: (first.y + second.y) / 2,
distance: Math.hypot(second.x - first.x, second.y - first.y),
};
}
function beginPreviewGesture(event: React.PointerEvent) {
if (!editing) return;
event.preventDefault();
phonePreviewRef.current?.setPointerCapture(event.pointerId);
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
const geometry = gestureGeometry();
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
}
function movePreviewGesture(event: React.PointerEvent) {
if (!previewPointers.current.has(event.pointerId)) return;
event.preventDefault();
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
const gesture = previewGesture.current;
const preview = phonePreviewRef.current;
if (!gesture || !preview) return;
const bounds = preview.getBoundingClientRect();
const geometry = gestureGeometry();
const zoom = geometry.distance && gesture.distance
? Math.min(3, Math.max(0.35, gesture.zoom * geometry.distance / gesture.distance))
: gesture.zoom;
const x = gesture.x - (geometry.centerX - gesture.centerX) / Math.max(1, bounds.width * zoom);
const y = gesture.y - (geometry.centerY - gesture.centerY) / Math.max(1, bounds.height * zoom);
setEditing(previous => previous ? {
...previous,
cropZoom: zoom,
cropPositionX: Math.min(1, Math.max(0, x)),
cropPositionY: Math.min(1, Math.max(0, y)),
} : previous);
}
function endPreviewGesture(event: React.PointerEvent) {
previewPointers.current.delete(event.pointerId);
if (phonePreviewRef.current?.hasPointerCapture(event.pointerId)) phonePreviewRef.current.releasePointerCapture(event.pointerId);
if (!editing || !previewPointers.current.size) {
previewGesture.current = null;
return;
}
const geometry = gestureGeometry();
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
}
async function next() {
setBusy(true);
try { setState(await nextWallpaper()); setNotice("Sperrbildschirm wurde aktualisiert"); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
try { setState(await nextWallpaper()); setNotice(t.wallpaperUpdated); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
finally { setBusy(false); }
}
return <main className="app-shell">
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label="Zurück" onClick={() => setTab("home")}><ArrowLeft /></button><div><h1>Meine Sammlung</h1><p>{galleryTotal} {galleryTotal === 1 ? "Bild" : "Bilder"}</p></div><button className="icon-button" aria-label="Bilder hinzufügen" onClick={choose} disabled={busy}><Plus /></button></header> :
<header><div><h1>LockScreenWallpaper</h1><p>{state.enabled ? "Deine Motive wechseln automatisch" : "Automatischer Wechsel ist pausiert"}</p></div><button className="icon-button" aria-label="Einstellungen" onClick={() => setTab("settings")}><Settings /></button></header>}
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => setTab("home")}><ArrowLeft /></button><div><h1>{t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div><button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button></header> : tab === "editor" ?
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={() => setTab("gallery")}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
<div className="content">
{tab === "home" ? <>
<section className="hero" aria-label="Aktuelles Hintergrundbild">
<section className="hero" aria-label={t.currentWallpaper}>
<div className="photo-stack" />
<img src={current} alt="Aktuelles Motiv" />
<img src={current} alt={t.currentImage} />
<div className="hero-shade" />
<div className="hero-meta"><span><Sparkles size={16} /> Aktuelles Motiv</span><button onClick={next} disabled={busy}>Nächstes Motiv <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>
<SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} />
<SettingRow icon={<Smartphone />} label={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} />
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? "Bitte warten …" : "Bilder auswählen"}</button>
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
<section className="collection"><div className="section-heading"><h2>Meine Sammlung</h2><button onClick={() => { setTab("gallery"); void loadGallery(); }}>{state.imageCount} {state.imageCount === 1 ? "Bild" : "Bilder"} <ChevronRight /></button></div>
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`Motiv ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>Noch keine Bilder ausgewählt</span></button>}
<p className="hint">Tippe auf die Bildanzahl, um deine Galerie zu öffnen.</p>
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={() => { setTab("gallery"); void loadGallery(); }}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div>
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`${t.motif} ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>}
<p className="hint">{t.collectionHint}</p>
</section>
<section className="settings-list"><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
</> : tab === "settings" ? <section className="settings-page"><h2>Einstellungen</h2><p>Lege fest, wie LockScreenWallpaper im Hintergrund arbeitet.</p><div className="settings-list"><SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} /><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>Ohne Bilderlimit</h3><p>Deine Auswahl wird privat auf dem Gerät gespeichert. Die einzige Grenze ist der freie Speicherplatz.</p></div></section> :
<section className="gallery-page" aria-label="Meine Bilder">
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={image.selected ? "current" : ""} key={image.id}><img src={image.url} alt={`Bild ${index + 1}`} />{image.selected && <span className="current-badge"><Check /> Aktuell</span>}<button className="delete-button" aria-label={`Bild ${index + 1} löschen`} onClick={() => remove(image)} disabled={deletingId === image.id}><Trash2 /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>Deine Sammlung ist leer</h2><p>Füge Bilder hinzu, die automatisch als Sperrbildschirm wechseln sollen.</p><button className="primary" onClick={choose}><Plus /> Bilder hinzufügen</button></div>}
{galleryLoading && <p className="gallery-status">Galerie wird geladen </p>}
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>Weitere Bilder laden</button>}
<section className="settings-list"><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)} /></section>
</> : tab === "settings" ? <section className="settings-page"><h2>{t.settings}</h2><p>{t.settingsIntro}</p><div className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{language === "de" ? t.german : t.english}</span></div><div className="language-toggle" role="group" aria-label={t.language}><button className={language === "de" ? "active" : ""} onClick={() => setLanguage("de")}>DE</button><button className={language === "en" ? "active" : ""} onClick={() => setLanguage("en")}>EN</button></div></div><div className="settings-list"><SettingRow icon={<Smartphone />} label={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} /><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="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div></section> : tab === "gallery" ?
<section className="gallery-page" aria-label={t.myImages}>
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={image.selected ? "current" : ""} key={image.id}><img src={image.url} alt={`${t.image} ${index + 1}`} />{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button><button className="delete-button" aria-label={`${t.deleteImage} ${index + 1}`} onClick={() => remove(image)} disabled={deletingId === image.id}><Trash2 /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
{galleryLoading && <p className="gallery-status">{t.galleryLoading}</p>}
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}>
<div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
<img src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%`, transform: `scale(${editing.cropZoom})`, transformOrigin: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%` }} />
<div className="preview-clock">12:34<span>{t.previewDate}</span></div>
</div>
<div className="crop-controls">
<div className="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover" })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain" })}>{t.fit}</button></div>
<div className="direct-crop-heading"><span><Crop /> {t.adjustOnScreen}</span><strong>{editing.cropZoom.toFixed(2)}×</strong></div>
<p className="direct-crop-help">{t.gestureHelp}</p>
<button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}><RotateCcw /> {t.reset}</button>
</div>
</section>}
</div>
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
{tab !== "gallery" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>Start</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>Einstellungen</span></button></nav>}
{tab !== "gallery" && tab !== "editor" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
</main>;
}
+140
View File
@@ -0,0 +1,140 @@
export type Language = "de" | "en";
export const translations = {
de: {
automaticActive: "Deine Motive wechseln automatisch",
automaticPaused: "Automatischer Wechsel ist pausiert",
settings: "Einstellungen",
currentWallpaper: "Aktuelles Hintergrundbild",
currentImage: "Aktuelles Motiv",
nextImage: "Nächstes Motiv",
changeOnWake: "Bei jedem Aktivieren wechseln",
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",
german: "Deutsch",
english: "Englisch",
noImageLimit: "Ohne Bilderlimit",
privacyInfo: "Deine Auswahl wird privat auf dem Gerät gespeichert. Die einzige Grenze ist der freie Speicherplatz.",
home: "Start",
back: "Zurück",
addImages: "Bilder hinzufügen",
backToGallery: "Zurück zur Galerie",
editImage: "Bild anpassen",
savedForImage: "Wird nur für dieses Bild gespeichert",
saveCrop: "Bildausschnitt speichern",
myImages: "Meine Bilder",
current: "Aktuell",
adjustImage: "Bild anpassen",
deleteImage: "Bild löschen",
emptyCollection: "Deine Sammlung ist leer",
emptyCollectionText: "Füge Bilder hinzu, die automatisch als Sperrbildschirm wechseln sollen.",
galleryLoading: "Galerie wird geladen …",
loadMore: "Weitere Bilder laden",
editCrop: "Bildausschnitt bearbeiten",
gestureLabel: "Bild mit einem Finger verschieben und mit zwei Fingern zoomen",
cropPreview: "Vorschau des Bildausschnitts",
previewDate: "Donnerstag, 20. August",
imageFit: "Bildanpassung",
fill: "Ausfüllen",
fit: "Einpassen",
adjustOnScreen: "Direkt im Bildschirm anpassen",
gestureHelp: "Mit einem Finger verschieben · mit zwei Fingern zoomen",
reset: "Zurücksetzen",
deleteConfirm: "Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?",
galleryLoadFailed: "Galerie konnte nicht geladen werden",
automaticEnabled: "Automatischer Wechsel ist aktiv",
automaticDisabled: "Automatischer Wechsel pausiert",
settingSaved: "Einstellung gespeichert",
androidOnly: "Diese Funktion ist auf Android verfügbar",
imagesAdded: "Bilder wurden zur Sammlung hinzugefügt",
selectionCancelled: "Auswahl abgebrochen",
imageSelectionAndroid: "Bildauswahl ist auf Android verfügbar",
imageDeleted: "Bild wurde gelöscht",
imageDeleteFailed: "Bild konnte nicht gelöscht werden",
cropSaved: "Bildausschnitt wurde gespeichert",
cropSaveFailed: "Bildausschnitt konnte nicht gespeichert werden",
wallpaperUpdated: "Sperrbildschirm wurde aktualisiert",
},
en: {
automaticActive: "Your wallpapers change automatically",
automaticPaused: "Automatic rotation is paused",
settings: "Settings",
currentWallpaper: "Current wallpaper",
currentImage: "Current image",
nextImage: "Next image",
changeOnWake: "Change whenever the screen wakes",
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",
german: "German",
english: "English",
noImageLimit: "No image limit",
privacyInfo: "Your selection stays private on this device. The only limit is the available storage.",
home: "Home",
back: "Back",
addImages: "Add images",
backToGallery: "Back to gallery",
editImage: "Adjust image",
savedForImage: "Saved only for this image",
saveCrop: "Save image crop",
myImages: "My images",
current: "Current",
adjustImage: "Adjust image",
deleteImage: "Delete image",
emptyCollection: "Your collection is empty",
emptyCollectionText: "Add images that should rotate automatically on your lock screen.",
galleryLoading: "Loading gallery …",
loadMore: "Load more images",
editCrop: "Edit image crop",
gestureLabel: "Move the image with one finger and zoom with two fingers",
cropPreview: "Image crop preview",
previewDate: "Thursday, August 20",
imageFit: "Image fit",
fill: "Fill",
fit: "Fit",
adjustOnScreen: "Adjust directly on screen",
gestureHelp: "Move with one finger · zoom with two fingers",
reset: "Reset",
deleteConfirm: "Do you really want to delete this image from your collection?",
galleryLoadFailed: "The gallery could not be loaded",
automaticEnabled: "Automatic rotation is active",
automaticDisabled: "Automatic rotation is paused",
settingSaved: "Setting saved",
androidOnly: "This feature is available on Android",
imagesAdded: "Images added to your collection",
selectionCancelled: "Selection cancelled",
imageSelectionAndroid: "Image selection is available on Android",
imageDeleted: "Image deleted",
imageDeleteFailed: "The image could not be deleted",
cropSaved: "Image crop saved",
cropSaveFailed: "The image crop could not be saved",
wallpaperUpdated: "Lock screen updated",
},
} as const;
export type TranslationKey = keyof typeof translations.de;
export function initialLanguage(): Language {
const stored = window.localStorage.getItem("wallpaperflow-language");
if (stored === "de" || stored === "en") return stored;
return window.navigator.language.toLowerCase().startsWith("de") ? "de" : "en";
}
+28 -5
View File
@@ -13,6 +13,10 @@ export type GalleryImage = {
id: string;
url: string;
selected: boolean;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
};
export type GalleryPage = {
@@ -30,6 +34,8 @@ const demoState: WallpaperState = {
};
const inTauri = () => "__TAURI_INTERNALS__" in window;
const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "cropPositionX" | "cropPositionY">>();
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 };
export async function getState(): Promise<WallpaperState> {
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
@@ -39,11 +45,10 @@ export async function getGallery(offset = 0, limit = 48): Promise<GalleryPage> {
if (inTauri()) return invoke<GalleryPage>("plugin:wallpaper|get_gallery", { offset, limit });
return {
total: demoState.imageUrls.length,
items: demoState.imageUrls.slice(offset, offset + limit).map((url, index) => ({
id: `demo-${offset + index}`,
url,
selected: offset + index === demoState.currentIndex,
})),
items: demoState.imageUrls.slice(offset, offset + limit).map((url, index) => {
const id = `demo-${offset + index}`;
return { id, url, selected: offset + index === demoState.currentIndex, ...(demoCrops.get(id) ?? defaultCrop) };
}),
};
}
@@ -63,6 +68,24 @@ export async function deleteImage(id: string): Promise<WallpaperState> {
return { ...demoState, imageUrls: [...demoState.imageUrls] };
}
export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
const payload = {
id: image.id,
mode: image.cropMode,
zoom: image.cropZoom,
positionX: image.cropPositionX,
positionY: image.cropPositionY,
};
if (inTauri()) return invoke<GalleryImage>("plugin:wallpaper|set_image_crop", payload);
demoCrops.set(image.id, {
cropMode: image.cropMode,
cropZoom: image.cropZoom,
cropPositionX: image.cropPositionX,
cropPositionY: image.cropPositionY,
});
return { ...image };
}
export async function setSetting(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
if (!inTauri()) return { ...demoState, [name]: value };
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
+50
View File
@@ -9,9 +9,26 @@ header p { margin: 0; font-size: 13px; color: #657068; font-weight: 500; }
.icon-button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: #edf2eb; color: #31523b; display: grid; place-items: center; }
.icon-button svg { width: 21px; }
.icon-button:disabled { opacity: .55; }
.app-header { gap: 12px; overflow: hidden; }
.brand { min-width: 0; flex: 1; display: flex; align-items: center; gap: 11px; }
.brand-logo { width: 46px; height: 46px; flex: 0 0 auto; border-radius: 13px; box-shadow: 0 5px 15px rgba(20,93,50,.18); }
.brand-copy { min-width: 0; }
.brand-copy h1 { margin-bottom: 3px; overflow: hidden; font-size: clamp(24px, 7vw, 30px); letter-spacing: -1.2px; text-overflow: ellipsis; white-space: nowrap; }
.brand-copy p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.settings-shortcut { flex: 0 0 46px; width: 46px; height: 46px; color: white; background: var(--green); box-shadow: 0 7px 18px rgba(20,93,50,.24); }
.settings-shortcut.selected { color: var(--green); background: #dcebdd; box-shadow: inset 0 0 0 2px rgba(20,93,50,.12); }
.settings-shortcut:active { transform: scale(.94); }
.gallery-header { gap: 14px; }
.gallery-header > div { flex: 1; }
.gallery-header h1 { font-size: 25px; letter-spacing: -.9px; margin-bottom: 3px; }
@media (max-width: 360px) {
header { padding-left: 16px; padding-right: 16px; }
.brand { gap: 8px; }
.brand-logo { width: 40px; height: 40px; border-radius: 11px; }
.brand-copy h1 { font-size: 23px; }
.brand-copy p { font-size: 11px; }
.settings-shortcut { flex-basis: 42px; width: 42px; height: 42px; }
}
.content { padding: 0 20px; }
.hero { position: relative; height: 294px; margin: 0 0 15px; border-radius: 26px; isolation: isolate; }
.hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; z-index: 1; }
@@ -47,6 +64,13 @@ h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
.hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; }
.settings-list { border-top: 1px solid var(--line); }
.settings-page > p { color: #667069; margin: 8px 0 24px; font-size: 14px; }
.language-setting { min-height: 68px; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); }
.language-setting > div:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.language-setting strong { font-size: 15px; }
.language-setting span { color: #768078; font-size: 11px; }
.language-toggle { padding: 3px; display: flex; border-radius: 12px; background: #e8eee7; }
.language-toggle button { width: 39px; height: 31px; padding: 0; border: 0; border-radius: 9px; color: #657068; background: transparent; font-size: 12px; font-weight: 800; }
.language-toggle button.active { color: white; background: var(--green); box-shadow: 0 2px 7px rgba(20,93,50,.2); }
.settings-page .settings-list { margin-top: 8px; }
.info { margin-top: 28px; padding: 20px; background: #eaf2e8; border-radius: 20px; }
.info h3 { color: var(--green); margin: 0 0 6px; font-size: 16px; }
@@ -64,6 +88,8 @@ nav svg { width: 21px; }
.delete-button { position: absolute; top: 7px; right: 7px; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; color: white; background: rgba(94, 20, 20, .86); box-shadow: 0 3px 12px rgba(0,0,0,.24); }
.delete-button svg { width: 16px; }
.delete-button:disabled { opacity: .5; }
.edit-button { position: absolute; right: 7px; bottom: 7px; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; color: var(--green); background: rgba(255,255,255,.92); box-shadow: 0 3px 12px rgba(0,0,0,.2); }
.edit-button svg { width: 16px; }
.current-badge { position: absolute; left: 6px; bottom: 6px; display: flex; align-items: center; gap: 3px; padding: 5px 7px; border-radius: 99px; background: rgba(20, 93, 50, .9); color: white; font-size: 9px; font-weight: 750; }
.current-badge svg { width: 11px; height: 11px; }
.gallery-empty { min-height: 58vh; padding: 40px 18px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #68736b; }
@@ -73,6 +99,30 @@ nav svg { width: 21px; }
.gallery-empty .primary { max-width: 260px; }
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; }
.load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; }
.save-crop { background: var(--green); color: white; }
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }
.phone-preview { position: relative; width: min(72vw, 270px); aspect-ratio: 9 / 19.5; margin: 0 auto 22px; overflow: hidden; border: 7px solid #202621; border-radius: 34px; background: #090b09; box-shadow: 0 18px 38px rgba(20,35,24,.22); }
.phone-preview > img { width: 100%; height: 100%; display: block; transition: transform .16s ease, object-position .16s ease; }
.phone-preview.interactive { touch-action: none; user-select: none; cursor: grab; }
.phone-preview.interactive:active { cursor: grabbing; }
.phone-preview.interactive > img { pointer-events: none; user-select: none; transition: none; }
.preview-clock { position: absolute; z-index: 2; top: 55px; left: 0; right: 0; color: white; text-align: center; font-size: 42px; line-height: 1; font-weight: 300; text-shadow: 0 2px 10px rgba(0,0,0,.45); pointer-events: none; }
.preview-clock span { display: block; margin-top: 7px; font-size: 10px; font-weight: 600; }
.crop-controls { padding: 17px; border: 1px solid var(--line); border-radius: 20px; background: white; }
.fit-toggle { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; margin-bottom: 17px; border-radius: 13px; background: #edf2eb; }
.fit-toggle button { height: 38px; border: 0; border-radius: 10px; color: #68736b; background: transparent; font-size: 13px; font-weight: 750; }
.fit-toggle button.active { color: white; background: var(--green); box-shadow: 0 3px 9px rgba(20,93,50,.18); }
.crop-controls label { display: block; margin-top: 13px; color: #58635b; font-size: 12px; font-weight: 700; }
.crop-controls label span { display: flex; justify-content: space-between; }
.crop-controls label strong { color: var(--green); }
.crop-controls input[type="range"] { width: 100%; margin: 8px 0 0; accent-color: var(--green); }
.direct-crop-heading { display: flex; align-items: center; justify-content: space-between; color: #526158; font-size: 13px; font-weight: 750; }
.direct-crop-heading span { display: flex; align-items: center; gap: 7px; }
.direct-crop-heading svg { width: 17px; }
.direct-crop-heading strong { color: var(--green); }
.direct-crop-help { margin: 5px 0 11px; color: #7a837d; font-size: 10px; line-height: 1.4; }
.reset-crop { width: 100%; height: 42px; margin-top: 16px; border: 0; border-radius: 12px; color: #526158; background: #edf2eb; display: flex; justify-content: center; align-items: center; gap: 7px; font-size: 12px; font-weight: 750; }
.reset-crop svg { width: 16px; }
@media (min-width: 700px) { .app-shell { margin-top: 20px; min-height: calc(100vh - 40px); border-radius: 32px; overflow: hidden; } nav { bottom: 20px; border-radius: 0 0 32px 32px; } }
@media (max-height: 740px) { .hero { height: 235px; } }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }