feat(wallpaper-store): enhance previews, EXIF handling, and shuffle
Android Release / Build signed release APK (push) Successful in 34m17s
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:
@@ -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<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)
|
||||
@@ -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<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 {
|
||||
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<Entry> { 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)
|
||||
|
||||
@@ -15,6 +15,20 @@ pub struct Wallpaper<R: Runtime>(AppHandle<R>);
|
||||
|
||||
impl<R: Runtime> Wallpaper<R> {
|
||||
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<R: Runtime> Wallpaper<R> {
|
||||
"/wallpapers/waterfall.png".into(),
|
||||
"/wallpapers/coast.png".into(),
|
||||
],
|
||||
image_previews,
|
||||
}
|
||||
}
|
||||
pub fn get_state(&self) -> crate::Result<WallpaperState> {
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct WallpaperState {
|
||||
pub current_id: Option<String>,
|
||||
pub image_ids: Vec<String>,
|
||||
pub image_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub image_previews: Vec<GalleryImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
+27
-13
@@ -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<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) {
|
||||
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() {
|
||||
<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>
|
||||
{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>
|
||||
</section>
|
||||
|
||||
@@ -487,11 +500,12 @@ export default function App() {
|
||||
{!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>)}
|
||||
</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>}
|
||||
{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>}
|
||||
{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}>
|
||||
<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>
|
||||
|
||||
+31
-11
@@ -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<string, Pick<GalleryImage, "cropMode" | "cropZoom" | "
|
||||
const defaultCrop = { cropMode: "cover" as const, cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5, cropRotation: 0 };
|
||||
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> {
|
||||
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
|
||||
}
|
||||
@@ -149,6 +166,7 @@ export async function setImageCrop(image: GalleryImage): Promise<GalleryImage> {
|
||||
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<WallpaperState> {
|
||||
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<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");
|
||||
demoState.currentIndex = index;
|
||||
demoState.currentId = id;
|
||||
orderDemoPreviewsFromCurrent();
|
||||
return { ...demoState };
|
||||
}
|
||||
return invoke<WallpaperState>("plugin:wallpaper|apply_wallpaper", { id });
|
||||
|
||||
+14
-5
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user