feat(android): add Android release workflow and shuffle UI improvements
Android Release / Build signed release APK (push) Canceled after 40m19s
Android Release / Build signed release APK (push) Canceled after 40m19s
Adds a new Android release workflow to sign and publish APKs when tags are pushed. Also enhances wallpaper shuffle by tracking seen IDs to reduce repeats. UI changes in App.tsx introduce lazy loading for the gallery and improved navigation/history handling. - Add Android signing workflow with keystore secrets - Track shuffle seen IDs to avoid image repeats - Enable gallery lazy loading via sentinel and observer
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
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
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
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: Check release tag and app version
|
||||||
|
if: gitea.ref_type == 'tag'
|
||||||
|
run: |
|
||||||
|
APP_VERSION="$(node -p "require('./src-tauri/tauri.conf.json').version")"
|
||||||
|
if [ "${GITHUB_REF_NAME}" != "v${APP_VERSION}" ]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} does not match app version v${APP_VERSION}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate signing secrets
|
||||||
|
run: |
|
||||||
|
for SECRET_NAME in \
|
||||||
|
ANDROID_KEYSTORE_BASE64 \
|
||||||
|
ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
ANDROID_KEY_ALIAS \
|
||||||
|
ANDROID_KEY_PASSWORD
|
||||||
|
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: 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: Upload release APK
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: WallpaperFlow-${{ gitea.ref_name }}-android
|
||||||
|
path: |
|
||||||
|
release/WallpaperFlow-release.apk
|
||||||
|
release/WallpaperFlow-release.apk.sha256
|
||||||
|
if-no-files-found: error
|
||||||
@@ -47,6 +47,36 @@ 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 `v0.1.0`
|
||||||
|
eine signierte Universal-APK. Der Tag muss der Version in
|
||||||
|
`src-tauri/tauri.conf.json` mit vorangestelltem `v` entsprechen. Der Workflow
|
||||||
|
kann außerdem manuell über die Actions-Oberfläche gestartet werden.
|
||||||
|
|
||||||
|
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 stehen `WallpaperFlow-release.apk` und die zugehörige
|
||||||
|
SHA-256-Prüfsumme als Artefakt des Workflows bereit. Der Keystore darf nicht in
|
||||||
|
das Repository eingecheckt werden und muss dauerhaft gesichert bleiben, da
|
||||||
|
spätere Updates mit demselben Schlüssel signiert werden müssen.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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 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_CACHE_MAX_BYTES = 128L * 1024 * 1024
|
private const val RENDER_CACHE_MAX_BYTES = 128L * 1024 * 1024
|
||||||
@@ -107,7 +108,9 @@ object WallpaperStore {
|
|||||||
|
|
||||||
fun set(context: Context, name: String, value: Boolean) {
|
fun set(context: Context, name: String, value: Boolean) {
|
||||||
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
|
require(name in setOf("shuffle", "lockScreenOnly", "allowMobileData", "prefetchImmich")) { "Unbekannte Einstellung" }
|
||||||
prefs(context).edit().putBoolean(name, value).apply()
|
val editor = prefs(context).edit().putBoolean(name, value)
|
||||||
|
if (name == "shuffle") editor.remove(KEY_SHUFFLE_SEEN_IDS)
|
||||||
|
editor.apply()
|
||||||
if (name == "prefetchImmich") {
|
if (name == "prefetchImmich") {
|
||||||
if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context)
|
if (value && ImmichClient.configured(context)) ImmichPrefetchWorker.enqueue(context)
|
||||||
else if (!value) ImmichPrefetchWorker.cancel(context)
|
else if (!value) ImmichPrefetchWorker.cancel(context)
|
||||||
@@ -390,9 +393,21 @@ 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 availableIds = items.mapTo(mutableSetOf()) { it.id }
|
||||||
} else (1..items.size).map { (previous + it).mod(items.size) }
|
val seenIds = prefs(context).getStringSet(KEY_SHUFFLE_SEEN_IDS, emptySet())
|
||||||
|
.orEmpty().filterTo(mutableSetOf()) { it in availableIds }
|
||||||
|
seenIds.add(items[previous].id)
|
||||||
|
|
||||||
|
var candidates = items.indices.filter { items[it].id !in seenIds }.shuffled()
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
seenIds.clear()
|
||||||
|
seenIds.add(items[previous].id)
|
||||||
|
candidates = items.indices.filter { it != previous }.shuffled()
|
||||||
|
}
|
||||||
|
return applyCandidates(context, items, candidates, automatic, seenIds)
|
||||||
|
}
|
||||||
|
val candidates = (1..items.size).map { (previous + it).mod(items.size) }
|
||||||
return applyCandidates(context, items, candidates, automatic)
|
return applyCandidates(context, items, candidates, automatic)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,10 +416,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]
|
||||||
@@ -447,7 +473,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.
|
||||||
|
|||||||
+47
-13
@@ -5,6 +5,7 @@ import { initialLanguage, languageNames, languages, translations, type Language
|
|||||||
import { immichTranslations } from "./immich-i18n";
|
import { immichTranslations } from "./immich-i18n";
|
||||||
|
|
||||||
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
|
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, allowMobileData: false, prefetchImmich: true, currentIndex: 0, currentId: null, imageIds: [], imageUrls: [] };
|
||||||
|
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,6 +99,15 @@ 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]);
|
||||||
@@ -98,13 +116,18 @@ export default function App() {
|
|||||||
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
|
const photos = useMemo(() => state.imageUrls, [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" | "allowMobileData" | "prefetchImmich", value: boolean) {
|
async function update(name: "shuffle" | "lockScreenOnly" | "allowMobileData" | "prefetchImmich", value: boolean) {
|
||||||
@@ -140,6 +163,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);
|
||||||
@@ -171,18 +205,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +258,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 {
|
||||||
@@ -257,7 +291,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);
|
||||||
@@ -397,10 +431,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" ? <>
|
||||||
@@ -455,9 +489,9 @@ export default function App() {
|
|||||||
</section> : tab === "gallery" ?
|
</section> : tab === "gallery" ?
|
||||||
<section className="gallery-page" aria-label={t.myImages}>
|
<section className="gallery-page" 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">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} loading="lazy" decoding="async" /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
|
||||||
{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" />}
|
||||||
</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>
|
||||||
@@ -476,6 +510,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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,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