Compare commits

...
2 Commits
Author SHA1 Message Date
Christoph 197090ddd2 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
2026-08-20 23:26:15 +02:00
Christoph 2cfc7c5dcb feat(wallpaper): extend commands with gallery, delete image, and crop
The plugin now exposes gallery retrieval, image deletion and image crop
across Android, desktop and mobile. The Rust core and Android
bridge are wired to handle these commands, updating state as needed.

- Add gallery, delete_image, and set_image_crop commands
- across Android, desktop and mobile.
- Update permissions, defaults, and schema to enable new commands.
2026-08-20 22:06:00 +02:00
32 changed files with 1220 additions and 112 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>
@@ -17,12 +17,34 @@ import java.util.concurrent.Executors
@InvokeArg
class SettingArgs { lateinit var name: String; var value: Boolean = false }
@InvokeArg
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()
@Command fun getState(invoke: Invoke) = io.execute { invoke.resolve(WallpaperStore.state(activity)) }
@Command fun getGallery(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(GalleryArgs::class.java)
invoke.resolve(WallpaperStore.gallery(activity, args.offset, args.limit))
} catch (error: Exception) { invoke.reject(error.message ?: "Galerie konnte nicht geladen werden") }
}
@Command fun selectImages(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
@@ -33,6 +55,23 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
startActivityForResult(invoke, intent, "selectedImages")
}
@Command fun deleteImage(invoke: Invoke) = io.execute {
try {
val args = invoke.parseArgs(DeleteImageArgs::class.java)
if (!WallpaperStore.delete(activity, args.id)) throw IllegalArgumentException("Bild wurde nicht gefunden")
invoke.resolve(WallpaperStore.state(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)
}
}
+137 -4
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)
@@ -44,6 +51,128 @@ object WallpaperStore {
}
}
fun gallery(context: Context, offset: Int, limit: Int): JSObject {
val originals = files(context)
val selectedIndex = prefs(context).getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(originals.size)
val safeLimit = limit.coerceIn(1, 100)
val items = JSArray()
originals.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, file ->
items.put(galleryImage(context, file, safeOffset + pageIndex == selectedIndex))
}
return JSObject().apply {
put("total", originals.size)
put("items", items)
}
}
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
val originals = files(context)
val position = originals.indexOfFirst { it.name == id }
if (position < 0 || !originals[position].delete()) return false
val preferences = prefs(context)
val previousIndex = preferences.getInt(KEY_INDEX, 0).coerceIn(0, (originals.size - 1).coerceAtLeast(0))
val remaining = originals.size - 1
val nextIndex = when {
remaining <= 0 -> 0
position < previousIndex -> previousIndex - 1
previousIndex >= remaining -> remaining - 1
else -> previousIndex
}
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)
@@ -66,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>
+13 -5
View File
@@ -1,8 +1,16 @@
const COMMANDS: &[&str] = &["get_state", "select_images", "set_setting", "next_wallpaper"];
const COMMANDS: &[&str] = &[
"get_state",
"get_gallery",
"select_images",
"delete_image",
"set_image_crop",
"set_setting",
"next_wallpaper",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete-image"
description = "Enables the delete_image command without any pre-configured scope."
commands.allow = ["delete_image"]
[[permission]]
identifier = "deny-delete-image"
description = "Denies the delete_image command without any pre-configured scope."
commands.deny = ["delete_image"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-gallery"
description = "Enables the get_gallery command without any pre-configured scope."
commands.allow = ["get_gallery"]
[[permission]]
identifier = "deny-get-gallery"
description = "Denies the get_gallery command without any pre-configured scope."
commands.deny = ["get_gallery"]
@@ -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"]
@@ -5,7 +5,10 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
#### This default permission set includes the following:
- `allow-get-state`
- `allow-get-gallery`
- `allow-select-images`
- `allow-delete-image`
- `allow-set-image-crop`
- `allow-set-setting`
- `allow-next-wallpaper`
@@ -18,6 +21,58 @@ Allow the LockScreenWallpaper app to manage its wallpaper collection
</tr>
<tr>
<td>
`wallpaper:allow-delete-image`
</td>
<td>
Enables the delete_image command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-delete-image`
</td>
<td>
Denies the delete_image command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:allow-get-gallery`
</td>
<td>
Enables the get_gallery command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`wallpaper:deny-get-gallery`
</td>
<td>
Denies the get_gallery command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
@@ -99,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>
+1 -1
View File
@@ -1,3 +1,3 @@
[default]
description = "Allow the LockScreenWallpaper app to manage its wallpaper collection"
permissions = ["allow-get-state", "allow-select-images", "allow-set-setting", "allow-next-wallpaper"]
permissions = ["allow-get-state", "allow-get-gallery", "allow-select-images", "allow-delete-image", "allow-set-image-crop", "allow-set-setting", "allow-next-wallpaper"]
+38 -2
View File
@@ -294,6 +294,30 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the delete_image command without any pre-configured scope.",
"type": "string",
"const": "allow-delete-image",
"markdownDescription": "Enables the delete_image command without any pre-configured scope."
},
{
"description": "Denies the delete_image command without any pre-configured scope.",
"type": "string",
"const": "deny-delete-image",
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
},
{
"description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "allow-get-gallery",
"markdownDescription": "Enables the get_gallery command without any pre-configured scope."
},
{
"description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "deny-get-gallery",
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
},
{
"description": "Enables the get_state command without any pre-configured scope.",
"type": "string",
@@ -330,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",
@@ -343,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-select-images`\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-select-images`\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`"
}
]
}
+54 -5
View File
@@ -1,10 +1,59 @@
use tauri::{AppHandle, command, Runtime};
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::Result;
use crate::WallpaperExt;
#[command] pub(crate) async fn get_state<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> { app.wallpaper().get_state() }
#[command] pub(crate) async fn select_images<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> { app.wallpaper().select_images() }
#[command] pub(crate) async fn set_setting<R: Runtime>(app: AppHandle<R>, name: String, value: bool) -> Result<WallpaperState> { app.wallpaper().set_setting(SettingRequest { name, value }) }
#[command] pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> { app.wallpaper().next_wallpaper() }
#[command]
pub(crate) async fn get_state<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
app.wallpaper().get_state()
}
#[command]
pub(crate) async fn get_gallery<R: Runtime>(
app: AppHandle<R>,
offset: usize,
limit: usize,
) -> Result<GalleryPage> {
app.wallpaper()
.get_gallery(GalleryRequest { offset, limit })
}
#[command]
pub(crate) async fn select_images<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
app.wallpaper().select_images()
}
#[command]
pub(crate) async fn delete_image<R: Runtime>(
app: AppHandle<R>,
id: String,
) -> Result<WallpaperState> {
app.wallpaper().delete_image(DeleteImageRequest { id })
}
#[command]
pub(crate) async fn set_image_crop<R: Runtime>(
app: AppHandle<R>,
id: String,
mode: String,
zoom: f64,
position_x: f64,
position_y: f64,
) -> Result<GalleryImage> {
app.wallpaper().set_image_crop(ImageCropRequest {
id,
mode,
zoom,
position_x,
position_y,
})
}
#[command]
pub(crate) async fn set_setting<R: Runtime>(
app: AppHandle<R>,
name: String,
value: bool,
) -> Result<WallpaperState> {
app.wallpaper().set_setting(SettingRequest { name, value })
}
#[command]
pub(crate) async fn next_wallpaper<R: Runtime>(app: AppHandle<R>) -> Result<WallpaperState> {
app.wallpaper().next_wallpaper()
}
+76 -8
View File
@@ -4,19 +4,87 @@ use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<Wallpaper<R>> {
Ok(Wallpaper(app.clone()))
Ok(Wallpaper(app.clone()))
}
/// Access to the wallpaper APIs.
pub struct Wallpaper<R: Runtime>(AppHandle<R>);
impl<R: Runtime> Wallpaper<R> {
fn demo() -> WallpaperState { WallpaperState { image_count: 3, enabled: true, shuffle: true, lock_screen_only: true, current_index: 0, image_urls: vec!["/wallpapers/alpine.png".into(), "/wallpapers/waterfall.png".into(), "/wallpapers/coast.png".into()] } }
pub fn get_state(&self) -> crate::Result<WallpaperState> { Ok(Self::demo()) }
pub fn select_images(&self) -> crate::Result<WallpaperState> { Ok(Self::demo()) }
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> { let mut state = Self::demo(); match payload.name.as_str() { "enabled" => state.enabled = payload.value, "shuffle" => state.shuffle = payload.value, "lockScreenOnly" => state.lock_screen_only = payload.value, _ => {} }; Ok(state) }
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> { let mut state = Self::demo(); state.current_index = 1; Ok(state) }
fn demo() -> WallpaperState {
WallpaperState {
image_count: 3,
enabled: true,
shuffle: true,
lock_screen_only: true,
current_index: 0,
image_urls: vec![
"/wallpapers/alpine.png".into(),
"/wallpapers/waterfall.png".into(),
"/wallpapers/coast.png".into(),
],
}
}
pub fn get_state(&self) -> crate::Result<WallpaperState> {
Ok(Self::demo())
}
pub fn get_gallery(&self, payload: GalleryRequest) -> crate::Result<GalleryPage> {
let urls = Self::demo().image_urls;
let items = urls
.into_iter()
.enumerate()
.skip(payload.offset)
.take(payload.limit)
.map(|(index, url)| GalleryImage {
id: format!("demo-{index}"),
url,
selected: index == 0,
crop_mode: "cover".into(),
crop_zoom: 1.0,
crop_position_x: 0.5,
crop_position_y: 0.5,
})
.collect();
Ok(GalleryPage { total: 3, items })
}
pub fn select_images(&self) -> crate::Result<WallpaperState> {
Ok(Self::demo())
}
pub fn delete_image(&self, _payload: DeleteImageRequest) -> crate::Result<WallpaperState> {
Ok(Self::demo())
}
pub fn set_image_crop(&self, payload: ImageCropRequest) -> crate::Result<GalleryImage> {
let url = Self::demo()
.image_urls
.into_iter()
.nth(payload.id.trim_start_matches("demo-").parse().unwrap_or(0))
.unwrap_or_default();
Ok(GalleryImage {
id: payload.id,
url,
selected: false,
crop_mode: payload.mode,
crop_zoom: payload.zoom,
crop_position_x: payload.position_x,
crop_position_y: payload.position_y,
})
}
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> {
let mut state = Self::demo();
match payload.name.as_str() {
"enabled" => state.enabled = payload.value,
"shuffle" => state.shuffle = payload.value,
"lockScreenOnly" => state.lock_screen_only = payload.value,
_ => {}
};
Ok(state)
}
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
let mut state = Self::demo();
state.current_index = 1;
Ok(state)
}
}
+11 -11
View File
@@ -4,18 +4,18 @@ pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
+25 -17
View File
@@ -1,6 +1,6 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
@@ -23,26 +23,34 @@ use mobile::Wallpaper;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the wallpaper APIs.
pub trait WallpaperExt<R: Runtime> {
fn wallpaper(&self) -> &Wallpaper<R>;
fn wallpaper(&self) -> &Wallpaper<R>;
}
impl<R: Runtime, T: Manager<R>> crate::WallpaperExt<R> for T {
fn wallpaper(&self) -> &Wallpaper<R> {
self.state::<Wallpaper<R>>().inner()
}
fn wallpaper(&self) -> &Wallpaper<R> {
self.state::<Wallpaper<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("wallpaper")
.invoke_handler(tauri::generate_handler![commands::get_state, commands::select_images, commands::set_setting, commands::next_wallpaper])
.setup(|app, api| {
#[cfg(mobile)]
let wallpaper = mobile::init(app, api)?;
#[cfg(desktop)]
let wallpaper = desktop::init(app, api)?;
app.manage(wallpaper);
Ok(())
})
.build()
Builder::new("wallpaper")
.invoke_handler(tauri::generate_handler![
commands::get_state,
commands::get_gallery,
commands::select_images,
commands::delete_image,
commands::set_image_crop,
commands::set_setting,
commands::next_wallpaper
])
.setup(|app, api| {
#[cfg(mobile)]
let wallpaper = mobile::init(app, api)?;
#[cfg(desktop)]
let wallpaper = desktop::init(app, api)?;
app.manage(wallpaper);
Ok(())
})
.build()
}
+42 -13
View File
@@ -1,7 +1,7 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
@@ -11,22 +11,51 @@ tauri::ios_plugin_binding!(init_plugin_wallpaper);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<Wallpaper<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("de.wechselbild.wallpaper", "WallpaperPlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_wallpaper)?;
Ok(Wallpaper(handle))
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("de.wechselbild.wallpaper", "WallpaperPlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_wallpaper)?;
Ok(Wallpaper(handle))
}
/// Access to the wallpaper APIs.
pub struct Wallpaper<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> Wallpaper<R> {
pub fn get_state(&self) -> crate::Result<WallpaperState> { self.0.run_mobile_plugin("getState", ()).map_err(Into::into) }
pub fn select_images(&self) -> crate::Result<WallpaperState> { self.0.run_mobile_plugin("selectImages", ()).map_err(Into::into) }
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> { self.0.run_mobile_plugin("setSetting", payload).map_err(Into::into) }
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> { self.0.run_mobile_plugin("nextWallpaper", ()).map_err(Into::into) }
pub fn get_state(&self) -> crate::Result<WallpaperState> {
self.0.run_mobile_plugin("getState", ()).map_err(Into::into)
}
pub fn get_gallery(&self, payload: GalleryRequest) -> crate::Result<GalleryPage> {
self.0
.run_mobile_plugin("getGallery", payload)
.map_err(Into::into)
}
pub fn select_images(&self) -> crate::Result<WallpaperState> {
self.0
.run_mobile_plugin("selectImages", ())
.map_err(Into::into)
}
pub fn delete_image(&self, payload: DeleteImageRequest) -> crate::Result<WallpaperState> {
self.0
.run_mobile_plugin("deleteImage", payload)
.map_err(Into::into)
}
pub fn set_image_crop(&self, payload: ImageCropRequest) -> crate::Result<GalleryImage> {
self.0
.run_mobile_plugin("setImageCrop", payload)
.map_err(Into::into)
}
pub fn set_setting(&self, payload: SettingRequest) -> crate::Result<WallpaperState> {
self.0
.run_mobile_plugin("setSetting", payload)
.map_err(Into::into)
}
pub fn next_wallpaper(&self) -> crate::Result<WallpaperState> {
self.0
.run_mobile_plugin("nextWallpaper", ())
.map_err(Into::into)
}
}
+52 -7
View File
@@ -3,14 +3,59 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WallpaperState {
pub image_count: usize,
pub enabled: bool,
pub shuffle: bool,
pub lock_screen_only: bool,
pub current_index: usize,
pub image_urls: Vec<String>,
pub image_count: usize,
pub enabled: bool,
pub shuffle: bool,
pub lock_screen_only: bool,
pub current_index: usize,
pub image_urls: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingRequest { pub name: String, pub value: bool }
pub struct SettingRequest {
pub name: String,
pub value: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GalleryRequest {
pub offset: usize,
pub limit: usize,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteImageRequest {
pub id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageCropRequest {
pub id: String,
pub mode: String,
pub zoom: f64,
pub position_x: f64,
pub position_y: f64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GalleryImage {
pub id: String,
pub url: String,
pub selected: bool,
pub crop_mode: String,
pub crop_zoom: f64,
pub crop_position_x: f64,
pub crop_position_y: f64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GalleryPage {
pub total: usize,
pub items: Vec<GalleryImage>,
}
+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
+38 -2
View File
@@ -2193,10 +2193,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the Wechselbild app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-select-images`\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 Wechselbild app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-select-images`\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.",
"type": "string",
"const": "wallpaper:allow-delete-image",
"markdownDescription": "Enables the delete_image command without any pre-configured scope."
},
{
"description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-gallery",
"markdownDescription": "Enables the get_gallery command without any pre-configured scope."
},
{
"description": "Enables the get_state command without any pre-configured scope.",
@@ -2216,12 +2228,30 @@
"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",
"const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope."
},
{
"description": "Denies the delete_image command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-delete-image",
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
},
{
"description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-gallery",
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
},
{
"description": "Denies the get_state command without any pre-configured scope.",
"type": "string",
@@ -2240,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",
+38 -2
View File
@@ -2193,10 +2193,22 @@
"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-select-images`\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-select-images`\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.",
"type": "string",
"const": "wallpaper:allow-delete-image",
"markdownDescription": "Enables the delete_image command without any pre-configured scope."
},
{
"description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-gallery",
"markdownDescription": "Enables the get_gallery command without any pre-configured scope."
},
{
"description": "Enables the get_state command without any pre-configured scope.",
@@ -2216,12 +2228,30 @@
"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",
"const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope."
},
{
"description": "Denies the delete_image command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-delete-image",
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
},
{
"description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-gallery",
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
},
{
"description": "Denies the get_state command without any pre-configured scope.",
"type": "string",
@@ -2240,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",
+38 -2
View File
@@ -2193,10 +2193,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the Wechselbild app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-select-images`\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 Wechselbild app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-select-images`\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.",
"type": "string",
"const": "wallpaper:allow-delete-image",
"markdownDescription": "Enables the delete_image command without any pre-configured scope."
},
{
"description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-gallery",
"markdownDescription": "Enables the get_gallery command without any pre-configured scope."
},
{
"description": "Enables the get_state command without any pre-configured scope.",
@@ -2216,12 +2228,30 @@
"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",
"const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope."
},
{
"description": "Denies the delete_image command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-delete-image",
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
},
{
"description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-gallery",
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
},
{
"description": "Denies the get_state command without any pre-configured scope.",
"type": "string",
@@ -2240,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",
+38 -2
View File
@@ -2193,10 +2193,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "Allow the Wechselbild app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-select-images`\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 Wechselbild app to manage its wallpaper collection\n#### This default permission set includes:\n\n- `allow-get-state`\n- `allow-select-images`\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.",
"type": "string",
"const": "wallpaper:allow-delete-image",
"markdownDescription": "Enables the delete_image command without any pre-configured scope."
},
{
"description": "Enables the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:allow-get-gallery",
"markdownDescription": "Enables the get_gallery command without any pre-configured scope."
},
{
"description": "Enables the get_state command without any pre-configured scope.",
@@ -2216,12 +2228,30 @@
"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",
"const": "wallpaper:allow-set-setting",
"markdownDescription": "Enables the set_setting command without any pre-configured scope."
},
{
"description": "Denies the delete_image command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-delete-image",
"markdownDescription": "Denies the delete_image command without any pre-configured scope."
},
{
"description": "Denies the get_gallery command without any pre-configured scope.",
"type": "string",
"const": "wallpaper:deny-get-gallery",
"markdownDescription": "Denies the get_gallery command without any pre-configured scope."
},
{
"description": "Denies the get_state command without any pre-configured scope.",
"type": "string",
@@ -2240,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"] }
}
+157 -20
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronRight, Home, Images, LockKeyhole, Settings, Shuffle, Smartphone, Sparkles } from "lucide-react";
import { getState, nextWallpaper, selectImages, setSetting, 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,57 +15,193 @@ 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">("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.length ? state.imageUrls : ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"], [state.imageUrls]);
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
async function loadGallery(offset = 0, append = false) {
setGalleryLoading(true);
try {
const page = await getGallery(offset, 48);
setGallery(previous => append ? [...previous, ...page.items] : page.items);
setGalleryTotal(page.total);
} 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() {
setBusy(true);
try { setState(await selectImages()); setNotice("Bilder wurden zur Sammlung hinzugefügt"); } catch (error) { setNotice(String(error).includes("cancel") ? "Auswahl abgebrochen" : "Bildauswahl ist auf Android verfügbar"); }
try {
setState(await selectImages());
if (tab === "gallery") await loadGallery();
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(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(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">
<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>{state.imageCount || photos.length} Bilder <ChevronRight /></button></div>
<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>
<p className="hint">Tippe auf ein Bild, um es als Vorschau zu sehen.</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>
</> : <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="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>}
<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";
}
+57
View File
@@ -9,6 +9,21 @@ export type WallpaperState = {
imageUrls: string[];
};
export type GalleryImage = {
id: string;
url: string;
selected: boolean;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
};
export type GalleryPage = {
total: number;
items: GalleryImage[];
};
const demoState: WallpaperState = {
imageCount: 3,
enabled: true,
@@ -19,16 +34,58 @@ 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;
}
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) => {
const id = `demo-${offset + index}`;
return { id, url, selected: offset + index === demoState.currentIndex, ...(demoCrops.get(id) ?? defaultCrop) };
}),
};
}
export async function selectImages(): Promise<WallpaperState> {
if (!inTauri()) return demoState;
return invoke<WallpaperState>("plugin:wallpaper|select_images");
}
export async function deleteImage(id: string): Promise<WallpaperState> {
if (inTauri()) return invoke<WallpaperState>("plugin:wallpaper|delete_image", { id });
const index = Number(id.replace("demo-", ""));
if (Number.isInteger(index) && index >= 0 && index < demoState.imageUrls.length) {
demoState.imageUrls.splice(index, 1);
demoState.imageCount = demoState.imageUrls.length;
demoState.currentIndex = Math.min(demoState.currentIndex, Math.max(0, demoState.imageCount - 1));
}
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 });
+73
View File
@@ -8,6 +8,27 @@ h1 { font-size: 34px; letter-spacing: -1.7px; line-height: 1.05; margin: 0 0 6px
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; }
@@ -38,9 +59,18 @@ h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
.photo-rail img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-rail span { position: absolute; left: 7px; top: 7px; width: 23px; height: 23px; background: var(--green); color: white; border-radius: 50%; display: grid; place-items: center; }
.photo-rail span svg { width: 14px; }
.empty-collection { width: 100%; min-height: 84px; border: 1px dashed #b8c6ba; border-radius: 15px; background: #f1f5ef; color: #667069; display: flex; align-items: center; justify-content: center; gap: 9px; font-size: 13px; font-weight: 650; }
.empty-collection svg { width: 20px; color: var(--green); }
.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; }
@@ -50,6 +80,49 @@ nav button { border: 0; color: #657068; background: transparent; border-radius:
nav button.active { color: var(--green); background: var(--sage); }
nav svg { width: 21px; }
.snackbar { position: fixed; z-index: 20; left: 50%; transform: translateX(-50%); bottom: 92px; max-width: calc(100% - 40px); background: #26312a; color: white; border: 0; border-radius: 12px; padding: 13px 18px; font-size: 12px; box-shadow: 0 8px 26px rgba(0,0,0,.22); }
.gallery-page { padding-bottom: max(24px, env(safe-area-inset-bottom)); }
.gallery-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
.gallery-grid article { position: relative; aspect-ratio: 3 / 4; overflow: hidden; border-radius: 14px; background: #e3e9e2; border: 2px solid transparent; }
.gallery-grid article.current { border-color: #73a17c; }
.gallery-grid article > img { width: 100%; height: 100%; object-fit: cover; display: block; }
.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; }
.gallery-empty > svg { width: 48px; height: 48px; padding: 11px; border-radius: 50%; color: var(--green); background: var(--sage); }
.gallery-empty h2 { margin-top: 16px; color: #26312a; }
.gallery-empty p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; }
.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; } }