3 Commits
Author SHA1 Message Date
Christoph affa330bf9 ci(workflows): upgrade actions/setup-java to v5
Update CI workflow to use actions/setup-java@v5.
Bring in upstream fixes and features for Java setup.
Improve compatibility with Java 21 and future updates.

- Use actions/setup-java@v5 for Java setup
2026-08-26 17:33:46 +02:00
Christoph 5ffa921967 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.
2026-08-26 17:12:18 +02:00
Christoph 455b68e562 feat(android-release): derive Android version code from release tag
The release workflow now derives versionName and versionCode from the tag
and validates the tag format. It updates multiple project files in the
runner workspace without committing changes back to the repository.
A verification step ensures the generated Android properties are correct.

- Tag parsing validates vMAJOR.MINOR.PATCH and enforces minor/patch <= 999
- VersionCode computed as major*1e6 + minor*1e3 + patch and applied
- Added post-build verification of Android versionName and versionCode
2026-08-26 00:54:20 +02:00
9 changed files with 316 additions and 81 deletions
+73 -5
View File
@@ -35,7 +35,7 @@ jobs:
cache: npm cache: npm
- name: Set up Java - name: Set up Java
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
@@ -67,18 +67,80 @@ jobs:
- name: Install JavaScript dependencies - name: Install JavaScript dependencies
run: npm ci run: npm ci
- name: Check release tag and app version - name: Apply version from release tag
run: | run: |
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "Release builds must run on a tag such as v0.1.0." >&2 echo "Release builds must run on a tag such as v0.1.0." >&2
exit 1 exit 1
fi fi
APP_VERSION="$(node -p "require('./src-tauri/tauri.conf.json').version")"
if [ "${GITHUB_REF_NAME}" != "v${APP_VERSION}" ]; then RELEASE_VERSION="${GITHUB_REF_NAME#v}"
echo "Tag ${GITHUB_REF_NAME} does not match app version v${APP_VERSION}." >&2 if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag ${GITHUB_REF_NAME} must use the format vMAJOR.MINOR.PATCH." >&2
exit 1 exit 1
fi fi
IFS=. read -r VERSION_MAJOR VERSION_MINOR VERSION_PATCH <<< "${RELEASE_VERSION}"
if (( 10#${VERSION_MINOR} > 999 || 10#${VERSION_PATCH} > 999 )); then
echo "Minor and patch versions must not exceed 999." >&2
exit 1
fi
ANDROID_VERSION_CODE=$((10#${VERSION_MAJOR} * 1000000 + 10#${VERSION_MINOR} * 1000 + 10#${VERSION_PATCH}))
if (( ANDROID_VERSION_CODE < 1 || ANDROID_VERSION_CODE > 2100000000 )); then
echo "Derived Android versionCode ${ANDROID_VERSION_CODE} is outside the allowed range." >&2
exit 1
fi
RELEASE_VERSION="${RELEASE_VERSION}" \
ANDROID_VERSION_CODE="${ANDROID_VERSION_CODE}" \
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
node -e '
const fs = require("fs");
const version = process.env.RELEASE_VERSION;
const versionCode = process.env.ANDROID_VERSION_CODE;
function updateJson(path, update) {
const value = JSON.parse(fs.readFileSync(path, "utf8"));
update(value);
fs.writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
updateJson("package.json", value => { value.version = version; });
updateJson("package-lock.json", value => {
value.version = version;
value.packages[""].version = version;
});
updateJson("src-tauri/tauri.conf.json", value => { value.version = version; });
const cargoTomlPath = "src-tauri/Cargo.toml";
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8").replace(
/(\[package\][\s\S]*?\nversion = ")[^"]+("\n)/,
`$1${version}$2`
);
fs.writeFileSync(cargoTomlPath, cargoToml);
const cargoLockPath = "src-tauri/Cargo.lock";
const cargoLock = fs.readFileSync(cargoLockPath, "utf8").replace(
/(\[\[package\]\]\nname = "lockscreenwallpaper"\nversion = ")[^"]+("\n)/,
`$1${version}$2`
);
fs.writeFileSync(cargoLockPath, cargoLock);
const fdroidPath = ".fdroid.yml";
let fdroid = fs.readFileSync(fdroidPath, "utf8");
fdroid = fdroid
.replace(/^ - versionName: .*$/m, ` - versionName: ${version}`)
.replace(/^ versionCode: .*$/m, ` versionCode: ${versionCode}`)
.replace(/^ commit: .*$/m, ` commit: ${process.env.GITHUB_REF_NAME}`)
.replace(/^CurrentVersion: .*$/m, `CurrentVersion: ${version}`)
.replace(/^CurrentVersionCode: .*$/m, `CurrentVersionCode: ${versionCode}`);
fs.writeFileSync(fdroidPath, fdroid);
'
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITEA_ENV}"
echo "ANDROID_VERSION_CODE=${ANDROID_VERSION_CODE}" >> "${GITEA_ENV}"
echo "Building ${GITHUB_REF_NAME} with Android versionCode ${ANDROID_VERSION_CODE}."
- name: Validate signing secrets - name: Validate signing secrets
run: | run: |
for SECRET_NAME in \ for SECRET_NAME in \
@@ -105,6 +167,12 @@ jobs:
- name: Build unsigned release APK - name: Build unsigned release APK
run: npm run tauri -- android build --apk --ci run: npm run tauri -- android build --apk --ci
- name: Verify generated Android version
run: |
TAURI_PROPERTIES="src-tauri/gen/android/app/tauri.properties"
grep -Fx "tauri.android.versionName=${RELEASE_VERSION}" "${TAURI_PROPERTIES}"
grep -Fx "tauri.android.versionCode=${ANDROID_VERSION_CODE}" "${TAURI_PROPERTIES}"
- name: Align and sign APK - name: Align and sign APK
run: | run: |
UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk" UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
+17 -5
View File
@@ -49,10 +49,18 @@ Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er
## Signierte Android-Releases mit Gitea Actions ## Signierte Android-Releases mit Gitea Actions
Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v0.1.0` Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v1.2.3`
eine signierte Universal-APK. Der Tag muss der Version in eine signierte Universal-APK. Der Tag ist dabei die Quelle für alle
`src-tauri/tauri.conf.json` mit vorangestelltem `v` entsprechen. Der Workflow Versionsnummern im Build. Die Pipeline setzt vorübergehend die Versionen in
kann außerdem manuell über die Actions-Oberfläche gestartet werden. `package.json`, `package-lock.json`, `src-tauri/tauri.conf.json`,
`src-tauri/Cargo.toml`, `src-tauri/Cargo.lock` und `.fdroid.yml`. Diese Änderungen
gelten nur im Arbeitsverzeichnis des Runners und werden nicht zurück ins
Repository geschrieben.
Aus `v1.2.3` erzeugt Tauri den Android-`versionCode` `1002003` nach dem Schema
`major * 1000000 + minor * 1000 + patch`. Der Workflow kann außerdem manuell
über die Actions-Oberfläche gestartet werden, wenn dabei ein Versions-Tag als
Ref ausgewählt wird.
In den Repository-Einstellungen unter **Settings → Actions → Secrets** müssen In den Repository-Einstellungen unter **Settings → Actions → Secrets** müssen
folgende Secrets angelegt werden: folgende Secrets angelegt werden:
@@ -99,7 +107,11 @@ Weitere Einzelheiten stehen in der [Datenschutzerklärung](PRIVACY.md).
## Veröffentlichungen ## Veröffentlichungen
Für eine neue Veröffentlichung müssen die Version in `package.json`, `src-tauri/tauri.conf.json` und `src-tauri/Cargo.toml` gemeinsam erhöht und ein passender Git-Tag angelegt werden, beispielsweise `v0.1.0`. Der Android-`versionCode` muss bei jeder Veröffentlichung steigen. Für ein Gitea-Release genügt ein neuer, höherer Versions-Tag wie `v0.1.1`; die
Pipeline übernimmt daraus alle Versionen für den Build. Für dauerhaft im
Repository gepflegte Versionsstände und F-Droid-Releases sollten die Versionen
in `package.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml` und
`.fdroid.yml` zusätzlich im Quellcode aktualisiert werden.
Die Metadaten für F-Droid liegen unter `fastlane/metadata/android`. Das Buildrezept für F-Droid befindet sich in `.fdroid.yml`. Die Metadaten für F-Droid liegen unter `fastlane/metadata/android`. Das Buildrezept für F-Droid befindet sich in `.fdroid.yml`.
+136 -42
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 canvas = Canvas(output) val top = (targetHeight / 2.0 + (baseTop - targetHeight / 2.0) * crop.zoom).toFloat()
canvas.drawColor(Color.BLACK) return try {
val matrix = Matrix().apply { Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
postTranslate(-source.width / 2f, -source.height / 2f) val canvas = Canvas(output)
postRotate(crop.rotation.toFloat()) canvas.drawColor(Color.BLACK)
postScale(scale, scale) canvas.drawBitmap(
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f) 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) 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; }