feat(wallpaper-store): enhance previews, EXIF handling, and shuffle
Android Release / Build signed release APK (push) Successful in 34m17s

These changes add image previews and robust image handling across platforms.
Android, desktop, and TS UI now share imagePreviews data and a
persistent shuffle queue, improving ordering and previews.
EXIF orientation is applied when decoding images and rendering crops.
Manifest and rendering paths were adjusted for portrait mode.

- Introduce imagePreviews in models and UI to show per-image crop data.
- Implement EXIF orientation handling and enhanced cropping.
- Improve shuffle with persistent queue and seen-tracking.
This commit is contained in:
2026-08-26 17:12:18 +02:00
parent 455b68e562
commit 5ffa921967
7 changed files with 226 additions and 71 deletions
+134 -40
View File
@@ -8,6 +8,9 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF
import android.media.ExifInterface
import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.LruCache import android.util.LruCache
import app.tauri.plugin.JSArray import app.tauri.plugin.JSArray
@@ -15,6 +18,7 @@ import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
object WallpaperStore { object WallpaperStore {
@@ -23,11 +27,14 @@ object WallpaperStore {
private const val KEY_CURRENT_ID = "current_entry_id" private const val KEY_CURRENT_ID = "current_entry_id"
private const val KEY_INTERVAL = "interval_minutes" private const val KEY_INTERVAL = "interval_minutes"
private const val KEY_SHUFFLE_SEEN_IDS = "shuffle_seen_ids" private const val KEY_SHUFFLE_SEEN_IDS = "shuffle_seen_ids"
private const val KEY_SHUFFLE_QUEUE_IDS = "shuffle_queue_ids"
private const val CROP_PREFIX = "crop_" private const val CROP_PREFIX = "crop_"
private const val INVALID_PREFIX = "invalid_" private const val INVALID_PREFIX = "invalid_"
private const val RENDER_VERSION = 7
private const val THUMBNAIL_VERSION = 2
private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024 private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 1024 * 1024 private const val THUMBNAIL_CACHE_MAX_BYTES = 64L * 1024 * 1024
private const val HOME_PREVIEW_LIMIT = 12 private const val HOME_PREVIEW_LIMIT = 6
private val thumbnailCache = LruCache<String, String>(48) private val thumbnailCache = LruCache<String, String>(48)
private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5, val rotation: Int = 0) private data class CropSettings(val mode: String = "cover", val zoom: Double = 1.0, val x: Double = 0.5, val y: Double = 0.5, val rotation: Int = 0)
@@ -93,6 +100,38 @@ object WallpaperStore {
return legacy return legacy
} }
private fun addedAt(entry: Entry): Long = when (entry) {
is Entry.Local -> entry.file.name.substringBefore('-').toLongOrNull() ?: entry.file.lastModified()
is Entry.Immich -> entry.addedAt
}
private fun writeShuffleQueue(context: Context, ids: List<String>) {
prefs(context).edit().putString(KEY_SHUFFLE_QUEUE_IDS, JSONArray(ids).toString()).apply()
}
private fun shuffleOrder(context: Context, items: List<Entry>, currentId: String): Pair<MutableSet<String>, MutableList<String>> {
val availableIds = items.mapTo(linkedSetOf()) { it.id }
val preferences = prefs(context)
val seenIds = preferences.getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
.orEmpty().filterTo(linkedSetOf()) { it in availableIds }
seenIds.add(currentId)
if (seenIds.size >= availableIds.size && availableIds.size > 1) {
seenIds.clear()
seenIds.add(currentId)
}
val storedQueue = runCatching {
val json = JSONArray(preferences.getString(KEY_SHUFFLE_QUEUE_IDS, "[]"))
(0 until json.length()).map { json.getString(it) }
}.getOrDefault(emptyList())
val queue = storedQueue.filterTo(mutableListOf()) { it in availableIds && it !in seenIds }
val missing = availableIds.filter { it !in seenIds && it !in queue }.shuffled()
queue.addAll(missing)
preferences.edit().putStringSet(KEY_SHUFFLE_SEEN_IDS, seenIds).apply()
writeShuffleQueue(context, queue)
return seenIds to queue
}
fun intervalMinutes(context: Context): Int { fun intervalMinutes(context: Context): Int {
val preferences = prefs(context) val preferences = prefs(context)
return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0) return if (preferences.contains(KEY_INTERVAL)) preferences.getInt(KEY_INTERVAL, 0)
@@ -109,7 +148,7 @@ object WallpaperStore {
fun set(context: Context, name: String, value: Boolean) { fun set(context: Context, name: String, value: Boolean) {
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" } require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
val editor = prefs(context).edit().putBoolean(name, value) val editor = prefs(context).edit().putBoolean(name, value)
if (name == "shuffle") editor.remove(KEY_SHUFFLE_SEEN_IDS) if (name == "shuffle") editor.remove(KEY_SHUFFLE_SEEN_IDS).remove(KEY_SHUFFLE_QUEUE_IDS)
editor.apply() editor.apply()
if (name == "prefetchImmich") { if (name == "prefetchImmich") {
if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context) if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context)
@@ -126,8 +165,15 @@ object WallpaperStore {
fun state(context: Context, includePreviews: Boolean = true): JSObject { fun state(context: Context, includePreviews: Boolean = true): JSObject {
val items = entries(context) val items = entries(context)
val index = currentIndex(context, items) val index = currentIndex(context, items)
val previewItems = if (index < HOME_PREVIEW_LIMIT) items.take(HOME_PREVIEW_LIMIT) else listOf(items[index]) + items.take(HOME_PREVIEW_LIMIT - 1) val previewItems = if (items.isEmpty()) emptyList() else {
val previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0 val current = items[index]
if (shuffle(context) && items.size > 1) {
val queue = shuffleOrder(context, items, current.id).second
listOf(current) + queue.take(HOME_PREVIEW_LIMIT - 1).mapNotNull { id -> items.firstOrNull { it.id == id } }
} else {
(0 until minOf(HOME_PREVIEW_LIMIT, items.size)).map { items[(index + it).mod(items.size)] }
}
}
return JSObject().apply { return JSObject().apply {
put("imageCount", items.size) put("imageCount", items.size)
put("enabled", enabled(context)) put("enabled", enabled(context))
@@ -136,7 +182,7 @@ object WallpaperStore {
put("lockScreenOnly", lockOnly(context)) put("lockScreenOnly", lockOnly(context))
put("allowMobileData", allowMobileData(context)) put("allowMobileData", allowMobileData(context))
put("prefetchImmich", prefetchImmich(context)) put("prefetchImmich", prefetchImmich(context))
put("currentIndex", previewIndex) put("currentIndex", 0)
put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL) put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL)
val ids = JSArray() val ids = JSArray()
previewItems.forEach { ids.put(it.id) } previewItems.forEach { ids.put(it.id) }
@@ -144,20 +190,24 @@ object WallpaperStore {
val previews = JSArray() val previews = JSArray()
if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) } if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) }
put("imageUrls", previews) put("imageUrls", previews)
val imagePreviews = JSArray()
if (includePreviews) previewItems.forEach { imagePreviews.put(galleryImage(context, it, it.id == items.getOrNull(index)?.id)) }
put("imagePreviews", imagePreviews)
} }
} }
@Synchronized @Synchronized
fun gallery(context: Context, offset: Int, limit: Int): JSObject { fun gallery(context: Context, offset: Int, limit: Int): JSObject {
val items = entries(context) val items = entries(context)
val selectedIndex = currentIndex(context, items) val selectedId = items.getOrNull(currentIndex(context, items))?.id
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size) val galleryItems = items.sortedWith(compareByDescending<Entry> { addedAt(it) }.thenByDescending { it.id })
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(galleryItems.size)
val safeLimit = limit.coerceIn(1, 100) val safeLimit = limit.coerceIn(1, 100)
val page = JSArray() val page = JSArray()
items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry -> galleryItems.drop(safeOffset).take(safeLimit).forEach { entry ->
page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex)) page.put(galleryImage(context, entry, entry.id == selectedId))
} }
return JSObject().apply { put("total", items.size); put("items", page) } return JSObject().apply { put("total", galleryItems.size); put("items", page) }
} }
@Synchronized @Synchronized
@@ -215,8 +265,16 @@ object WallpaperStore {
val json = JSONObject().apply { val json = JSONObject().apply {
put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation) put("mode", normalized.mode); put("zoom", normalized.zoom); put("x", normalized.x); put("y", normalized.y); put("rotation", normalized.rotation)
} }
prefs(context).edit().putString(cropKey(id), json.toString()).apply() val selected = items.indexOf(entry) == currentIndex(context, items)
return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items)) check(prefs(context).edit().putString(cropKey(id), json.toString()).commit()) {
"Bildausschnitt konnte nicht gespeichert werden"
}
if (selected) {
check(apply(context, id)) {
"Bildausschnitt wurde gespeichert, konnte aber nicht angewendet werden"
}
}
return galleryImage(context, entry, selected)
} }
@Synchronized @Synchronized
@@ -288,27 +346,42 @@ object WallpaperStore {
val metrics = context.resources.displayMetrics val metrics = context.resources.displayMetrics
val targetWidth = metrics.widthPixels.coerceAtLeast(1) val targetWidth = metrics.widthPixels.coerceAtLeast(1)
val targetHeight = metrics.heightPixels.coerceAtLeast(1) val targetHeight = metrics.heightPixels.coerceAtLeast(1)
val quarterTurn = crop.rotation == 90 || crop.rotation == 270 val rotated = if (crop.rotation == 0) source else Bitmap.createBitmap(
val rotatedWidth = if (quarterTurn) source.height else source.width source,
val rotatedHeight = if (quarterTurn) source.width else source.height 0,
0,
source.width,
source.height,
Matrix().apply { setRotate(crop.rotation.toFloat()) },
true,
)
val rotatedWidth = rotated.width
val rotatedHeight = rotated.height
val widthScale = targetWidth.toDouble() / rotatedWidth val widthScale = targetWidth.toDouble() / rotatedWidth
val heightScale = targetHeight.toDouble() / rotatedHeight val heightScale = targetHeight.toDouble() / rotatedHeight
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale) val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
val scale = (baseScale * crop.zoom).toFloat() val scale = (baseScale * crop.zoom).toFloat()
val baseWidth = rotatedWidth * baseScale
val baseHeight = rotatedHeight * baseScale
val scaledWidth = rotatedWidth * scale val scaledWidth = rotatedWidth * scale
val scaledHeight = rotatedHeight * scale val scaledHeight = rotatedHeight * scale
val left = ((targetWidth - scaledWidth) * crop.x).toFloat() val baseLeft = (targetWidth - baseWidth) * crop.x
val top = ((targetHeight - scaledHeight) * crop.y).toFloat() val baseTop = (targetHeight - baseHeight) * crop.y
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output -> val left = (targetWidth / 2.0 + (baseLeft - targetWidth / 2.0) * crop.zoom).toFloat()
val top = (targetHeight / 2.0 + (baseTop - targetHeight / 2.0) * crop.zoom).toFloat()
return try {
Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
val canvas = Canvas(output) val canvas = Canvas(output)
canvas.drawColor(Color.BLACK) canvas.drawColor(Color.BLACK)
val matrix = Matrix().apply { canvas.drawBitmap(
postTranslate(-source.width / 2f, -source.height / 2f) rotated,
postRotate(crop.rotation.toFloat()) null,
postScale(scale, scale) RectF(left, top, left + scaledWidth, top + scaledHeight),
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f) Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG),
)
} }
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)) } finally {
if (rotated !== source) rotated.recycle()
} }
} }
@@ -319,11 +392,34 @@ object WallpaperStore {
val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1) val targetLongSide = maxOf(context.resources.displayMetrics.widthPixels, context.resources.displayMetrics.heightPixels).coerceAtLeast(1)
var sample = 1 var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= targetLongSide) sample *= 2 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 }) val decoded = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 }) ?: return null
return applyExifOrientation(file, decoded)
}
private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return bitmap
val orientation = runCatching {
ExifInterface(file.absolutePath).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix().apply {
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> setScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> { setRotate(90f); postScale(-1f, 1f) }
ExifInterface.ORIENTATION_ROTATE_90 -> setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> { setRotate(-90f); postScale(-1f, 1f) }
ExifInterface.ORIENTATION_ROTATE_270 -> setRotate(-90f)
}
}
if (matrix.isIdentity) return bitmap
val oriented = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (oriented !== bitmap) bitmap.recycle()
return oriented
} }
private fun thumbnailDataUrl(context: Context, file: File): String { private fun thumbnailDataUrl(context: Context, file: File): String {
val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}" val cacheKey = "$THUMBNAIL_VERSION:${file.absolutePath}:${file.lastModified()}:${file.length()}"
thumbnailCache.get(cacheKey)?.let { return it } thumbnailCache.get(cacheKey)?.let { return it }
val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg") val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg")
if (cached.isFile) { if (cached.isFile) {
@@ -336,7 +432,8 @@ object WallpaperStore {
BitmapFactory.decodeFile(file.absolutePath, bounds) BitmapFactory.decodeFile(file.absolutePath, bounds)
var sample = 1 var sample = 1
while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2 while (bounds.outWidth / sample > 360 || bounds.outHeight / sample > 480) sample *= 2
val bitmap = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return "" val decoded = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return ""
val bitmap = applyExifOrientation(file, decoded)
val result = ByteArrayOutputStream().use { out -> val result = ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle() bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle()
val bytes = out.toByteArray() val bytes = out.toByteArray()
@@ -348,7 +445,7 @@ object WallpaperStore {
} }
private fun removeThumbnail(file: File) { private fun removeThumbnail(file: File) {
thumbnailCache.remove("${file.absolutePath}:${file.lastModified()}:${file.length()}") thumbnailCache.remove("$THUMBNAIL_VERSION:${file.absolutePath}:${file.lastModified()}:${file.length()}")
} }
private fun digest(value: String): String = MessageDigest.getInstance("SHA-256") private fun digest(value: String): String = MessageDigest.getInstance("SHA-256")
@@ -360,7 +457,7 @@ object WallpaperStore {
is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}" is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}"
is Entry.Immich -> entry.assetId is Entry.Immich -> entry.assetId
} }
val key = "${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}" val key = "$RENDER_VERSION:${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}"
return File(renderedDirectory(context), "${digest(key)}.jpg") return File(renderedDirectory(context), "${digest(key)}.jpg")
} }
@@ -394,18 +491,15 @@ object WallpaperStore {
if (items.isEmpty()) return false if (items.isEmpty()) return false
val previous = currentIndex(context, items) val previous = currentIndex(context, items)
if (shuffle(context) && items.size > 1) { if (shuffle(context) && items.size > 1) {
val availableIds = items.mapTo(mutableSetOf()) { it.id } val (seenIds, queue) = shuffleOrder(context, items, items[previous].id)
val seenIds = prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet()) val candidates = queue.mapNotNull { id -> items.indexOfFirst { it.id == id }.takeIf { it >= 0 } }
.orEmpty().filterTo(mutableSetOf()) { it in availableIds } val applied = applyCandidates(context, items, candidates, automatic, seenIds)
seenIds.add(items[previous].id) if (applied) {
val selectedId = prefs(context).getString(KEY_CURRENT_ID, null)
var candidates = items.indices.filter { items[it].id !in seenIds }.shuffled() val selectedPosition = queue.indexOf(selectedId)
if (candidates.isEmpty()) { writeShuffleQueue(context, if (selectedPosition >= 0) queue.drop(selectedPosition + 1) else queue.filter { it != selectedId })
seenIds.clear()
seenIds.add(items[previous].id)
candidates = items.indices.filter { it != previous }.shuffled()
} }
return applyCandidates(context, items, candidates, automatic, seenIds) return applied
} }
val candidates = (1..items.size).map { (previous + it).mod(items.size) } val candidates = (1..items.size).map { (previous + it).mod(items.size) }
return applyCandidates(context, items, candidates, automatic) return applyCandidates(context, items, candidates, automatic)
+15
View File
@@ -15,6 +15,20 @@ pub struct Wallpaper<R: Runtime>(AppHandle<R>);
impl<R: Runtime> Wallpaper<R> { impl<R: Runtime> Wallpaper<R> {
fn demo() -> WallpaperState { fn demo() -> WallpaperState {
let image_previews = ["alpine", "waterfall", "coast"]
.into_iter()
.enumerate()
.map(|(index, name)| GalleryImage {
id: format!("demo-{index}"),
url: format!("/wallpapers/{name}.png"),
selected: index == 0,
crop_mode: "cover".into(),
crop_zoom: 1.0,
crop_position_x: 0.5,
crop_position_y: 0.5,
crop_rotation: 0,
})
.collect();
WallpaperState { WallpaperState {
image_count: 3, image_count: 3,
enabled: true, enabled: true,
@@ -31,6 +45,7 @@ impl<R: Runtime> Wallpaper<R> {
"/wallpapers/waterfall.png".into(), "/wallpapers/waterfall.png".into(),
"/wallpapers/coast.png".into(), "/wallpapers/coast.png".into(),
], ],
image_previews,
} }
} }
pub fn get_state(&self) -> crate::Result<WallpaperState> { pub fn get_state(&self) -> crate::Result<WallpaperState> {
+2
View File
@@ -14,6 +14,8 @@ pub struct WallpaperState {
pub current_id: Option<String>, pub current_id: Option<String>,
pub image_ids: Vec<String>, pub image_ids: Vec<String>,
pub image_urls: Vec<String>, pub image_urls: Vec<String>,
#[serde(default)]
pub image_previews: Vec<GalleryImage>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -15,6 +15,7 @@
android:launchMode="singleTask" android:launchMode="singleTask"
android:label="@string/main_activity_title" android:label="@string/main_activity_title"
android:name=".MainActivity" android:name=".MainActivity"
android:screenOrientation="portrait"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
+27 -13
View File
@@ -1,10 +1,10 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Download, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2, Wifi } from "lucide-react"; import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Download, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2, Wifi } from "lucide-react";
import { applyWallpaper, connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getImmichImportProgress, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImmichAlbum, type ImmichAsset, type ImmichConnection, type ImmichImportProgress, type WallpaperState } from "./native"; import { applyWallpaper, connectImmich, deleteImages, disconnectImmich, getGallery, getImageIds, getImmichAlbums, getImmichAssets, getImmichConnection, getImmichImportProgress, getState, importImmichAssets, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type ImagePreview, type ImmichAlbum, type ImmichAsset, type ImmichConnection, type ImmichImportProgress, type WallpaperState } from "./native";
import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local"; import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
import { immichTranslations } from "./immich-i18n"; import { immichTranslations } from "./immich-i18n";
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] }; const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [], imagePreviews: [] };
type Tab = "home" | "settings" | "gallery" | "editor" | "immich"; type Tab = "home" | "settings" | "gallery" | "editor" | "immich";
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) { function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
@@ -113,7 +113,15 @@ export default function App() {
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]); const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
const currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? ""); const currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? "");
const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png"; const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png";
const photos = useMemo(() => state.imageUrls, [state.imageUrls]); const photos = useMemo<ImagePreview[]>(() => state.imagePreviews?.length ? state.imagePreviews : state.imageUrls.map((url, index) => ({
id: state.imageIds[index] ?? `preview-${index}`,
url,
cropMode: "cover",
cropZoom: 1,
cropPositionX: 0.5,
cropPositionY: 0.5,
cropRotation: 0,
})), [state.imageIds, state.imagePreviews, state.imageUrls]);
async function loadGallery(offset = 0, append = false) { async function loadGallery(offset = 0, append = false) {
if (galleryLoadingRef.current) return; if (galleryLoadingRef.current) return;
@@ -306,6 +314,7 @@ export default function App() {
try { try {
const saved = await setImageCrop(editing); const saved = await setImageCrop(editing);
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image)); setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
setState(previous => ({ ...previous, imagePreviews: (previous.imagePreviews ?? []).map(image => image.id === saved.id ? saved : image) }));
setEditing(saved); setEditing(saved);
setNotice(t.cropSaved); setNotice(t.cropSaved);
returnToGallery(); returnToGallery();
@@ -361,7 +370,7 @@ export default function App() {
} : previous); } : previous);
} }
function previewPosition(image: GalleryImage) { function previewPosition(image: ImagePreview) {
const { cropPositionX: x, cropPositionY: y } = image; const { cropPositionX: x, cropPositionY: y } = image;
if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`; if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 100}%`;
if (image.cropRotation === 180) return `${(1 - x) * 100}% ${(1 - y) * 100}%`; if (image.cropRotation === 180) return `${(1 - x) * 100}% ${(1 - y) * 100}%`;
@@ -387,16 +396,20 @@ export default function App() {
const zoom = geometry.distance && gesture.distance const zoom = geometry.distance && gesture.distance
? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance)) ? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance))
: gesture.zoom; : gesture.zoom;
const previousLeft = (gesture.width - gesture.baseWidth * gesture.zoom) * gesture.x; const previousBaseLeft = (gesture.width - gesture.baseWidth) * gesture.x;
const previousTop = (gesture.height - gesture.baseHeight * gesture.zoom) * gesture.y; const previousBaseTop = (gesture.height - gesture.baseHeight) * gesture.y;
const previousLeft = gesture.width / 2 + (previousBaseLeft - gesture.width / 2) * gesture.zoom;
const previousTop = gesture.height / 2 + (previousBaseTop - gesture.height / 2) * gesture.zoom;
const imageX = (gesture.centerX - gesture.left - previousLeft) / gesture.zoom; const imageX = (gesture.centerX - gesture.left - previousLeft) / gesture.zoom;
const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom; const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom;
const nextLeft = geometry.centerX - gesture.left - imageX * zoom; const nextLeft = geometry.centerX - gesture.left - imageX * zoom;
const nextTop = geometry.centerY - gesture.top - imageY * zoom; const nextTop = geometry.centerY - gesture.top - imageY * zoom;
const horizontalTravel = gesture.width - gesture.baseWidth * zoom; const horizontalTravel = gesture.width - gesture.baseWidth;
const verticalTravel = gesture.height - gesture.baseHeight * zoom; const verticalTravel = gesture.height - gesture.baseHeight;
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextLeft / horizontalTravel; const nextBaseLeft = gesture.width / 2 + (nextLeft - gesture.width / 2) / zoom;
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextTop / verticalTravel; const nextBaseTop = gesture.height / 2 + (nextTop - gesture.height / 2) / zoom;
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextBaseLeft / horizontalTravel;
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextBaseTop / verticalTravel;
setEditing(previous => previous ? { setEditing(previous => previous ? {
...previous, ...previous,
cropZoom: zoom, cropZoom: zoom,
@@ -450,7 +463,7 @@ export default function App() {
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button> <button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={openGallery}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div> <section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={openGallery}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div>
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={state.imageIds[index] ?? `${photo}-${index}`} className={state.imageIds[index] === state.currentId ? "selected" : ""} disabled={busy} onClick={() => void selectWallpaper(state.imageIds[index])}><img src={photo} alt={`${t.motif} ${index + 1}`} />{state.imageIds[index] === state.currentId && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>} {photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={photo.id} className={photo.id === state.currentId ? "selected" : ""} disabled={busy} onClick={() => void selectWallpaper(photo.id)}><span className={`gallery-preview-frame ${photo.cropRotation === 90 || photo.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${photo.cropRotation}deg) scale(${photo.cropZoom})` }}><img src={photo.url} alt={`${t.motif} ${index + 1}`} style={{ objectFit: photo.cropMode, objectPosition: previewPosition(photo) }} /></span>{photo.id === state.currentId && <span className="photo-current"><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>}
<p className="hint">{t.collectionHint}</p> <p className="hint">{t.collectionHint}</p>
</section> </section>
@@ -487,11 +500,12 @@ export default function App() {
{!immichLoading && immichHasMore && <button className="load-more" onClick={() => loadImmichPhotos(immichAlbumId, immichPage + 1, true)}>{t.loadMore}</button>} {!immichLoading && immichHasMore && <button className="load-more" onClick={() => loadImmichPhotos(immichAlbumId, immichPage + 1, true)}>{t.loadMore}</button>}
{!!immichSelected.size && (busy && immichImportProgress ? <div className="immich-import-progress" role="status" aria-live="polite"><div><span>{it.importing}</span><strong>{immichImportProgress.completed} / {immichImportProgress.total}</strong></div><progress max={immichImportProgress.bytesTotal || immichImportProgress.total || 1} value={immichImportProgress.bytesTotal ? immichImportProgress.bytesDownloaded : immichImportProgress.completed} />{immichImportProgress.bytesTotal > 0 && <div><span>{Math.round(immichImportProgress.bytesDownloaded / immichImportProgress.bytesTotal * 100)}%</span><span>{(immichImportProgress.bytesDownloaded / 1024 / 1024).toFixed(1)} / {(immichImportProgress.bytesTotal / 1024 / 1024).toFixed(1)} MB</span></div>}</div> : <button className="primary immich-import" onClick={importFromImmich} disabled={busy}><CloudDownload /> {`${it.importSelected} (${immichSelected.size})`}</button>)} {!!immichSelected.size && (busy && immichImportProgress ? <div className="immich-import-progress" role="status" aria-live="polite"><div><span>{it.importing}</span><strong>{immichImportProgress.completed} / {immichImportProgress.total}</strong></div><progress max={immichImportProgress.bytesTotal || immichImportProgress.total || 1} value={immichImportProgress.bytesTotal ? immichImportProgress.bytesDownloaded : immichImportProgress.completed} />{immichImportProgress.bytesTotal > 0 && <div><span>{Math.round(immichImportProgress.bytesDownloaded / immichImportProgress.bytesTotal * 100)}%</span><span>{(immichImportProgress.bytesDownloaded / 1024 / 1024).toFixed(1)} / {(immichImportProgress.bytesTotal / 1024 / 1024).toFixed(1)} MB</span></div>}</div> : <button className="primary immich-import" onClick={importFromImmich} disabled={busy}><CloudDownload /> {`${it.importSelected} (${immichSelected.size})`}</button>)}
</section> : tab === "gallery" ? </section> : tab === "gallery" ?
<section className="gallery-page" aria-label={t.myImages}> <section className={`gallery-page ${selectedIds.size ? "has-floating-action" : ""}`} aria-label={t.myImages}>
{!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>} {!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>}
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} loading="lazy" decoding="async" /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></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>} {gallery.length ? <div className="gallery-grid wallpaper-gallery">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><span className={`gallery-preview-frame ${image.cropRotation === 90 || image.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${image.cropRotation}deg) scale(${image.cropZoom})` }}><img src={image.url} alt={`${t.image} ${index + 1}`} loading="lazy" decoding="async" style={{ objectFit: image.cropMode, objectPosition: previewPosition(image) }} /></span></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></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 && <p className="gallery-status">{t.galleryLoading}</p>}
{gallery.length < galleryTotal && <div ref={galleryLoadMoreRef} className="gallery-load-sentinel" aria-hidden="true" />} {gallery.length < galleryTotal && <div ref={galleryLoadMoreRef} className="gallery-load-sentinel" aria-hidden="true" />}
{!!selectedIds.size && <button className="floating-delete-selection" aria-label={`${t.deleteSelected} (${selectedIds.size})`} onClick={removeSelected} disabled={busy}><Trash2 /><span>{t.deleteSelected}</span><strong>{selectedIds.size}</strong></button>}
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}> </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}> <div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
<div className={`preview-image-frame ${editing.cropRotation === 90 || editing.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${editing.cropRotation}deg) scale(${editing.cropZoom})` }}><img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: previewPosition(editing) }} /></div> <div className={`preview-image-frame ${editing.cropRotation === 90 || editing.cropRotation === 270 ? "quarter-turn" : ""}`} style={{ transform: `rotate(${editing.cropRotation}deg) scale(${editing.cropZoom})` }}><img ref={phoneImageRef} src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: previewPosition(editing) }} /></div>
+31 -11
View File
@@ -1,5 +1,15 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
export type ImagePreview = {
id: string;
url: string;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
cropRotation: number;
};
export type WallpaperState = { export type WallpaperState = {
imageCount: number; imageCount: number;
enabled: boolean; enabled: boolean;
@@ -12,17 +22,11 @@ export type WallpaperState = {
currentId: string | null; currentId: string | null;
imageIds: string[]; imageIds: string[];
imageUrls: string[]; imageUrls: string[];
imagePreviews: ImagePreview[];
}; };
export type GalleryImage = { export type GalleryImage = ImagePreview & {
id: string;
url: string;
selected: boolean; selected: boolean;
cropMode: "cover" | "contain";
cropZoom: number;
cropPositionX: number;
cropPositionY: number;
cropRotation: number;
}; };
export type GalleryPage = { export type GalleryPage = {
@@ -65,7 +69,7 @@ export type ImmichImportProgress = {
}; };
const demoState: WallpaperState = { const demoState: WallpaperState = {
imageCount: 3, imageCount: 6,
enabled: true, enabled: true,
intervalMinutes: 30, intervalMinutes: 30,
shuffle: true, shuffle: true,
@@ -74,8 +78,16 @@ const demoState: WallpaperState = {
prefetchImmich: true, prefetchImmich: true,
currentIndex: 0, currentIndex: 0,
currentId: "demo-0", currentId: "demo-0",
imageIds: ["demo-0", "demo-1", "demo-2"], imageIds: ["demo-0", "demo-1", "demo-2", "demo-3", "demo-4", "demo-5"],
imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"], imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png", "/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"],
imagePreviews: [
{ id: "demo-0", url: "/wallpapers/alpine.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-1", url: "/wallpapers/waterfall.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-2", url: "/wallpapers/coast.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-3", url: "/wallpapers/alpine.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-4", url: "/wallpapers/waterfall.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
{ id: "demo-5", url: "/wallpapers/coast.png", cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 },
],
}; };
const inTauri = () => "__TAURI_INTERNALS__" in window; const inTauri = () => "__TAURI_INTERNALS__" in window;
@@ -83,6 +95,11 @@ const demoCrops = new Map<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 }; const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 };
let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" }; let demoImmichConnection: ImmichConnection = { configured: false, serverUrl: "", userName: "" };
function orderDemoPreviewsFromCurrent() {
const index = demoState.imagePreviews.findIndex(image => image.id === demoState.currentId);
if (index > 0) demoState.imagePreviews = [...demoState.imagePreviews.slice(index), ...demoState.imagePreviews.slice(0, index)];
}
export async function getState(): Promise<WallpaperState> { export async function getState(): Promise<WallpaperState> {
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState; return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
} }
@@ -149,6 +166,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
cropPositionY: image.cropPositionY, cropPositionY: image.cropPositionY,
cropRotation: image.cropRotation, cropRotation: image.cropRotation,
}); });
demoState.imagePreviews = demoState.imagePreviews.map(preview => preview.id === image.id ? { ...preview, ...image } : preview);
return { ...image }; return { ...image };
} }
@@ -170,6 +188,7 @@ export async function nextWallpaper(): Promise<WallpaperState> {
if (!inTauri()) { if (!inTauri()) {
if (demoState.imageUrls.length) demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length; if (demoState.imageUrls.length) demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length;
demoState.currentId = demoState.imageIds[demoState.currentIndex] ?? null; demoState.currentId = demoState.imageIds[demoState.currentIndex] ?? null;
orderDemoPreviewsFromCurrent();
return { ...demoState }; return { ...demoState };
} }
return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper"); return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper");
@@ -181,6 +200,7 @@ export async function applyWallpaper(id: string): Promise<WallpaperState> {
if (index < 0) throw new Error("Image not found"); if (index < 0) throw new Error("Image not found");
demoState.currentIndex = index; demoState.currentIndex = index;
demoState.currentId = id; demoState.currentId = id;
orderDemoPreviewsFromCurrent();
return { ...demoState }; return { ...demoState };
} }
return invoke<WallpaperState>("plugin:wallpaper|apply_wallpaper", { id }); return invoke<WallpaperState>("plugin:wallpaper|apply_wallpaper", { id });
+14 -5
View File
@@ -53,13 +53,13 @@ header p { margin: 0; font-size: 13px; color: #657068; font-weight: 500; }
h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; } h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
.section-heading button { border: 0; padding: 4px 0; color: var(--green); background: transparent; font-size: 13px; font-weight: 750; display: flex; align-items: center; } .section-heading button { border: 0; padding: 4px 0; color: var(--green); background: transparent; font-size: 13px; font-weight: 750; display: flex; align-items: center; }
.section-heading svg { width: 17px; } .section-heading svg { width: 17px; }
.photo-rail { display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 4px; scrollbar-width: none; } .photo-rail { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 4px; scrollbar-width: none; }
.photo-rail::-webkit-scrollbar { display: none; } .photo-rail::-webkit-scrollbar { display: none; }
.photo-rail button { position: relative; width: 80px; height: 102px; padding: 0; border-radius: 13px; border: 2px solid transparent; background: #ddd; flex: 0 0 auto; overflow: hidden; } .photo-rail button { position: relative; width: 64px; aspect-ratio: 9 / 19.5; padding: 0; border-radius: 13px; border: 2px solid transparent; background: #090b09; flex: 0 0 auto; overflow: hidden; }
.photo-rail button.selected { border-color: #73a17c; box-shadow: 0 0 0 2px #f8faf7 inset; } .photo-rail button.selected { border-color: #73a17c; box-shadow: 0 0 0 2px #f8faf7 inset; }
.photo-rail img { width: 100%; height: 100%; object-fit: cover; display: block; } .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 .photo-current { position: absolute; z-index: 2; left: 6px; top: 6px; width: 22px; height: 22px; background: var(--green); color: white; border-radius: 50%; display: grid; place-items: center; }
.photo-rail span svg { width: 14px; } .photo-rail .photo-current svg { width: 13px; }
.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 { 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); } .empty-collection svg { width: 20px; color: var(--green); }
.hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; } .hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; }
@@ -122,16 +122,25 @@ nav button.active { color: var(--green); background: var(--sage); }
nav svg { width: 21px; } nav svg { width: 21px; }
.snackbar { position: fixed; z-index: 20; left: 50%; transform: translateX(-50%); bottom: calc(92px + env(safe-area-inset-bottom)); 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); } .snackbar { position: fixed; z-index: 20; left: 50%; transform: translateX(-50%); bottom: calc(92px + env(safe-area-inset-bottom)); 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-page { padding-bottom: max(24px, env(safe-area-inset-bottom)); }
.gallery-page.has-floating-action { padding-bottom: calc(94px + env(safe-area-inset-bottom)); }
.selection-toolbar { min-height: 44px; margin: 0 0 11px; padding: 0 4px; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #6c766e; font-size: 12px; } .selection-toolbar { min-height: 44px; margin: 0 0 11px; padding: 0 4px; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #6c766e; font-size: 12px; }
.selection-toolbar button { min-height: 36px; padding: 0 12px; border: 0; border-radius: 11px; color: var(--green); background: var(--sage); font-size: 12px; font-weight: 800; } .selection-toolbar button { min-height: 36px; padding: 0 12px; border: 0; border-radius: 11px; color: var(--green); background: var(--sage); font-size: 12px; font-weight: 800; }
.selection-toolbar button:disabled { opacity: .55; } .selection-toolbar button:disabled { opacity: .55; }
.delete-selection { color: #fff; background: #8e2929; } .delete-selection { color: #fff; background: #8e2929; }
.floating-delete-selection { position: fixed; z-index: 12; left: 50%; bottom: max(18px, env(safe-area-inset-bottom)); width: min(calc(100% - 40px), 440px); height: 56px; padding: 0 18px; border: 0; border-radius: 17px; transform: translateX(-50%); display: flex; align-items: center; justify-content: center; gap: 10px; color: white; background: #8e2929; box-shadow: 0 12px 30px rgba(91, 21, 21, .34); font-size: 15px; font-weight: 800; }
.floating-delete-selection svg { width: 20px; }
.floating-delete-selection strong { min-width: 24px; height: 24px; padding: 0 7px; border-radius: 99px; display: grid; place-items: center; color: #8e2929; background: white; font-size: 11px; }
.floating-delete-selection:disabled { opacity: .6; }
.gallery-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; } .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 { position: relative; aspect-ratio: 3 / 4; overflow: hidden; border-radius: 14px; background: #e3e9e2; border: 2px solid transparent; }
.wallpaper-gallery article { aspect-ratio: 9 / 19.5; background: #090b09; }
.gallery-grid article.current { border-color: #73a17c; } .gallery-grid article.current { border-color: #73a17c; }
.gallery-grid article.chosen { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20,93,50,.14); } .gallery-grid article.chosen { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20,93,50,.14); }
.gallery-image-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; display: block; } .gallery-image-button { position: relative; width: 100%; height: 100%; padding: 0; overflow: hidden; border: 0; background: transparent; display: block; }
.gallery-image-button img { width: 100%; height: 100%; object-fit: cover; display: block; } .gallery-image-button img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gallery-preview-frame { position: absolute; inset: 0; transform-origin: center; }
.gallery-preview-frame.quarter-turn { inset: 26.923% -58.333%; }
.gallery-preview-frame img { width: 100%; height: 100%; display: block; }
.gallery-grid article.chosen .gallery-image-button img { filter: brightness(.78); } .gallery-grid article.chosen .gallery-image-button img { filter: brightness(.78); }
.selection-check { position: absolute; z-index: 2; top: 7px; right: 7px; width: 27px; height: 27px; display: grid; place-items: center; border: 2px solid rgba(255,255,255,.94); border-radius: 50%; color: transparent; background: rgba(28,38,31,.28); pointer-events: none; } .selection-check { position: absolute; z-index: 2; top: 7px; right: 7px; width: 27px; height: 27px; display: grid; place-items: center; border: 2px solid rgba(255,255,255,.94); border-radius: 50%; color: transparent; background: rgba(28,38,31,.28); pointer-events: none; }
.selection-check svg { width: 15px; } .selection-check svg { width: 15px; }