From 5ffa921967096478cdc0e9d4c348341f7fbdf4ff Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 26 Aug 2026 17:12:18 +0200 Subject: [PATCH] feat(wallpaper-store): enhance previews, EXIF handling, and shuffle 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. --- .../android/src/main/java/WallpaperStore.kt | 178 +++++++++++++----- plugins/src/desktop.rs | 15 ++ plugins/src/models.rs | 2 + .../android/app/src/main/AndroidManifest.xml | 1 + src/App.tsx | 40 ++-- src/native.ts | 42 +++-- src/styles.css | 19 +- 7 files changed, 226 insertions(+), 71 deletions(-) diff --git a/plugins/android/src/main/java/WallpaperStore.kt b/plugins/android/src/main/java/WallpaperStore.kt index 1b20ebe..389f383 100644 --- a/plugins/android/src/main/java/WallpaperStore.kt +++ b/plugins/android/src/main/java/WallpaperStore.kt @@ -8,6 +8,9 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix import android.graphics.Paint +import android.graphics.RectF +import android.media.ExifInterface +import android.os.Build import android.util.Base64 import android.util.LruCache import app.tauri.plugin.JSArray @@ -15,6 +18,7 @@ import app.tauri.plugin.JSObject import java.io.ByteArrayOutputStream import java.io.File import java.security.MessageDigest +import org.json.JSONArray import org.json.JSONObject object WallpaperStore { @@ -23,11 +27,14 @@ object WallpaperStore { private const val KEY_CURRENT_ID = "current_entry_id" private const val KEY_INTERVAL = "interval_minutes" 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 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 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(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) @@ -93,6 +100,38 @@ object WallpaperStore { 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) { + prefs(context).edit().putString(KEY_SHUFFLE_QUEUE_IDS, JSONArray(ids).toString()).apply() + } + + private fun shuffleOrder(context: Context, items: List, currentId: String): Pair, MutableList> { + 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 { val preferences = prefs(context) 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) { require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" } 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() if (name == "prefetchImmich") { if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context) @@ -126,8 +165,15 @@ object WallpaperStore { fun state(context: Context, includePreviews: Boolean = true): JSObject { val items = entries(context) 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 previewIndex = if (index < HOME_PREVIEW_LIMIT) index else 0 + val previewItems = if (items.isEmpty()) emptyList() else { + 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 { put("imageCount", items.size) put("enabled", enabled(context)) @@ -136,7 +182,7 @@ object WallpaperStore { put("lockScreenOnly", lockOnly(context)) put("allowMobileData", allowMobileData(context)) put("prefetchImmich", prefetchImmich(context)) - put("currentIndex", previewIndex) + put("currentIndex", 0) put("currentId", items.getOrNull(index)?.id ?: JSONObject.NULL) val ids = JSArray() previewItems.forEach { ids.put(it.id) } @@ -144,20 +190,24 @@ object WallpaperStore { val previews = JSArray() if (includePreviews) previewItems.forEach { previews.put(thumbnailDataUrl(context, it.preview)) } 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 fun gallery(context: Context, offset: Int, limit: Int): JSObject { val items = entries(context) - val selectedIndex = currentIndex(context, items) - val safeOffset = offset.coerceAtLeast(0).coerceAtMost(items.size) + val selectedId = items.getOrNull(currentIndex(context, items))?.id + val galleryItems = items.sortedWith(compareByDescending { addedAt(it) }.thenByDescending { it.id }) + val safeOffset = offset.coerceAtLeast(0).coerceAtMost(galleryItems.size) val safeLimit = limit.coerceIn(1, 100) val page = JSArray() - items.drop(safeOffset).take(safeLimit).forEachIndexed { pageIndex, entry -> - page.put(galleryImage(context, entry, safeOffset + pageIndex == selectedIndex)) + galleryItems.drop(safeOffset).take(safeLimit).forEach { entry -> + 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 @@ -215,8 +265,16 @@ object WallpaperStore { val json = JSONObject().apply { 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() - return galleryImage(context, entry, items.indexOf(entry) == currentIndex(context, items)) + val selected = 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 @@ -288,27 +346,42 @@ object WallpaperStore { val metrics = context.resources.displayMetrics val targetWidth = metrics.widthPixels.coerceAtLeast(1) val targetHeight = metrics.heightPixels.coerceAtLeast(1) - val quarterTurn = crop.rotation == 90 || crop.rotation == 270 - val rotatedWidth = if (quarterTurn) source.height else source.width - val rotatedHeight = if (quarterTurn) source.width else source.height + val rotated = if (crop.rotation == 0) source else Bitmap.createBitmap( + source, + 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 heightScale = targetHeight.toDouble() / rotatedHeight val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale) val scale = (baseScale * crop.zoom).toFloat() + val baseWidth = rotatedWidth * baseScale + val baseHeight = rotatedHeight * baseScale val scaledWidth = rotatedWidth * scale val scaledHeight = rotatedHeight * 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 { - postTranslate(-source.width / 2f, -source.height / 2f) - postRotate(crop.rotation.toFloat()) - postScale(scale, scale) - postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f) + val baseLeft = (targetWidth - baseWidth) * crop.x + val baseTop = (targetHeight - baseHeight) * crop.y + 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) + canvas.drawColor(Color.BLACK) + canvas.drawBitmap( + rotated, + null, + RectF(left, top, left + scaledWidth, top + scaledHeight), + 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) 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 }) + 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 { - val cacheKey = "${file.absolutePath}:${file.lastModified()}:${file.length()}" + val cacheKey = "$THUMBNAIL_VERSION:${file.absolutePath}:${file.lastModified()}:${file.length()}" thumbnailCache.get(cacheKey)?.let { return it } val cached = File(thumbnailDirectory(context), "${digest(cacheKey)}.jpg") if (cached.isFile) { @@ -336,7 +432,8 @@ object WallpaperStore { BitmapFactory.decodeFile(file.absolutePath, bounds) var sample = 1 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 -> bitmap.compress(Bitmap.CompressFormat.JPEG, 76, out); bitmap.recycle() val bytes = out.toByteArray() @@ -348,7 +445,7 @@ object WallpaperStore { } 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") @@ -360,7 +457,7 @@ object WallpaperStore { is Entry.Local -> "${entry.file.lastModified()}:${entry.file.length()}" is Entry.Immich -> entry.assetId } - val key = "${entry.id}:$sourceVersion:${metrics.widthPixels}x${metrics.heightPixels}:${prefs(context).getString(cropKey(entry.id), "")}" + 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") } @@ -394,18 +491,15 @@ object WallpaperStore { if (items.isEmpty()) return false val previous = currentIndex(context, items) if (shuffle(context) && items.size > 1) { - val availableIds = items.mapTo(mutableSetOf()) { it.id } - val seenIds = prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet()) - .orEmpty().filterTo(mutableSetOf()) { it in availableIds } - seenIds.add(items[previous].id) - - var candidates = items.indices.filter { items[it].id !in seenIds }.shuffled() - if (candidates.isEmpty()) { - seenIds.clear() - seenIds.add(items[previous].id) - candidates = items.indices.filter { it != previous }.shuffled() + val (seenIds, queue) = shuffleOrder(context, items, items[previous].id) + val candidates = queue.mapNotNull { id -> items.indexOfFirst { it.id == id }.takeIf { it >= 0 } } + val applied = applyCandidates(context, items, candidates, automatic, seenIds) + if (applied) { + val selectedId = prefs(context).getString(KEY_CURRENT_ID, null) + val selectedPosition = queue.indexOf(selectedId) + writeShuffleQueue(context, if (selectedPosition >= 0) queue.drop(selectedPosition + 1) else queue.filter { it != selectedId }) } - return applyCandidates(context, items, candidates, automatic, seenIds) + return applied } val candidates = (1..items.size).map { (previous + it).mod(items.size) } return applyCandidates(context, items, candidates, automatic) diff --git a/plugins/src/desktop.rs b/plugins/src/desktop.rs index 291fa13..c4fff9d 100644 --- a/plugins/src/desktop.rs +++ b/plugins/src/desktop.rs @@ -15,6 +15,20 @@ pub struct Wallpaper(AppHandle); impl Wallpaper { 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 { image_count: 3, enabled: true, @@ -31,6 +45,7 @@ impl Wallpaper { "/wallpapers/waterfall.png".into(), "/wallpapers/coast.png".into(), ], + image_previews, } } pub fn get_state(&self) -> crate::Result { diff --git a/plugins/src/models.rs b/plugins/src/models.rs index 40290a9..967e6a6 100644 --- a/plugins/src/models.rs +++ b/plugins/src/models.rs @@ -14,6 +14,8 @@ pub struct WallpaperState { pub current_id: Option, pub image_ids: Vec, pub image_urls: Vec, + #[serde(default)] + pub image_previews: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index dfb33ec..693cfd8 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ android:launchMode="singleTask" android:label="@string/main_activity_title" android:name=".MainActivity" + android:screenOrientation="portrait" android:exported="true"> diff --git a/src/App.tsx b/src/App.tsx index 1b681c4..c9e9226 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ 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 { 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 { 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"; 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 currentPreviewIndex = state.imageIds.indexOf(state.currentId ?? ""); const current = state.imageUrls[currentPreviewIndex] ?? "/wallpapers/alpine.png"; - const photos = useMemo(() => state.imageUrls, [state.imageUrls]); + const photos = useMemo(() => 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) { if (galleryLoadingRef.current) return; @@ -306,6 +314,7 @@ export default function App() { try { const saved = await setImageCrop(editing); 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); setNotice(t.cropSaved); returnToGallery(); @@ -361,7 +370,7 @@ export default function App() { } : previous); } - function previewPosition(image: GalleryImage) { + function previewPosition(image: ImagePreview) { const { cropPositionX: x, cropPositionY: y } = image; if (image.cropRotation === 90) return `${y * 100}% ${(1 - x) * 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 ? Math.min(3, Math.max(1, gesture.zoom * geometry.distance / gesture.distance)) : gesture.zoom; - const previousLeft = (gesture.width - gesture.baseWidth * gesture.zoom) * gesture.x; - const previousTop = (gesture.height - gesture.baseHeight * gesture.zoom) * gesture.y; + const previousBaseLeft = (gesture.width - gesture.baseWidth) * gesture.x; + 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 imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom; const nextLeft = geometry.centerX - gesture.left - imageX * zoom; const nextTop = geometry.centerY - gesture.top - imageY * zoom; - const horizontalTravel = gesture.width - gesture.baseWidth * zoom; - const verticalTravel = gesture.height - gesture.baseHeight * zoom; - const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextLeft / horizontalTravel; - const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextTop / verticalTravel; + const horizontalTravel = gesture.width - gesture.baseWidth; + const verticalTravel = gesture.height - gesture.baseHeight; + const nextBaseLeft = gesture.width / 2 + (nextLeft - gesture.width / 2) / zoom; + 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 ? { ...previous, cropZoom: zoom, @@ -450,7 +463,7 @@ export default function App() {

{t.collection}

- {photos.length ?
{photos.map((photo, index) => )}
: } + {photos.length ?
{photos.map((photo, index) => )}
: }

{t.collectionHint}

@@ -487,11 +500,12 @@ export default function App() { {!immichLoading && immichHasMore && } {!!immichSelected.size && (busy && immichImportProgress ?
{it.importing}{immichImportProgress.completed} / {immichImportProgress.total}
{immichImportProgress.bytesTotal > 0 &&
{Math.round(immichImportProgress.bytesDownloaded / immichImportProgress.bytesTotal * 100)}%{(immichImportProgress.bytesDownloaded / 1024 / 1024).toFixed(1)} / {(immichImportProgress.bytesTotal / 1024 / 1024).toFixed(1)} MB
}
: )} : tab === "gallery" ? -
+
{!!galleryTotal &&
{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}
} - {gallery.length ?
{gallery.map((image, index) =>
{image.selected && {t.current}}
)}
: !galleryLoading &&

{t.emptyCollection}

{t.emptyCollectionText}

} + {gallery.length ?
{gallery.map((image, index) =>
{image.selected && {t.current}}
)}
: !galleryLoading &&

{t.emptyCollection}

{t.emptyCollectionText}

} {galleryLoading &&

{t.galleryLoading}

} {gallery.length < galleryTotal &&
: editing &&
{t.cropPreview}
diff --git a/src/native.ts b/src/native.ts index 38f0f44..5e29c23 100644 --- a/src/native.ts +++ b/src/native.ts @@ -1,5 +1,15 @@ 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 = { imageCount: number; enabled: boolean; @@ -12,17 +22,11 @@ export type WallpaperState = { currentId: string | null; imageIds: string[]; imageUrls: string[]; + imagePreviews: ImagePreview[]; }; -export type GalleryImage = { - id: string; - url: string; +export type GalleryImage = ImagePreview & { selected: boolean; - cropMode: "cover" | "contain"; - cropZoom: number; - cropPositionX: number; - cropPositionY: number; - cropRotation: number; }; export type GalleryPage = { @@ -65,7 +69,7 @@ export type ImmichImportProgress = { }; const demoState: WallpaperState = { - imageCount: 3, + imageCount: 6, enabled: true, intervalMinutes: 30, shuffle: true, @@ -74,8 +78,16 @@ const demoState: WallpaperState = { prefetchImmich: true, currentIndex: 0, currentId: "demo-0", - imageIds: ["demo-0", "demo-1", "demo-2"], - imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"], + imageIds: ["demo-0", "demo-1", "demo-2", "demo-3", "demo-4", "demo-5"], + 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; @@ -83,6 +95,11 @@ const demoCrops = new Map image.id === demoState.currentId); + if (index > 0) demoState.imagePreviews = [...demoState.imagePreviews.slice(index), ...demoState.imagePreviews.slice(0, index)]; +} + export async function getState(): Promise { return inTauri() ? invoke("plugin:wallpaper|get_state") : demoState; } @@ -149,6 +166,7 @@ export async function setImageCrop(image: GalleryImage): Promise { cropPositionY: image.cropPositionY, cropRotation: image.cropRotation, }); + demoState.imagePreviews = demoState.imagePreviews.map(preview => preview.id === image.id ? { ...preview, ...image } : preview); return { ...image }; } @@ -170,6 +188,7 @@ export async function nextWallpaper(): Promise { if (!inTauri()) { if (demoState.imageUrls.length) demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length; demoState.currentId = demoState.imageIds[demoState.currentIndex] ?? null; + orderDemoPreviewsFromCurrent(); return { ...demoState }; } return invoke("plugin:wallpaper|next_wallpaper"); @@ -181,6 +200,7 @@ export async function applyWallpaper(id: string): Promise { if (index < 0) throw new Error("Image not found"); demoState.currentIndex = index; demoState.currentId = id; + orderDemoPreviewsFromCurrent(); return { ...demoState }; } return invoke("plugin:wallpaper|apply_wallpaper", { id }); diff --git a/src/styles.css b/src/styles.css index fcd72ac..2168083 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } .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; } -.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 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 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; } +.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 .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 svg { width: 20px; color: var(--green); } .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; } .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.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 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; } .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 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.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-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); } .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; }