Compare commits
5
Commits
75c79a8344
..
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffa921967 | ||
|
|
455b68e562 | ||
|
|
aca5562f81 | ||
|
|
7c15f51355 | ||
|
|
a5f71b576b |
@@ -0,0 +1,266 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-release-apk:
|
||||||
|
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"
|
||||||
|
ANDROID_NDK_VERSION: "30.0.15729638"
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
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
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@1.95.0
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Cache Rust build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri -> target
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
|
sdkmanager \
|
||||||
|
"platforms;android-${ANDROID_COMPILE_SDK}" \
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS}" \
|
||||||
|
"ndk;${ANDROID_NDK_VERSION}"
|
||||||
|
echo "NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}" >> "${GITEA_ENV}"
|
||||||
|
echo "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}" >> "${GITEA_PATH}"
|
||||||
|
|
||||||
|
- name: Install JavaScript dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Apply version from release tag
|
||||||
|
run: |
|
||||||
|
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 \
|
||||||
|
GITEA_RELEASE_TOKEN
|
||||||
|
do
|
||||||
|
if [ -z "${!SECRET_NAME:-}" ]; then
|
||||||
|
echo "Missing Gitea Actions secret: ${SECRET_NAME}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Restore release keystore
|
||||||
|
run: |
|
||||||
|
SIGNING_DIRECTORY="${RUNNER_TEMP}/wallpaperflow-signing"
|
||||||
|
mkdir -p "${SIGNING_DIRECTORY}"
|
||||||
|
printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 --decode > "${SIGNING_DIRECTORY}/release.jks"
|
||||||
|
chmod 600 "${SIGNING_DIRECTORY}/release.jks"
|
||||||
|
echo "ANDROID_SIGNING_KEYSTORE=${SIGNING_DIRECTORY}/release.jks" >> "${GITEA_ENV}"
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
RELEASE_DIRECTORY="release"
|
||||||
|
ALIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release-aligned.apk"
|
||||||
|
SIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release.apk"
|
||||||
|
|
||||||
|
test -f "${UNSIGNED_APK}"
|
||||||
|
mkdir -p "${RELEASE_DIRECTORY}"
|
||||||
|
zipalign -p -f 4 "${UNSIGNED_APK}" "${ALIGNED_APK}"
|
||||||
|
apksigner sign \
|
||||||
|
--ks "${ANDROID_SIGNING_KEYSTORE}" \
|
||||||
|
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
|
||||||
|
--ks-pass env:ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
--key-pass env:ANDROID_KEY_PASSWORD \
|
||||||
|
--out "${SIGNED_APK}" \
|
||||||
|
"${ALIGNED_APK}"
|
||||||
|
apksigner verify --verbose --print-certs "${SIGNED_APK}"
|
||||||
|
rm "${ALIGNED_APK}"
|
||||||
|
(cd "${RELEASE_DIRECTORY}" && sha256sum WallpaperFlow-release.apk > WallpaperFlow-release.apk.sha256)
|
||||||
|
|
||||||
|
- 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
|
||||||
|
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
|
||||||
@@ -47,6 +47,51 @@ npm run tauri android dev
|
|||||||
|
|
||||||
Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er wird beim Android-Build automatisch eingebunden.
|
Der native Android-Code liegt als lokales Tauri-Plugin in `plugins/android`. Er wird beim Android-Build automatisch eingebunden.
|
||||||
|
|
||||||
|
## Signierte Android-Releases mit Gitea Actions
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `ANDROID_KEYSTORE_BASE64`: der Base64-kodierte Inhalt des Release-Keystores
|
||||||
|
- `ANDROID_KEYSTORE_PASSWORD`: Passwort des Keystores
|
||||||
|
- `ANDROID_KEY_ALIAS`: Alias des Signaturschlüssels
|
||||||
|
- `ANDROID_KEY_PASSWORD`: Passwort des Signaturschlüssels
|
||||||
|
|
||||||
|
Den Keystore-Inhalt für das Secret erzeugt man unter Linux mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
base64 -w 0 /sicherer/pfad/wallpaperflow-release.jks
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
ausreichend Speicher für Android SDK, NDK und Rust-Buildartefakte.
|
||||||
|
|
||||||
## Wichtige Android-Hinweise
|
## Wichtige Android-Hinweise
|
||||||
|
|
||||||
- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist.
|
- Android darf ungenaue Alarme im Ruhemodus verzögern. Der nächste Wechsel wird ausgeführt, sobald das Gerät wieder aktiv ist.
|
||||||
@@ -62,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`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
|
WallpaperStore.screenOnEnabled(context) -> runCatching { ScreenOnRotationService.start(context) }
|
||||||
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
|
WallpaperStore.timedEnabled(context) -> WallpaperScheduler.scheduleNext(context)
|
||||||
}
|
}
|
||||||
|
if (ImmichClient.configured(context) && WallpaperStore.prefetchImmich(context)) ImmichPrefetchWorker.enqueue(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ object ImmichClient {
|
|||||||
|
|
||||||
fun connection(context: Context) = connectionObject(context)
|
fun connection(context: Context) = connectionObject(context)
|
||||||
|
|
||||||
|
fun configured(context: Context): Boolean {
|
||||||
|
val preferences = prefs(context)
|
||||||
|
return preferences.getString(KEY_SERVER_URL, "").orEmpty().isNotBlank() && !decryptApiKey(context).isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
|
fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
|
||||||
val normalized = normalizeServerUrl(serverUrl)
|
val normalized = normalizeServerUrl(serverUrl)
|
||||||
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
|
require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
|
||||||
@@ -314,10 +319,10 @@ object ImmichClient {
|
|||||||
return bounds.outWidth > 0 && bounds.outHeight > 0
|
return bounds.outWidth > 0 && bounds.outHeight > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun automaticDownloadsAllowed(context: Context): Boolean {
|
fun automaticDownloadsAllowed(context: Context, allowMetered: Boolean): Boolean {
|
||||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
return connectivity.activeNetworkInfo?.isConnected == true && !connectivity.isActiveNetworkMetered
|
return connectivity.activeNetworkInfo?.isConnected == true && (allowMetered || !connectivity.isActiveNetworkMetered)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.wechselbild.wallpaper
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ImmichPrefetchWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
WallpaperStore.prefetchImmichOriginals(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "immich-wifi-prefetch"
|
||||||
|
|
||||||
|
fun enqueue(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build()
|
||||||
|
val request = PeriodicWorkRequestBuilder<ImmichPrefetchWorker>(6, TimeUnit.HOURS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,11 +170,14 @@ class WallpaperPlugin(private val activity: Activity) : Plugin(activity) {
|
|||||||
@Command fun connectImmich(invoke: Invoke) = io.execute {
|
@Command fun connectImmich(invoke: Invoke) = io.execute {
|
||||||
try {
|
try {
|
||||||
val args = invoke.parseArgs(ImmichConnectArgs::class.java)
|
val args = invoke.parseArgs(ImmichConnectArgs::class.java)
|
||||||
invoke.resolve(ImmichClient.connect(activity, args.serverUrl, args.apiKey))
|
val result = ImmichClient.connect(activity, args.serverUrl, args.apiKey)
|
||||||
|
if (WallpaperStore.prefetchImmich(activity)) ImmichPrefetchWorker.enqueue(activity)
|
||||||
|
invoke.resolve(result)
|
||||||
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
|
} catch (error: Exception) { invoke.reject(error.message ?: "Immich-Verbindung fehlgeschlagen") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Command fun disconnectImmich(invoke: Invoke) = io.execute {
|
@Command fun disconnectImmich(invoke: Invoke) = io.execute {
|
||||||
|
ImmichPrefetchWorker.cancel(activity)
|
||||||
invoke.resolve(ImmichClient.disconnect(activity))
|
invoke.resolve(ImmichClient.disconnect(activity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -22,11 +26,15 @@ object WallpaperStore {
|
|||||||
private const val KEY_INDEX = "current_index"
|
private const val KEY_INDEX = "current_index"
|
||||||
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_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)
|
||||||
@@ -92,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)
|
||||||
@@ -102,10 +142,18 @@ object WallpaperStore {
|
|||||||
fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1
|
fun screenOnEnabled(context: Context) = intervalMinutes(context) == -1
|
||||||
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
|
fun shuffle(context: Context) = prefs(context).getBoolean("shuffle", true)
|
||||||
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
|
fun lockOnly(context: Context) = prefs(context).getBoolean("lockScreenOnly", true)
|
||||||
|
fun allowMobileData(context: Context) = prefs(context).getBoolean("allowMobileData", false)
|
||||||
|
fun prefetchImmich(context: Context) = prefs(context).getBoolean("prefetchImmich", true)
|
||||||
|
|
||||||
fun set(context: Context, name: String, value: Boolean) {
|
fun set(context: Context, name: String, value: Boolean) {
|
||||||
require(name in setOf("shuffle", "lockScreenOnly")) { "Unbekannte Einstellung" }
|
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
|
||||||
prefs(context).edit().putBoolean(name, value).apply()
|
val editor = prefs(context).edit().putBoolean(name, value)
|
||||||
|
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)
|
||||||
|
else if (!value) ImmichPrefetchWorker.cancel(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setInterval(context: Context, minutes: Int) {
|
fun setInterval(context: Context, minutes: Int) {
|
||||||
@@ -117,15 +165,24 @@ 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))
|
||||||
put("intervalMinutes", intervalMinutes(context))
|
put("intervalMinutes", intervalMinutes(context))
|
||||||
put("shuffle", shuffle(context))
|
put("shuffle", shuffle(context))
|
||||||
put("lockScreenOnly", lockOnly(context))
|
put("lockScreenOnly", lockOnly(context))
|
||||||
put("currentIndex", previewIndex)
|
put("allowMobileData", allowMobileData(context))
|
||||||
|
put("prefetchImmich", prefetchImmich(context))
|
||||||
|
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) }
|
||||||
@@ -133,25 +190,39 @@ 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
|
||||||
fun imageIds(context: Context) = entries(context).map { it.id }
|
fun imageIds(context: Context) = entries(context).map { it.id }
|
||||||
|
|
||||||
|
fun prefetchImmichOriginals(context: Context) {
|
||||||
|
val unavailableServers = mutableSetOf<String>()
|
||||||
|
entries(context).filterIsInstance<Entry.Immich>().forEach { entry ->
|
||||||
|
if (entry.serverUrl in unavailableServers) return@forEach
|
||||||
|
if (ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork = true) == null) {
|
||||||
|
unavailableServers.add(entry.serverUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun cropKey(id: String) = CROP_PREFIX + id
|
private fun cropKey(id: String) = CROP_PREFIX + id
|
||||||
private fun crop(context: Context, entry: Entry): CropSettings {
|
private fun crop(context: Context, entry: Entry): CropSettings {
|
||||||
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
|
val raw = prefs(context).getString(cropKey(entry.id), null) ?: return CropSettings()
|
||||||
@@ -194,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
|
||||||
@@ -267,27 +346,42 @@ object WallpaperStore {
|
|||||||
val metrics = context.resources.displayMetrics
|
val metrics = context.resources.displayMetrics
|
||||||
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
|
val targetWidth = metrics.widthPixels.coerceAtLeast(1)
|
||||||
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
|
val targetHeight = metrics.heightPixels.coerceAtLeast(1)
|
||||||
val quarterTurn = crop.rotation == 90 || crop.rotation == 270
|
val rotated = if (crop.rotation == 0) source else Bitmap.createBitmap(
|
||||||
val rotatedWidth = if (quarterTurn) source.height else source.width
|
source,
|
||||||
val rotatedHeight = if (quarterTurn) source.width else source.height
|
0,
|
||||||
|
0,
|
||||||
|
source.width,
|
||||||
|
source.height,
|
||||||
|
Matrix().apply { setRotate(crop.rotation.toFloat()) },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val rotatedWidth = rotated.width
|
||||||
|
val rotatedHeight = rotated.height
|
||||||
val widthScale = targetWidth.toDouble() / rotatedWidth
|
val widthScale = targetWidth.toDouble() / rotatedWidth
|
||||||
val heightScale = targetHeight.toDouble() / rotatedHeight
|
val heightScale = targetHeight.toDouble() / rotatedHeight
|
||||||
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
|
val baseScale = if (crop.mode == "contain") minOf(widthScale, heightScale) else maxOf(widthScale, heightScale)
|
||||||
val scale = (baseScale * crop.zoom).toFloat()
|
val scale = (baseScale * crop.zoom).toFloat()
|
||||||
|
val baseWidth = rotatedWidth * baseScale
|
||||||
|
val baseHeight = rotatedHeight * baseScale
|
||||||
val scaledWidth = rotatedWidth * scale
|
val scaledWidth = rotatedWidth * scale
|
||||||
val scaledHeight = rotatedHeight * scale
|
val scaledHeight = rotatedHeight * scale
|
||||||
val left = ((targetWidth - scaledWidth) * crop.x).toFloat()
|
val baseLeft = (targetWidth - baseWidth) * crop.x
|
||||||
val top = ((targetHeight - scaledHeight) * crop.y).toFloat()
|
val baseTop = (targetHeight - baseHeight) * crop.y
|
||||||
return Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
|
val left = (targetWidth / 2.0 + (baseLeft - targetWidth / 2.0) * crop.zoom).toFloat()
|
||||||
|
val top = (targetHeight / 2.0 + (baseTop - targetHeight / 2.0) * crop.zoom).toFloat()
|
||||||
|
return try {
|
||||||
|
Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888).also { output ->
|
||||||
val canvas = Canvas(output)
|
val canvas = Canvas(output)
|
||||||
canvas.drawColor(Color.BLACK)
|
canvas.drawColor(Color.BLACK)
|
||||||
val matrix = Matrix().apply {
|
canvas.drawBitmap(
|
||||||
postTranslate(-source.width / 2f, -source.height / 2f)
|
rotated,
|
||||||
postRotate(crop.rotation.toFloat())
|
null,
|
||||||
postScale(scale, scale)
|
RectF(left, top, left + scaledWidth, top + scaledHeight),
|
||||||
postTranslate(left + scaledWidth / 2f, top + scaledHeight / 2f)
|
Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
canvas.drawBitmap(source, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
|
} finally {
|
||||||
|
if (rotated !== source) rotated.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,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) {
|
||||||
@@ -315,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()
|
||||||
@@ -327,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")
|
||||||
@@ -339,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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,9 +490,18 @@ object WallpaperStore {
|
|||||||
val items = entries(context)
|
val items = entries(context)
|
||||||
if (items.isEmpty()) return false
|
if (items.isEmpty()) return false
|
||||||
val previous = currentIndex(context, items)
|
val previous = currentIndex(context, items)
|
||||||
val candidates = if (shuffle(context) && items.size > 1) {
|
if (shuffle(context) && items.size > 1) {
|
||||||
items.indices.filter { it != previous }.shuffled() + previous
|
val (seenIds, queue) = shuffleOrder(context, items, items[previous].id)
|
||||||
} else (1..items.size).map { (previous + it).mod(items.size) }
|
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 applied
|
||||||
|
}
|
||||||
|
val candidates = (1..items.size).map { (previous + it).mod(items.size) }
|
||||||
return applyCandidates(context, items, candidates, automatic)
|
return applyCandidates(context, items, candidates, automatic)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,10 +510,21 @@ object WallpaperStore {
|
|||||||
val items = entries(context)
|
val items = entries(context)
|
||||||
val index = items.indexOfFirst { it.id == id }
|
val index = items.indexOfFirst { it.id == id }
|
||||||
if (index < 0) return false
|
if (index < 0) return false
|
||||||
return applyCandidates(context, items, listOf(index), automatic = false)
|
val seenIds = if (shuffle(context)) {
|
||||||
|
val availableIds = items.mapTo(mutableSetOf()) { it.id }
|
||||||
|
prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
|
||||||
|
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
|
||||||
|
} else null
|
||||||
|
return applyCandidates(context, items, listOf(index), automatic = false, seenIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyCandidates(context: Context, items: List<Entry>, candidates: List<Int>, automatic: Boolean): Boolean {
|
private fun applyCandidates(
|
||||||
|
context: Context,
|
||||||
|
items: List<Entry>,
|
||||||
|
candidates: List<Int>,
|
||||||
|
automatic: Boolean,
|
||||||
|
shuffleSeenIds: Set<String>? = null,
|
||||||
|
): Boolean {
|
||||||
val unavailableServers = mutableSetOf<String>()
|
val unavailableServers = mutableSetOf<String>()
|
||||||
for (index in candidates) {
|
for (index in candidates) {
|
||||||
val entry = items[index]
|
val entry = items[index]
|
||||||
@@ -401,7 +539,7 @@ object WallpaperStore {
|
|||||||
val sourceFile = when (entry) {
|
val sourceFile = when (entry) {
|
||||||
is Entry.Local -> entry.file
|
is Entry.Local -> entry.file
|
||||||
is Entry.Immich -> {
|
is Entry.Immich -> {
|
||||||
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context))
|
val allowNetwork = entry.serverUrl !in unavailableServers && (!automatic || ImmichClient.automaticDownloadsAllowed(context, allowMobileData(context)))
|
||||||
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
|
val source = ImmichClient.cachedOriginal(context, entry.serverUrl, entry.assetId, allowNetwork)
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
if (allowNetwork) unavailableServers.add(entry.serverUrl)
|
if (allowNetwork) unavailableServers.add(entry.serverUrl)
|
||||||
@@ -429,7 +567,12 @@ object WallpaperStore {
|
|||||||
val manager = WallpaperManager.getInstance(context)
|
val manager = WallpaperManager.getInstance(context)
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
|
if (android.os.Build.VERSION.SDK_INT >= 24 && lockOnly(context)) manager.setBitmap(wallpaper, null, true, WallpaperManager.FLAG_LOCK)
|
||||||
else manager.setBitmap(wallpaper)
|
else manager.setBitmap(wallpaper)
|
||||||
prefs(context).edit().remove(INVALID_PREFIX + entry.id).putString(KEY_CURRENT_ID, entry.id).putInt(KEY_INDEX, index).apply()
|
val editor = prefs(context).edit()
|
||||||
|
.remove(INVALID_PREFIX + entry.id)
|
||||||
|
.putString(KEY_CURRENT_ID, entry.id)
|
||||||
|
.putInt(KEY_INDEX, index)
|
||||||
|
if (shuffleSeenIds != null) editor.putStringSet(KEY_SHUFFLE_SEEN_IDS, shuffleSeenIds + entry.id)
|
||||||
|
editor.apply()
|
||||||
return true
|
return true
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Try the next usable entry without changing the current selection.
|
// Try the next usable entry without changing the current selection.
|
||||||
|
|||||||
@@ -15,12 +15,28 @@ 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,
|
||||||
interval_minutes: 30,
|
interval_minutes: 30,
|
||||||
shuffle: true,
|
shuffle: true,
|
||||||
lock_screen_only: true,
|
lock_screen_only: true,
|
||||||
|
allow_mobile_data: false,
|
||||||
|
prefetch_immich: true,
|
||||||
current_index: 0,
|
current_index: 0,
|
||||||
current_id: Some("demo-0".into()),
|
current_id: Some("demo-0".into()),
|
||||||
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".into()],
|
image_ids: vec!["demo-0".into(), "demo-1".into(), "demo-2".into()],
|
||||||
@@ -29,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> {
|
||||||
@@ -89,6 +106,8 @@ impl<R: Runtime> Wallpaper<R> {
|
|||||||
"enabled" => state.enabled = payload.value,
|
"enabled" => state.enabled = payload.value,
|
||||||
"shuffle" => state.shuffle = payload.value,
|
"shuffle" => state.shuffle = payload.value,
|
||||||
"lockScreenOnly" => state.lock_screen_only = payload.value,
|
"lockScreenOnly" => state.lock_screen_only = payload.value,
|
||||||
|
"allowMobileData" => state.allow_mobile_data = payload.value,
|
||||||
|
"prefetchImmich" => state.prefetch_immich = payload.value,
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
Ok(state)
|
Ok(state)
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ pub struct WallpaperState {
|
|||||||
pub interval_minutes: i32,
|
pub interval_minutes: i32,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
pub lock_screen_only: bool,
|
pub lock_screen_only: bool,
|
||||||
|
pub allow_mobile_data: bool,
|
||||||
|
pub prefetch_immich: bool,
|
||||||
pub current_index: usize,
|
pub current_index: usize,
|
||||||
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" />
|
||||||
|
|||||||
+84
-28
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Check, ChevronRight, Cloud, CloudDownload, Crop, Home, Images, Languages, Link2Off, LockKeyhole, Plus, RotateCcw, RotateCw, Save, Server, Settings, Shuffle, Smartphone, Sparkles, Trash2 } 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, 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 }) {
|
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
|
||||||
return <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
|
return <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
|
||||||
@@ -24,7 +25,7 @@ function IntervalRow({ value, onChange, label, paused, screenOn, everyMinutes }:
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [state, setState] = useState(initial);
|
const [state, setState] = useState(initial);
|
||||||
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor" | "immich">("home");
|
const [tab, setTab] = useState<Tab>("home");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [language, setLanguage] = useState<Language>(initialLanguage);
|
const [language, setLanguage] = useState<Language>(initialLanguage);
|
||||||
@@ -46,6 +47,8 @@ export default function App() {
|
|||||||
const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null);
|
const [immichImportProgress, setImmichImportProgress] = useState<ImmichImportProgress | null>(null);
|
||||||
const phonePreviewRef = useRef<HTMLDivElement>(null);
|
const phonePreviewRef = useRef<HTMLDivElement>(null);
|
||||||
const phoneImageRef = useRef<HTMLImageElement>(null);
|
const phoneImageRef = useRef<HTMLImageElement>(null);
|
||||||
|
const galleryLoadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const galleryLoadingRef = useRef(false);
|
||||||
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
|
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
|
||||||
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number; left: number; top: number; width: number; height: number; baseWidth: number; baseHeight: number }>(null);
|
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number; left: number; top: number; width: number; height: number; baseWidth: number; baseHeight: number }>(null);
|
||||||
const galleryScrollPosition = useRef(0);
|
const galleryScrollPosition = useRef(0);
|
||||||
@@ -62,6 +65,12 @@ export default function App() {
|
|||||||
window.removeEventListener("focus", refresh);
|
window.removeEventListener("focus", refresh);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
window.history.replaceState({ ...window.history.state, wallpaperFlowTab: "home" }, "");
|
||||||
|
const onPopState = (event: PopStateEvent) => setTab((event.state?.wallpaperFlowTab as Tab | undefined) ?? "home");
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getImmichConnection().then(connection => {
|
getImmichConnection().then(connection => {
|
||||||
setImmichConnection(connection);
|
setImmichConnection(connection);
|
||||||
@@ -90,30 +99,56 @@ export default function App() {
|
|||||||
const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
|
const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
|
||||||
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
|
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
useEffect(() => {
|
||||||
|
const target = galleryLoadMoreRef.current;
|
||||||
|
if (tab !== "gallery" || !target || galleryLoading || gallery.length >= galleryTotal) return;
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0]?.isIntersecting) void loadGallery(gallery.length, true);
|
||||||
|
}, { rootMargin: "300px" });
|
||||||
|
observer.observe(target);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [tab, gallery.length, galleryTotal, galleryLoading]);
|
||||||
const t = translations[language];
|
const t = translations[language];
|
||||||
const it = immichTranslations[language];
|
const it = immichTranslations[language];
|
||||||
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;
|
||||||
|
galleryLoadingRef.current = true;
|
||||||
setGalleryLoading(true);
|
setGalleryLoading(true);
|
||||||
try {
|
try {
|
||||||
const page = await getGallery(offset, 48);
|
const page = await getGallery(offset, 48);
|
||||||
setGallery(previous => append ? [...previous, ...page.items] : page.items);
|
setGallery(previous => append ? [...previous, ...page.items] : page.items);
|
||||||
setGalleryTotal(page.total);
|
setGalleryTotal(page.total);
|
||||||
} catch { setNotice(t.galleryLoadFailed); }
|
} catch { setNotice(t.galleryLoadFailed); }
|
||||||
finally { setGalleryLoading(false); }
|
finally {
|
||||||
|
galleryLoadingRef.current = false;
|
||||||
|
setGalleryLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update(name: "shuffle" | "lockScreenOnly", value: boolean) {
|
async function update(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
|
||||||
|
const previous = state[name];
|
||||||
setState(prev => ({ ...prev, [name]: value }));
|
setState(prev => ({ ...prev, [name]: value }));
|
||||||
try {
|
try {
|
||||||
const saved = await setSetting(name, value);
|
const saved = await setSetting(name, value);
|
||||||
setState(saved);
|
setState(saved);
|
||||||
setNotice(t.settingSaved);
|
setNotice(t.settingSaved);
|
||||||
} catch { setNotice(t.androidOnly); }
|
} catch (error) {
|
||||||
|
setState(prev => ({ ...prev, [name]: previous }));
|
||||||
|
setNotice(String(error).replace(/^Error:\s*/, "") || t.androidOnly);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateInterval(minutes: number) {
|
async function updateInterval(minutes: number) {
|
||||||
@@ -136,6 +171,17 @@ export default function App() {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigate(nextTab: Tab) {
|
||||||
|
if (tab === nextTab) return;
|
||||||
|
window.history.pushState({ ...window.history.state, wallpaperFlowTab: nextTab }, "");
|
||||||
|
setTab(nextTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(fallback: Tab) {
|
||||||
|
if (window.history.state?.wallpaperFlowTab === tab) window.history.back();
|
||||||
|
else setTab(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelected(id: string) {
|
function toggleSelected(id: string) {
|
||||||
setSelectedIds(previous => {
|
setSelectedIds(previous => {
|
||||||
const next = new Set(previous);
|
const next = new Set(previous);
|
||||||
@@ -167,18 +213,18 @@ export default function App() {
|
|||||||
function openEditor(image: GalleryImage) {
|
function openEditor(image: GalleryImage) {
|
||||||
galleryScrollPosition.current = window.scrollY;
|
galleryScrollPosition.current = window.scrollY;
|
||||||
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
|
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
|
||||||
setTab("editor");
|
navigate("editor");
|
||||||
}
|
}
|
||||||
|
|
||||||
function returnToGallery() {
|
function returnToGallery() {
|
||||||
restoreGalleryScroll.current = true;
|
restoreGalleryScroll.current = true;
|
||||||
setTab("gallery");
|
goBack("gallery");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGallery() {
|
function openGallery() {
|
||||||
galleryScrollPosition.current = 0;
|
galleryScrollPosition.current = 0;
|
||||||
restoreGalleryScroll.current = false;
|
restoreGalleryScroll.current = false;
|
||||||
setTab("gallery");
|
navigate("gallery");
|
||||||
void loadGallery();
|
void loadGallery();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +266,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openImmich() {
|
async function openImmich() {
|
||||||
setTab("immich");
|
navigate("immich");
|
||||||
setImmichSelected(new Set());
|
setImmichSelected(new Set());
|
||||||
setImmichLoading(true);
|
setImmichLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -253,7 +299,7 @@ export default function App() {
|
|||||||
setState(await importImmichAssets([...immichSelected]));
|
setState(await importImmichAssets([...immichSelected]));
|
||||||
setImmichSelected(new Set());
|
setImmichSelected(new Set());
|
||||||
setNotice(it.importSuccess);
|
setNotice(it.importSuccess);
|
||||||
setTab("home");
|
navigate("home");
|
||||||
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
|
} catch (error) { setNotice(String(error).replace(/^Error:\s*/, "") || it.immichLoadFailed); }
|
||||||
finally {
|
finally {
|
||||||
window.clearInterval(poll);
|
window.clearInterval(poll);
|
||||||
@@ -268,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();
|
||||||
@@ -323,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}%`;
|
||||||
@@ -349,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,
|
||||||
@@ -393,10 +444,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <main className="app-shell">
|
return <main className="app-shell">
|
||||||
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); setTab("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
|
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); goBack("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button>}</header> : tab === "editor" ?
|
||||||
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={returnToGallery}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
|
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={returnToGallery}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
|
||||||
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); setTab("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
|
tab === "immich" ? <header className="gallery-header immich-header"><button className="icon-button" aria-label={t.back} onClick={() => { setImmichSelected(new Set()); goBack("settings"); }}><ArrowLeft /></button><div><h1>{it.immich}</h1><p>{immichSelected.size ? `${immichSelected.size} ${t.selected}` : it.selectImmichPhotos}</p></div>{immichSelected.size > 0 && <button className="icon-button import-selection" aria-label={it.importSelected} onClick={importFromImmich} disabled={busy}><CloudDownload /></button>}</header> :
|
||||||
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
|
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => navigate("settings")}><Settings /></button></header>}
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
{tab === "home" ? <>
|
{tab === "home" ? <>
|
||||||
@@ -412,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>
|
||||||
|
|
||||||
@@ -425,6 +476,10 @@ export default function App() {
|
|||||||
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
|
<div className="immich-title"><span><Cloud /></span><div><h3>{it.immich}</h3><p>{it.immichIntro}</p></div></div>
|
||||||
{immichConnection.configured ? <>
|
{immichConnection.configured ? <>
|
||||||
<div className="immich-connected"><strong>{it.connectedAs} {immichConnection.userName}</strong><span>{immichConnection.serverUrl}</span></div>
|
<div className="immich-connected"><strong>{it.connectedAs} {immichConnection.userName}</strong><span>{immichConnection.serverUrl}</span></div>
|
||||||
|
<SettingRow icon={<Download />} label={it.prefetchImmich} value={state.prefetchImmich} onChange={v => update("prefetchImmich", v)} />
|
||||||
|
<p className="immich-help">{it.prefetchImmichHint}</p>
|
||||||
|
<SettingRow icon={<Wifi />} label={it.allowMobileData} value={state.allowMobileData} onChange={v => update("allowMobileData", v)} />
|
||||||
|
<p className="immich-help">{it.allowMobileDataHint}</p>
|
||||||
<button className="immich-open" onClick={openImmich} disabled={busy}><Images /> {it.openImmich} <ChevronRight /></button>
|
<button className="immich-open" onClick={openImmich} disabled={busy}><Images /> {it.openImmich} <ChevronRight /></button>
|
||||||
<button className="immich-disconnect" onClick={removeImmichConnection} disabled={busy}><Link2Off /> {it.disconnect}</button>
|
<button className="immich-disconnect" onClick={removeImmichConnection} disabled={busy}><Link2Off /> {it.disconnect}</button>
|
||||||
</> : <>
|
</> : <>
|
||||||
@@ -445,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}`} /></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>}
|
||||||
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
|
{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>
|
||||||
@@ -468,6 +524,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
|
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
|
||||||
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
|
{tab !== "gallery" && tab !== "editor" && tab !== "immich" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => navigate("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => navigate("settings")}><Settings /><span>{t.settings}</span></button></nav>}
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export type ImmichText = {
|
|||||||
importSuccess: string;
|
importSuccess: string;
|
||||||
immichLoadFailed: string;
|
immichLoadFailed: string;
|
||||||
noImmichPhotos: string;
|
noImmichPhotos: string;
|
||||||
|
allowMobileData: string;
|
||||||
|
allowMobileDataHint: string;
|
||||||
|
prefetchImmich: string;
|
||||||
|
prefetchImmichHint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const immichTranslations: Record<Language, ImmichText> = {
|
export const immichTranslations: Record<Language, ImmichText> = {
|
||||||
@@ -47,6 +51,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt",
|
importSuccess: "Immich-Bilder wurden zur Sammlung hinzugefügt",
|
||||||
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
|
immichLoadFailed: "Immich-Bilder konnten nicht geladen werden",
|
||||||
noImmichPhotos: "Keine Bilder gefunden",
|
noImmichPhotos: "Keine Bilder gefunden",
|
||||||
|
allowMobileData: "Auch über mobile Daten laden",
|
||||||
|
allowMobileDataHint: "Ist das ausgeschaltet, lädt der automatische Wechsel Immich-Bilder nur über WLAN und überspringt sie sonst. Manuelles Wechseln lädt immer.",
|
||||||
|
prefetchImmich: "Immich-Bilder immer vorladen",
|
||||||
|
prefetchImmichHint: "Lädt neue Immich-Bilder automatisch im Hintergrund herunter, sobald WLAN verfügbar ist – nie über mobile Daten. Ausgeschaltet werden Bilder nur bei Bedarf geladen.",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
immich: "Immich", immichIntro: "Import images from your own Immich server.", serverUrl: "Server URL", apiKey: "API key",
|
immich: "Immich", immichIntro: "Import images from your own Immich server.", serverUrl: "Server URL", apiKey: "API key",
|
||||||
@@ -56,6 +64,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP is unencrypted. Use HTTPS outside your home network.", allPhotos: "All photos",
|
httpWarning: "HTTP is unencrypted. Use HTTPS outside your home network.", allPhotos: "All photos",
|
||||||
selectImmichPhotos: "Select images to import", importSelected: "Import selection", importing: "Importing images…",
|
selectImmichPhotos: "Select images to import", importSelected: "Import selection", importing: "Importing images…",
|
||||||
importSuccess: "Immich images imported", immichLoadFailed: "Could not load Immich images", noImmichPhotos: "No images found",
|
importSuccess: "Immich images imported", immichLoadFailed: "Could not load Immich images", noImmichPhotos: "No images found",
|
||||||
|
allowMobileData: "Allow downloads over mobile data",
|
||||||
|
allowMobileDataHint: "When off, automatic rotation only downloads Immich images over Wi-Fi and skips them otherwise. Manually changing the wallpaper always downloads.",
|
||||||
|
prefetchImmich: "Always prefetch Immich images",
|
||||||
|
prefetchImmichHint: "Downloads new Immich images in the background as soon as Wi-Fi is available – never over mobile data. When off, images are only downloaded when needed.",
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
immich: "Immich", immichIntro: "Importez des images depuis votre propre serveur Immich.", serverUrl: "URL du serveur", apiKey: "Clé API",
|
immich: "Immich", immichIntro: "Importez des images depuis votre propre serveur Immich.", serverUrl: "URL du serveur", apiKey: "Clé API",
|
||||||
@@ -65,6 +77,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP n’est pas chiffré. Utilisez HTTPS hors de votre réseau domestique.", allPhotos: "Toutes les photos",
|
httpWarning: "HTTP n’est pas chiffré. Utilisez HTTPS hors de votre réseau domestique.", allPhotos: "Toutes les photos",
|
||||||
selectImmichPhotos: "Sélectionnez les images à importer", importSelected: "Importer la sélection", importing: "Importation des images…",
|
selectImmichPhotos: "Sélectionnez les images à importer", importSelected: "Importer la sélection", importing: "Importation des images…",
|
||||||
importSuccess: "Images Immich importées", immichLoadFailed: "Impossible de charger les images Immich", noImmichPhotos: "Aucune image trouvée",
|
importSuccess: "Images Immich importées", immichLoadFailed: "Impossible de charger les images Immich", noImmichPhotos: "Aucune image trouvée",
|
||||||
|
allowMobileData: "Autoriser les données mobiles",
|
||||||
|
allowMobileDataHint: "Désactivé, la rotation automatique ne télécharge les images Immich que via le Wi-Fi et les ignore sinon. Un changement manuel télécharge toujours.",
|
||||||
|
prefetchImmich: "Toujours précharger les images Immich",
|
||||||
|
prefetchImmichHint: "Télécharge les nouvelles images Immich en arrière-plan dès que le Wi-Fi est disponible – jamais via les données mobiles. Désactivé, les images ne sont téléchargées qu’en cas de besoin.",
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
immich: "Immich", immichIntro: "Importa imágenes desde tu propio servidor Immich.", serverUrl: "URL del servidor", apiKey: "Clave API",
|
immich: "Immich", immichIntro: "Importa imágenes desde tu propio servidor Immich.", serverUrl: "URL del servidor", apiKey: "Clave API",
|
||||||
@@ -74,6 +90,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP no está cifrado. Usa HTTPS fuera de tu red doméstica.", allPhotos: "Todas las fotos",
|
httpWarning: "HTTP no está cifrado. Usa HTTPS fuera de tu red doméstica.", allPhotos: "Todas las fotos",
|
||||||
selectImmichPhotos: "Selecciona imágenes para importar", importSelected: "Importar selección", importing: "Importando imágenes…",
|
selectImmichPhotos: "Selecciona imágenes para importar", importSelected: "Importar selección", importing: "Importando imágenes…",
|
||||||
importSuccess: "Imágenes de Immich importadas", immichLoadFailed: "No se pudieron cargar las imágenes de Immich", noImmichPhotos: "No se encontraron imágenes",
|
importSuccess: "Imágenes de Immich importadas", immichLoadFailed: "No se pudieron cargar las imágenes de Immich", noImmichPhotos: "No se encontraron imágenes",
|
||||||
|
allowMobileData: "Permitir datos móviles",
|
||||||
|
allowMobileDataHint: "Si está desactivado, la rotación automática solo descarga imágenes de Immich por Wi-Fi y las omite si no hay Wi-Fi. Cambiar manualmente siempre descarga.",
|
||||||
|
prefetchImmich: "Precargar siempre las imágenes de Immich",
|
||||||
|
prefetchImmichHint: "Descarga las nuevas imágenes de Immich en segundo plano en cuanto haya Wi-Fi disponible, nunca con datos móviles. Si está desactivado, las imágenes solo se descargan cuando se necesitan.",
|
||||||
},
|
},
|
||||||
it: {
|
it: {
|
||||||
immich: "Immich", immichIntro: "Importa immagini dal tuo server Immich.", serverUrl: "URL del server", apiKey: "Chiave API",
|
immich: "Immich", immichIntro: "Importa immagini dal tuo server Immich.", serverUrl: "URL del server", apiKey: "Chiave API",
|
||||||
@@ -83,6 +103,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP non è crittografato. Usa HTTPS fuori dalla rete domestica.", allPhotos: "Tutte le foto",
|
httpWarning: "HTTP non è crittografato. Usa HTTPS fuori dalla rete domestica.", allPhotos: "Tutte le foto",
|
||||||
selectImmichPhotos: "Seleziona le immagini da importare", importSelected: "Importa selezione", importing: "Importazione immagini…",
|
selectImmichPhotos: "Seleziona le immagini da importare", importSelected: "Importa selezione", importing: "Importazione immagini…",
|
||||||
importSuccess: "Immagini Immich importate", immichLoadFailed: "Impossibile caricare le immagini Immich", noImmichPhotos: "Nessuna immagine trovata",
|
importSuccess: "Immagini Immich importate", immichLoadFailed: "Impossibile caricare le immagini Immich", noImmichPhotos: "Nessuna immagine trovata",
|
||||||
|
allowMobileData: "Consenti dati mobili",
|
||||||
|
allowMobileDataHint: "Se disattivato, la rotazione automatica scarica le immagini Immich solo via Wi-Fi e le salta altrimenti. Il cambio manuale scarica sempre.",
|
||||||
|
prefetchImmich: "Precarica sempre le immagini Immich",
|
||||||
|
prefetchImmichHint: "Scarica le nuove immagini Immich in background non appena il Wi-Fi è disponibile, mai con i dati mobili. Se disattivato, le immagini vengono scaricate solo quando servono.",
|
||||||
},
|
},
|
||||||
nl: {
|
nl: {
|
||||||
immich: "Immich", immichIntro: "Importeer afbeeldingen van je eigen Immich-server.", serverUrl: "Server-URL", apiKey: "API-sleutel",
|
immich: "Immich", immichIntro: "Importeer afbeeldingen van je eigen Immich-server.", serverUrl: "Server-URL", apiKey: "API-sleutel",
|
||||||
@@ -92,6 +116,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP is niet versleuteld. Gebruik HTTPS buiten je thuisnetwerk.", allPhotos: "Alle foto’s",
|
httpWarning: "HTTP is niet versleuteld. Gebruik HTTPS buiten je thuisnetwerk.", allPhotos: "Alle foto’s",
|
||||||
selectImmichPhotos: "Selecteer afbeeldingen om te importeren", importSelected: "Selectie importeren", importing: "Afbeeldingen importeren…",
|
selectImmichPhotos: "Selecteer afbeeldingen om te importeren", importSelected: "Selectie importeren", importing: "Afbeeldingen importeren…",
|
||||||
importSuccess: "Immich-afbeeldingen geïmporteerd", immichLoadFailed: "Immich-afbeeldingen konden niet worden geladen", noImmichPhotos: "Geen afbeeldingen gevonden",
|
importSuccess: "Immich-afbeeldingen geïmporteerd", immichLoadFailed: "Immich-afbeeldingen konden niet worden geladen", noImmichPhotos: "Geen afbeeldingen gevonden",
|
||||||
|
allowMobileData: "Mobiele data toestaan",
|
||||||
|
allowMobileDataHint: "Indien uit, worden Immich-afbeeldingen bij automatisch wisselen alleen via wifi gedownload en anders overgeslagen. Handmatig wisselen downloadt altijd.",
|
||||||
|
prefetchImmich: "Immich-afbeeldingen altijd vooraf laden",
|
||||||
|
prefetchImmichHint: "Downloadt nieuwe Immich-afbeeldingen op de achtergrond zodra wifi beschikbaar is – nooit via mobiele data. Indien uit, worden afbeeldingen alleen bij gebruik gedownload.",
|
||||||
},
|
},
|
||||||
pl: {
|
pl: {
|
||||||
immich: "Immich", immichIntro: "Importuj obrazy z własnego serwera Immich.", serverUrl: "Adres serwera", apiKey: "Klucz API",
|
immich: "Immich", immichIntro: "Importuj obrazy z własnego serwera Immich.", serverUrl: "Adres serwera", apiKey: "Klucz API",
|
||||||
@@ -101,6 +129,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP nie jest szyfrowany. Poza siecią domową używaj HTTPS.", allPhotos: "Wszystkie zdjęcia",
|
httpWarning: "HTTP nie jest szyfrowany. Poza siecią domową używaj HTTPS.", allPhotos: "Wszystkie zdjęcia",
|
||||||
selectImmichPhotos: "Wybierz obrazy do importu", importSelected: "Importuj wybrane", importing: "Importowanie obrazów…",
|
selectImmichPhotos: "Wybierz obrazy do importu", importSelected: "Importuj wybrane", importing: "Importowanie obrazów…",
|
||||||
importSuccess: "Zaimportowano obrazy Immich", immichLoadFailed: "Nie udało się wczytać obrazów Immich", noImmichPhotos: "Nie znaleziono obrazów",
|
importSuccess: "Zaimportowano obrazy Immich", immichLoadFailed: "Nie udało się wczytać obrazów Immich", noImmichPhotos: "Nie znaleziono obrazów",
|
||||||
|
allowMobileData: "Zezwól na dane mobilne",
|
||||||
|
allowMobileDataHint: "Gdy wyłączone, automatyczna zmiana pobiera obrazy Immich tylko przez Wi-Fi, w przeciwnym razie je pomija. Ręczna zmiana zawsze pobiera.",
|
||||||
|
prefetchImmich: "Zawsze pobieraj obrazy Immich z wyprzedzeniem",
|
||||||
|
prefetchImmichHint: "Pobiera nowe obrazy Immich w tle, gdy tylko dostępne jest Wi-Fi – nigdy przez dane mobilne. Gdy wyłączone, obrazy są pobierane tylko w razie potrzeby.",
|
||||||
},
|
},
|
||||||
pt: {
|
pt: {
|
||||||
immich: "Immich", immichIntro: "Importe imagens do seu próprio servidor Immich.", serverUrl: "URL do servidor", apiKey: "Chave de API",
|
immich: "Immich", immichIntro: "Importe imagens do seu próprio servidor Immich.", serverUrl: "URL do servidor", apiKey: "Chave de API",
|
||||||
@@ -110,6 +142,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP não é encriptado. Use HTTPS fora da sua rede doméstica.", allPhotos: "Todas as fotos",
|
httpWarning: "HTTP não é encriptado. Use HTTPS fora da sua rede doméstica.", allPhotos: "Todas as fotos",
|
||||||
selectImmichPhotos: "Selecione imagens para importar", importSelected: "Importar seleção", importing: "A importar imagens…",
|
selectImmichPhotos: "Selecione imagens para importar", importSelected: "Importar seleção", importing: "A importar imagens…",
|
||||||
importSuccess: "Imagens do Immich importadas", immichLoadFailed: "Não foi possível carregar imagens do Immich", noImmichPhotos: "Nenhuma imagem encontrada",
|
importSuccess: "Imagens do Immich importadas", immichLoadFailed: "Não foi possível carregar imagens do Immich", noImmichPhotos: "Nenhuma imagem encontrada",
|
||||||
|
allowMobileData: "Permitir dados móveis",
|
||||||
|
allowMobileDataHint: "Quando desativado, a rotação automática só transfere imagens do Immich por Wi-Fi e ignora-as caso contrário. A troca manual transfere sempre.",
|
||||||
|
prefetchImmich: "Pré-carregar sempre imagens do Immich",
|
||||||
|
prefetchImmichHint: "Transfere novas imagens do Immich em segundo plano assim que o Wi-Fi estiver disponível – nunca com dados móveis. Quando desativado, as imagens só são transferidas quando necessário.",
|
||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー",
|
immich: "Immich", immichIntro: "自分の Immich サーバーから画像を取り込みます。", serverUrl: "サーバー URL", apiKey: "API キー",
|
||||||
@@ -119,6 +155,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真",
|
httpWarning: "HTTP は暗号化されません。自宅ネットワーク外では HTTPS を使用してください。", allPhotos: "すべての写真",
|
||||||
selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…",
|
selectImmichPhotos: "取り込む画像を選択", importSelected: "選択項目を取り込む", importing: "画像を取り込み中…",
|
||||||
importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません",
|
importSuccess: "Immich の画像を取り込みました", immichLoadFailed: "Immich の画像を読み込めません", noImmichPhotos: "画像が見つかりません",
|
||||||
|
allowMobileData: "モバイルデータでのダウンロードを許可",
|
||||||
|
allowMobileDataHint: "オフの場合、自動切り替えは Wi-Fi 接続時のみ Immich の画像をダウンロードし、それ以外はスキップします。手動での切り替えは常にダウンロードします。",
|
||||||
|
prefetchImmich: "Immich画像を常に先読みする",
|
||||||
|
prefetchImmichHint: "Wi-Fi が利用可能になり次第、新しい Immich 画像をバックグラウンドでダウンロードします(モバイルデータは使用しません)。オフの場合、画像は必要なときのみダウンロードされます。",
|
||||||
},
|
},
|
||||||
ko: {
|
ko: {
|
||||||
immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키",
|
immich: "Immich", immichIntro: "내 Immich 서버에서 이미지를 가져옵니다.", serverUrl: "서버 URL", apiKey: "API 키",
|
||||||
@@ -128,6 +168,10 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진",
|
httpWarning: "HTTP는 암호화되지 않습니다. 홈 네트워크 밖에서는 HTTPS를 사용하세요.", allPhotos: "모든 사진",
|
||||||
selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…",
|
selectImmichPhotos: "가져올 이미지를 선택하세요", importSelected: "선택 항목 가져오기", importing: "이미지 가져오는 중…",
|
||||||
importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다",
|
importSuccess: "Immich 이미지를 가져왔습니다", immichLoadFailed: "Immich 이미지를 불러올 수 없습니다", noImmichPhotos: "이미지를 찾을 수 없습니다",
|
||||||
|
allowMobileData: "모바일 데이터 다운로드 허용",
|
||||||
|
allowMobileDataHint: "꺼져 있으면 자동 변경 시 Wi-Fi에서만 Immich 이미지를 다운로드하고 그렇지 않으면 건너뜁니다. 수동 변경은 항상 다운로드합니다.",
|
||||||
|
prefetchImmich: "Immich 이미지 항상 미리 다운로드",
|
||||||
|
prefetchImmichHint: "Wi-Fi를 사용할 수 있게 되면 새 Immich 이미지를 백그라운드에서 다운로드합니다(모바일 데이터는 사용하지 않음). 꺼져 있으면 필요할 때만 이미지를 다운로드합니다.",
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥",
|
immich: "Immich", immichIntro: "从你自己的 Immich 服务器导入图片。", serverUrl: "服务器 URL", apiKey: "API 密钥",
|
||||||
@@ -137,5 +181,9 @@ export const immichTranslations: Record<Language, ImmichText> = {
|
|||||||
httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片",
|
httpWarning: "HTTP 未加密。在家庭网络之外请使用 HTTPS。", allPhotos: "所有照片",
|
||||||
selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…",
|
selectImmichPhotos: "选择要导入的图片", importSelected: "导入所选图片", importing: "正在导入图片…",
|
||||||
importSuccess: "已导入 Immich 图片", immichLoadFailed: "无法加载 Immich 图片", noImmichPhotos: "未找到图片",
|
importSuccess: "已导入 Immich 图片", immichLoadFailed: "无法加载 Immich 图片", noImmichPhotos: "未找到图片",
|
||||||
|
allowMobileData: "允许使用移动数据下载",
|
||||||
|
allowMobileDataHint: "关闭时,自动更换仅在 Wi-Fi 下下载 Immich 图片,否则将跳过;手动更换始终会下载。",
|
||||||
|
prefetchImmich: "始终预下载 Immich 图片",
|
||||||
|
prefetchImmichHint: "只要有 Wi-Fi 可用,就会在后台下载新的 Immich 图片——绝不使用移动数据。关闭时,图片仅在需要时才会下载。",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-12
@@ -1,26 +1,32 @@
|
|||||||
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;
|
||||||
intervalMinutes: number;
|
intervalMinutes: number;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
lockScreenOnly: boolean;
|
lockScreenOnly: boolean;
|
||||||
|
allowMobileData: boolean;
|
||||||
|
prefetchImmich: boolean;
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
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 = {
|
||||||
@@ -63,15 +69,25 @@ 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,
|
||||||
lockScreenOnly: true,
|
lockScreenOnly: true,
|
||||||
|
allowMobileData: false,
|
||||||
|
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;
|
||||||
@@ -79,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;
|
||||||
}
|
}
|
||||||
@@ -145,10 +166,11 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSetting(name: "shuffle" | "lockScreenOnly", value: boolean) {
|
export async function setSetting(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
|
||||||
if (!inTauri()) return { ...demoState, [name]: value };
|
if (!inTauri()) return { ...demoState, [name]: value };
|
||||||
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
|
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
|
||||||
}
|
}
|
||||||
@@ -166,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");
|
||||||
@@ -177,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 });
|
||||||
|
|||||||
+15
-5
@@ -53,13 +53,13 @@ header p { margin: 0; font-size: 13px; color: #657068; font-weight: 500; }
|
|||||||
h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
|
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; }
|
||||||
@@ -149,6 +158,7 @@ nav svg { width: 21px; }
|
|||||||
.gallery-empty p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; }
|
.gallery-empty p { max-width: 290px; margin: 8px 0 2px; font-size: 13px; line-height: 1.5; }
|
||||||
.gallery-empty .primary { max-width: 260px; }
|
.gallery-empty .primary { max-width: 260px; }
|
||||||
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; }
|
.gallery-status { padding: 28px 0; text-align: center; color: #6d776f; font-size: 13px; }
|
||||||
|
.gallery-load-sentinel { height: 1px; }
|
||||||
.load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; }
|
.load-more { width: 100%; height: 48px; margin-top: 18px; border: 1px solid #b9c8bb; border-radius: 15px; color: var(--green); background: white; font-weight: 750; }
|
||||||
.save-crop { background: var(--green); color: white; }
|
.save-crop { background: var(--green); color: white; }
|
||||||
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }
|
.crop-editor { padding-bottom: max(28px, env(safe-area-inset-bottom)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user