3 Commits
Author SHA1 Message Date
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
Christoph aca5562f81 feat(gitea): attach build artifacts to Gitea releases
Android Release / Build signed release APK (push) Successful in 37m54s
The Android release workflow now creates or updates a Gitea
release and attaches the APK and its SHA256 to it. A
GITEA_TOKEN with releases: write is required for uploads.
The README now documents the Gitea release flow and the
required permissions.

- Enforces that releases run only on a tag and matches the app version.
- Replaces or updates release assets to match tag names.
- Requires GITEA_TOKEN with releases: write for uploads.
2026-08-26 00:14:39 +02:00
9 changed files with 405 additions and 93 deletions
+151 -13
View File
@@ -11,6 +11,9 @@ jobs:
name: Build signed release APK
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
code: read
releases: write
env:
ANDROID_COMPILE_SDK: "36"
ANDROID_BUILD_TOOLS: "36.0.0"
@@ -19,6 +22,7 @@ jobs:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
- name: Check out repository
@@ -63,22 +67,88 @@ jobs:
- name: Install JavaScript dependencies
run: npm ci
- name: Check release tag and app version
if: gitea.ref_type == 'tag'
- name: Apply version from release tag
run: |
APP_VERSION="$(node -p "require('./src-tauri/tauri.conf.json').version")"
if [ "${GITHUB_REF_NAME}" != "v${APP_VERSION}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match app version v${APP_VERSION}." >&2
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "Release builds must run on a tag such as v0.1.0." >&2
exit 1
fi
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
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
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
run: |
for SECRET_NAME in \
ANDROID_KEYSTORE_BASE64 \
ANDROID_KEYSTORE_PASSWORD \
ANDROID_KEY_ALIAS \
ANDROID_KEY_PASSWORD
ANDROID_KEY_PASSWORD \
GITEA_RELEASE_TOKEN
do
if [ -z "${!SECRET_NAME:-}" ]; then
echo "Missing Gitea Actions secret: ${SECRET_NAME}" >&2
@@ -97,6 +167,12 @@ jobs:
- name: Build unsigned release APK
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
run: |
UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk"
@@ -118,11 +194,73 @@ jobs:
rm "${ALIGNED_APK}"
(cd "${RELEASE_DIRECTORY}" && sha256sum WallpaperFlow-release.apk > WallpaperFlow-release.apk.sha256)
- name: Upload release APK
uses: actions/upload-artifact@v3
with:
name: WallpaperFlow-${{ gitea.ref_name }}-android
path: |
release/WallpaperFlow-release.apk
- name: Attach APK to Gitea release
run: |
API_ROOT="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
RELEASE_JSON="${RUNNER_TEMP}/wallpaperflow-release.json"
CREATE_JSON="${RUNNER_TEMP}/wallpaperflow-create-release.json"
ASSETS_JSON="${RUNNER_TEMP}/wallpaperflow-release-assets.json"
STATUS="$(curl --silent --show-error \
--output "${RELEASE_JSON}" \
--write-out '%{http_code}' \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
"${API_ROOT}/releases/tags/${GITHUB_REF_NAME}")"
if [ "${STATUS}" = "404" ]; then
node -e '
process.stdout.write(JSON.stringify({
tag_name: process.env.GITHUB_REF_NAME,
name: `WallpaperFlow ${process.env.GITHUB_REF_NAME}`,
body: "Automatisch erstelltes Android-Release.",
draft: false,
prerelease: false
}))
' > "${CREATE_JSON}"
curl --fail --silent --show-error \
--request POST \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
--header "Content-Type: application/json" \
--data-binary "@${CREATE_JSON}" \
--output "${RELEASE_JSON}" \
"${API_ROOT}/releases"
elif [ "${STATUS}" != "200" ]; then
echo "Could not read Gitea release for ${GITHUB_REF_NAME} (HTTP ${STATUS})." >&2
exit 1
fi
RELEASE_ID="$(node -e '
const release = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
if (!release.id) process.exit(1);
process.stdout.write(String(release.id));
' "${RELEASE_JSON}")"
curl --fail --silent --show-error \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
--output "${ASSETS_JSON}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets"
for FILE in \
release/WallpaperFlow-release.apk \
release/WallpaperFlow-release.apk.sha256
if-no-files-found: error
do
ASSET_NAME="$(basename "${FILE}")"
ASSET_ID="$(ASSET_NAME="${ASSET_NAME}" node -e '
const assets = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const asset = assets.find(item => item.name === process.env.ASSET_NAME);
if (asset) process.stdout.write(String(asset.id));
' "${ASSETS_JSON}")"
if [ -n "${ASSET_ID}" ]; then
curl --fail --silent --show-error \
--request DELETE \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
curl --fail --silent --show-error \
--request POST \
--header "Authorization: token ${GITEA_RELEASE_TOKEN}" \
--form "attachment=@${FILE}" \
"${API_ROOT}/releases/${RELEASE_ID}/assets" >/dev/null
done
+28 -9
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
Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v0.1.0`
eine signierte Universal-APK. Der Tag muss der Version in
`src-tauri/tauri.conf.json` mit vorangestelltem `v` entsprechen. Der Workflow
kann außerdem manuell über die Actions-Oberfläche gestartet werden.
Der Workflow `.gitea/workflows/android-release.yml` baut bei Tags wie `v1.2.3`
eine signierte Universal-APK. Der Tag ist dabei die Quelle für alle
Versionsnummern im Build. Die Pipeline setzt vorübergehend die Versionen in
`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
folgende Secrets angelegt werden:
@@ -68,10 +76,17 @@ Den Keystore-Inhalt für das Secret erzeugt man unter Linux mit:
base64 -w 0 /sicherer/pfad/wallpaperflow-release.jks
```
Nach erfolgreichem Lauf stehen `WallpaperFlow-release.apk` und die zugehörige
SHA-256-Prüfsumme als Artefakt des Workflows bereit. Der Keystore darf nicht in
das Repository eingecheckt werden und muss dauerhaft gesichert bleiben, da
spätere Updates mit demselben Schlüssel signiert werden müssen.
Nach erfolgreichem Lauf erstellt die Pipeline ein Gitea-Release für den Tag
beziehungsweise verwendet ein bereits vorhandenes Release. Die Dateien
`WallpaperFlow-release.apk` und `WallpaperFlow-release.apk.sha256` werden direkt
an dieses Release angehängt. Bei einem erneuten Lauf für denselben Tag werden
vorhandene Anhänge mit diesen Namen ersetzt. Dafür muss der integrierte
`GITEA_TOKEN` Schreibzugriff auf Releases besitzen; die Pipeline fordert die
Berechtigung `releases: write` selbst an.
Der Keystore darf nicht in das Repository eingecheckt werden und muss dauerhaft
gesichert bleiben, da spätere Updates mit demselben Schlüssel signiert werden
müssen.
Voraussetzung ist ein aktiver Gitea-Actions-Runner mit dem Label
`ubuntu-latest`, Netzwerkzugriff auf npm, Rust und die Android-SDK-Server sowie
@@ -92,7 +107,11 @@ Weitere Einzelheiten stehen in der [Datenschutzerklärung](PRIVACY.md).
## 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`.
+136 -42
View File
@@ -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
View File
@@ -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> {
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
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; }
.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; }