feat(hobbs-organizer): v1.3.0 photo gallery + Shield photo app 1.1, slideshow 15s
- /photo/gallery: D-pad grid, OK opens full screen, arrows browse, Back returns - 400px gallery tiles cached per photo (sync + uploads) - com.dayline.photos 1.1: launcher tile opens the gallery, takes remote input, Back steps through WebView history; screensaver unchanged - slideshow interval 20s -> 15s (live settings) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
@@ -68,7 +68,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
|
|||||||
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
|
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
|
||||||
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
|
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
|
||||||
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
|
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
|
||||||
├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.2.0` since 2026-11-04 evening; `1.0.0`/`1.1.0` kept for rollback; Watchtower disabled). **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **Shield photo app `com.dayline.photos` v1.0** (2026-11-04, separate APK: launcher tile + screensaver/DreamService, both load `/photo`; default URL baked in, override via `files/base_url` with `run-as`). Built on .100 by `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` (JDK17 container, SDK cached in volume `hobbs-android-sdk`). **Signing key** `/var/lib/hobbs-organizer/apk-signing/` (root 600) + NAS copy `/mnt/docker-data/configs/hobbs-organizer/apk-signing/` -- losing it means uninstall/reinstall for any update. Installed on **both Shields** (.186 canary, then .179 on 2026-11-04 after the user confirmed .186) and set as the screensaver on both (previous on each: `com.google.android.backdrop/.Backdrop`, saved in `~/hobbs-shield/screensaver-before-{186,179}.txt` on .100; rollback = `adb shell settings put secure screensaver_components <that value>`). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`.
|
├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.3.0` since 2026-11-04 night; `1.0.0`/`1.1.0`/`1.2.0` kept for rollback; Watchtower disabled). **v1.3.0 adds `/photo/gallery`** (remote-driven grid: arrows move, OK opens full screen, ◀ ▶ browse, Back returns; Slideshow button; idle 10 min -> slideshow) and a 400px tile per photo (`<key>.m.jpg`). Slideshow interval set to **15s** (was 20s) per user. **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **Shield photo app `com.dayline.photos` v1.1** (2026-11-04, separate APK: launcher tile opens `/photo/gallery` and takes D-pad input, Back steps back through WebView history; screensaver/DreamService loads `/photo`, non-interactive; default URL baked in, override via `files/base_url` with `run-as`). Built on .100 by `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` (JDK17 container, SDK cached in volume `hobbs-android-sdk`). **Signing key** `/var/lib/hobbs-organizer/apk-signing/` (root 600) + NAS copy `/mnt/docker-data/configs/hobbs-organizer/apk-signing/` -- losing it means uninstall/reinstall for any update. Installed on **both Shields** (.186 canary, then .179 on 2026-11-04 after the user confirmed .186) and set as the screensaver on both (previous on each: `com.google.android.backdrop/.Backdrop`, saved in `~/hobbs-shield/screensaver-before-{186,179}.txt` on .100; rollback = `adb shell settings put secure screensaver_components <that value>`). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`.
|
||||||
├── [COMPOSE] Farm Agent (:8889)
|
├── [COMPOSE] Farm Agent (:8889)
|
||||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
||||||
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
# 1.3.0 (2026-11-04)
|
||||||
|
|
||||||
|
- `/photo/gallery`: a remote-driven photo grid for the Shield photo app's launcher tile.
|
||||||
|
- Arrows move between tiles, OK opens a photo full screen with a zoom-in, and ◀ ▶ browse while it is open.
|
||||||
|
- Back (or OK) returns to the grid, and a **Slideshow** button at the top starts `/photo`.
|
||||||
|
- Photos are shown newest first.
|
||||||
|
- After 10 minutes without a key press, the slideshow starts on its own. The app keeps the screen on, so the TV screensaver never would.
|
||||||
|
- Sync now also caches a 400px gallery tile (`<key>.m.jpg`) per photo. Existing albums backfill on their next sync.
|
||||||
|
- Admin uploads send a tile too. Older uploads without one use the full photo in the grid.
|
||||||
|
- `/api/photos` entries gain a `tile` field.
|
||||||
|
- Shield photo app `com.dayline.photos` 1.1 (versionCode 2):
|
||||||
|
- The launcher tile opens the gallery and takes D-pad input.
|
||||||
|
- Back steps back through the gallery's history (closing a full-screen photo) before exiting.
|
||||||
|
- The screensaver is unchanged (non-interactive slideshow).
|
||||||
|
|
||||||
# 1.2.0 (2026-11-04)
|
# 1.2.0 (2026-11-04)
|
||||||
|
|
||||||
- `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken.
|
- `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.2.0
|
1.3.0
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ export const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|||||||
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
|
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
|
||||||
const PHOTO_EDGE = 1920;
|
const PHOTO_EDGE = 1920;
|
||||||
const THUMB_EDGE = 64;
|
const THUMB_EDGE = 64;
|
||||||
|
/** Gallery tile (the Shield app's grid view). */
|
||||||
|
const TILE_EDGE = 400;
|
||||||
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
|
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
|
||||||
const VIDEO_MARKER = "76647426";
|
const VIDEO_MARKER = "76647426";
|
||||||
|
|
||||||
export type CachedPhoto = { key: string; takenAt: number | null; width: number; height: number };
|
export type CachedPhoto = { key: string; takenAt: number | null; width: number; height: number; tile?: boolean };
|
||||||
export type AlbumIndex = {
|
export type AlbumIndex = {
|
||||||
title: string;
|
title: string;
|
||||||
firstAttemptAt: number;
|
firstAttemptAt: number;
|
||||||
@@ -40,7 +42,7 @@ export function validPhotoDir(name: string) {
|
|||||||
return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name);
|
return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name);
|
||||||
}
|
}
|
||||||
export function validPhotoFile(name: string) {
|
export function validPhotoFile(name: string) {
|
||||||
return /^[a-f0-9]{20}(\.t)?\.jpg$/.test(name);
|
return /^[a-f0-9]{20}(\.t|\.m)?\.jpg$/.test(name);
|
||||||
}
|
}
|
||||||
export const photoFilePath = (dir: string, file: string) => path.join(dirFor(dir), file);
|
export const photoFilePath = (dir: string, file: string) => path.join(dirFor(dir), file);
|
||||||
|
|
||||||
@@ -222,12 +224,22 @@ export async function syncAlbum(album: PhotoAlbum) {
|
|||||||
try {
|
try {
|
||||||
if (!existing.has(`${key}.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.jpg`), await downloadImage(item.baseUrl, PHOTO_EDGE, 15 * 1024 * 1024));
|
if (!existing.has(`${key}.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.jpg`), await downloadImage(item.baseUrl, PHOTO_EDGE, 15 * 1024 * 1024));
|
||||||
if (!existing.has(`${key}.t.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.t.jpg`), await downloadImage(item.baseUrl, THUMB_EDGE, 512 * 1024));
|
if (!existing.has(`${key}.t.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.t.jpg`), await downloadImage(item.baseUrl, THUMB_EDGE, 512 * 1024));
|
||||||
photos.push({ key, takenAt: item.takenAt, width: item.width, height: item.height });
|
// The tile is optional: the gallery falls back to the full photo if it can't be fetched.
|
||||||
|
let tile = existing.has(`${key}.m.jpg`);
|
||||||
|
if (!tile) {
|
||||||
|
try {
|
||||||
|
await writeFileAtomic(photoFilePath(album.id, `${key}.m.jpg`), await downloadImage(item.baseUrl, TILE_EDGE, 2 * 1024 * 1024));
|
||||||
|
tile = true;
|
||||||
|
} catch {
|
||||||
|
tile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photos.push({ key, takenAt: item.takenAt, width: item.width, height: item.height, tile });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(error instanceof Error ? error.message : String(error));
|
failures.push(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const keep = new Set(photos.flatMap((photo) => [`${photo.key}.jpg`, `${photo.key}.t.jpg`]));
|
const keep = new Set(photos.flatMap((photo) => [`${photo.key}.jpg`, `${photo.key}.t.jpg`, `${photo.key}.m.jpg`]));
|
||||||
for (const file of existing) if (!keep.has(file)) await unlink(photoFilePath(album.id, file)).catch(() => undefined);
|
for (const file of existing) if (!keep.has(file)) await unlink(photoFilePath(album.id, file)).catch(() => undefined);
|
||||||
|
|
||||||
const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position]));
|
const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position]));
|
||||||
@@ -296,7 +308,7 @@ function serializedUploads<T>(task: () => Promise<T>) {
|
|||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addUpload(photo: Uint8Array, thumb: Uint8Array, meta: { takenAt: number | null; width: number; height: number }) {
|
export function addUpload(photo: Uint8Array, thumb: Uint8Array, tile: Uint8Array | null, meta: { takenAt: number | null; width: number; height: number }) {
|
||||||
return serializedUploads(async () => {
|
return serializedUploads(async () => {
|
||||||
const index = await readIndex(UPLOADS_DIR);
|
const index = await readIndex(UPLOADS_DIR);
|
||||||
if (index.photos.length >= MAX_UPLOADS) throw new Error(`At most ${MAX_UPLOADS} uploaded photos`);
|
if (index.photos.length >= MAX_UPLOADS) throw new Error(`At most ${MAX_UPLOADS} uploaded photos`);
|
||||||
@@ -305,7 +317,8 @@ export function addUpload(photo: Uint8Array, thumb: Uint8Array, meta: { takenAt:
|
|||||||
await mkdir(dirFor(UPLOADS_DIR), { recursive: true, mode: 0o700 });
|
await mkdir(dirFor(UPLOADS_DIR), { recursive: true, mode: 0o700 });
|
||||||
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo);
|
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo);
|
||||||
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.t.jpg`), thumb);
|
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.t.jpg`), thumb);
|
||||||
index.photos.push({ key, ...meta });
|
if (tile) await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.m.jpg`), tile);
|
||||||
|
index.photos.push({ key, ...meta, tile: Boolean(tile) });
|
||||||
index.title = "Uploaded photos";
|
index.title = "Uploaded photos";
|
||||||
index.lastSuccessAt = Date.now();
|
index.lastSuccessAt = Date.now();
|
||||||
await writeIndex(UPLOADS_DIR, index);
|
await writeIndex(UPLOADS_DIR, index);
|
||||||
@@ -321,14 +334,14 @@ export function removeUpload(key: string) {
|
|||||||
index.photos = index.photos.filter((entry) => entry.key !== key);
|
index.photos = index.photos.filter((entry) => entry.key !== key);
|
||||||
if (index.photos.length === before) return false;
|
if (index.photos.length === before) return false;
|
||||||
await writeIndex(UPLOADS_DIR, index);
|
await writeIndex(UPLOADS_DIR, index);
|
||||||
for (const file of [`${key}.jpg`, `${key}.t.jpg`]) await unlink(photoFilePath(UPLOADS_DIR, file)).catch(() => undefined);
|
for (const file of [`${key}.jpg`, `${key}.t.jpg`, `${key}.m.jpg`]) await unlink(photoFilePath(UPLOADS_DIR, file)).catch(() => undefined);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- what the slideshow shows ----------
|
// ---------- what the slideshow shows ----------
|
||||||
|
|
||||||
export type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number };
|
export type SlidePhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number };
|
||||||
|
|
||||||
export async function currentSlideshow() {
|
export async function currentSlideshow() {
|
||||||
const settings = await readSettings();
|
const settings = await readSettings();
|
||||||
@@ -339,11 +352,12 @@ export async function currentSlideshow() {
|
|||||||
const fingerprint = createHash("sha1").update(settings.updatedAt);
|
const fingerprint = createHash("sha1").update(settings.updatedAt);
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
const index = await readIndex(source);
|
const index = await readIndex(source);
|
||||||
fingerprint.update(`${source}:${index.lastSuccessAt}:${index.photos.length}`);
|
fingerprint.update(`${source}:${index.lastSuccessAt}:${index.photos.length}:${index.photos.filter((photo) => photo.tile).length}`);
|
||||||
for (const photo of index.photos) {
|
for (const photo of index.photos) {
|
||||||
photos.push({
|
photos.push({
|
||||||
src: `/api/photos/img/${source}/${photo.key}.jpg`,
|
src: `/api/photos/img/${source}/${photo.key}.jpg`,
|
||||||
thumb: `/api/photos/img/${source}/${photo.key}.t.jpg`,
|
thumb: `/api/photos/img/${source}/${photo.key}.t.jpg`,
|
||||||
|
tile: photo.tile ? `/api/photos/img/${source}/${photo.key}.m.jpg` : `/api/photos/img/${source}/${photo.key}.jpg`,
|
||||||
takenAt: photo.takenAt,
|
takenAt: photo.takenAt,
|
||||||
width: photo.width,
|
width: photo.width,
|
||||||
height: photo.height,
|
height: photo.height,
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ function ago(timestamp: number | null) {
|
|||||||
return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
|
return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resize in the browser to a 1920px JPEG and a 64px thumbnail, so the server needs no image library. */
|
/** Resize in the browser to a 1920px JPEG, a 400px gallery tile and a 64px thumbnail, so the server needs no image library. */
|
||||||
async function resizeForUpload(file: File) {
|
async function resizeForUpload(file: File) {
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
const encode = async (edge: number, quality: number) => {
|
const encode = async (edge: number, quality: number) => {
|
||||||
@@ -395,9 +395,10 @@ async function resizeForUpload(file: File) {
|
|||||||
return { blob, width: canvas.width, height: canvas.height };
|
return { blob, width: canvas.width, height: canvas.height };
|
||||||
};
|
};
|
||||||
const photo = await encode(1920, 0.88);
|
const photo = await encode(1920, 0.88);
|
||||||
|
const tile = await encode(400, 0.82);
|
||||||
const thumb = await encode(64, 0.7);
|
const thumb = await encode(64, 0.7);
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
return { photo, thumb };
|
return { photo, tile, thumb };
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhotosTab() {
|
function PhotosTab() {
|
||||||
@@ -500,10 +501,11 @@ function PhotosTab() {
|
|||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
setUploading(`Uploading ${done + failed + 1} of ${files.length}…`);
|
setUploading(`Uploading ${done + failed + 1} of ${files.length}…`);
|
||||||
try {
|
try {
|
||||||
const { photo, thumb } = await resizeForUpload(file);
|
const { photo, tile, thumb } = await resizeForUpload(file);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("photo", photo.blob, "photo.jpg");
|
form.append("photo", photo.blob, "photo.jpg");
|
||||||
form.append("thumb", thumb.blob, "thumb.jpg");
|
form.append("thumb", thumb.blob, "thumb.jpg");
|
||||||
|
form.append("tile", tile.blob, "tile.jpg");
|
||||||
form.append("width", String(photo.width));
|
form.append("width", String(photo.width));
|
||||||
form.append("height", String(photo.height));
|
form.append("height", String(photo.height));
|
||||||
form.append("takenAt", String(file.lastModified || ""));
|
form.append("takenAt", String(file.lastModified || ""));
|
||||||
|
|||||||
+10
-2
@@ -4,7 +4,7 @@ import { addUpload, isJpeg, removeUpload } from "../../../../_lib/photos";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin page resizes photos in the browser (max 1920px JPEG plus a 64px thumbnail)
|
* The admin page resizes photos in the browser (max 1920px JPEG, a 400px gallery tile and a 64px thumbnail)
|
||||||
* before sending them, so the server only validates and stores.
|
* before sending them, so the server only validates and stores.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -23,9 +23,17 @@ export async function POST(request: Request) {
|
|||||||
const photoBytes = new Uint8Array(await photo.arrayBuffer());
|
const photoBytes = new Uint8Array(await photo.arrayBuffer());
|
||||||
const thumbBytes = new Uint8Array(await thumb.arrayBuffer());
|
const thumbBytes = new Uint8Array(await thumb.arrayBuffer());
|
||||||
if (!isJpeg(photoBytes) || !isJpeg(thumbBytes)) return json({ error: "Photos must be JPEG." }, 415);
|
if (!isJpeg(photoBytes) || !isJpeg(thumbBytes)) return json({ error: "Photos must be JPEG." }, 415);
|
||||||
|
// The gallery tile is optional (older admin pages don't send one); the gallery then uses the full photo.
|
||||||
|
const tile = form.get("tile");
|
||||||
|
let tileBytes: Uint8Array | null = null;
|
||||||
|
if (tile instanceof Blob) {
|
||||||
|
if (tile.size > 1024 * 1024) return json({ error: "Photo is too large." }, 413);
|
||||||
|
tileBytes = new Uint8Array(await tile.arrayBuffer());
|
||||||
|
if (!isJpeg(tileBytes)) return json({ error: "Photos must be JPEG." }, 415);
|
||||||
|
}
|
||||||
const takenAt = Number(form.get("takenAt"));
|
const takenAt = Number(form.get("takenAt"));
|
||||||
try {
|
try {
|
||||||
const result = await addUpload(photoBytes, thumbBytes, {
|
const result = await addUpload(photoBytes, thumbBytes, tileBytes, {
|
||||||
takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null,
|
takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null,
|
||||||
width: Math.max(0, Math.round(Number(form.get("width")) || 0)),
|
width: Math.max(0, Math.round(Number(form.get("width")) || 0)),
|
||||||
height: Math.max(0, Math.round(Number(form.get("height")) || 0)),
|
height: Math.max(0, Math.round(Number(form.get("height")) || 0)),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
status: "ok",
|
status: "ok",
|
||||||
service: "hobbs-family-organizer",
|
service: "hobbs-family-organizer",
|
||||||
version: "1.2.0",
|
version: "1.3.0",
|
||||||
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
|
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
|
||||||
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
|
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
|
||||||
adminConfigured,
|
adminConfigured,
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gallery for the Shield photo app's launcher tile: a grid of tiles driven by the remote's
|
||||||
|
* D-pad (arrow keys), OK (Enter) opens a photo full screen, Back returns to the grid.
|
||||||
|
* Android's Back key never reaches the page; the app maps it to WebView history, so opening
|
||||||
|
* a photo pushes a history entry and closing it pops that entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GalleryPhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number };
|
||||||
|
type Payload = { photos: GalleryPhoto[]; version: string };
|
||||||
|
|
||||||
|
const REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
/** With no key press for this long, start the slideshow (the app keeps the screen on). */
|
||||||
|
const IDLE_SLIDESHOW_MS = 10 * 60 * 1000;
|
||||||
|
const TILE_TARGET_PX = 300;
|
||||||
|
const SLIDESHOW = -1;
|
||||||
|
const TIME_ZONE = "America/Chicago";
|
||||||
|
|
||||||
|
function formatTaken(takenAt: number | null) {
|
||||||
|
if (!takenAt) return "";
|
||||||
|
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first; photos without a date keep their album order at the end. */
|
||||||
|
function sortPhotos(photos: GalleryPhoto[]) {
|
||||||
|
return photos
|
||||||
|
.map((photo, position) => ({ photo, position }))
|
||||||
|
.sort((left, right) => (right.photo.takenAt ?? -1) - (left.photo.takenAt ?? -1) || left.position - right.position)
|
||||||
|
.map((entry) => entry.photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useColumns() {
|
||||||
|
const [columns, setColumns] = useState(6);
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setColumns(Math.max(2, Math.min(8, Math.round(window.innerWidth / TILE_TARGET_PX))));
|
||||||
|
update();
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
return () => window.removeEventListener("resize", update);
|
||||||
|
}, []);
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhotoGallery() {
|
||||||
|
const [photos, setPhotos] = useState<GalleryPhoto[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [focus, setFocus] = useState(0);
|
||||||
|
const [viewer, setViewer] = useState<number | null>(null);
|
||||||
|
const columns = useColumns();
|
||||||
|
const versionRef = useRef("");
|
||||||
|
const focusSrc = useRef("");
|
||||||
|
const tileRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const slideshowRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const viewerOpenedHere = useRef(false);
|
||||||
|
|
||||||
|
// Load (and occasionally refresh) the list, keeping focus on the same photo.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/photos", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
|
const next = (await response.json()) as Payload;
|
||||||
|
if (cancelled) return;
|
||||||
|
setFailed(false);
|
||||||
|
if (next.version === versionRef.current) return;
|
||||||
|
versionRef.current = next.version;
|
||||||
|
const sorted = sortPhotos(next.photos);
|
||||||
|
setPhotos(sorted);
|
||||||
|
const kept = sorted.findIndex((photo) => photo.src === focusSrc.current);
|
||||||
|
setFocus((current) => (current === SLIDESHOW ? SLIDESHOW : kept >= 0 ? kept : Math.min(current, Math.max(0, sorted.length - 1))));
|
||||||
|
setViewer((current) => (current === null ? null : kept >= 0 ? kept : null));
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
const timer = window.setInterval(load, REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const count = photos?.length ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusSrc.current = photos && focus >= 0 ? (photos[focus]?.src ?? "") : "";
|
||||||
|
}, [photos, focus]);
|
||||||
|
|
||||||
|
// Keep the focused tile on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewer !== null) return;
|
||||||
|
// On the top row (or the Slideshow button) show the whole header, not just the focused item.
|
||||||
|
if (focus < columns) {
|
||||||
|
scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = focus === SLIDESHOW ? slideshowRef.current : tileRefs.current[focus];
|
||||||
|
element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}, [focus, viewer, columns]);
|
||||||
|
|
||||||
|
const openViewer = useCallback((index: number) => {
|
||||||
|
window.history.pushState({ hfoViewer: true }, "");
|
||||||
|
viewerOpenedHere.current = true;
|
||||||
|
setViewer(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeViewer = useCallback(() => {
|
||||||
|
if (viewerOpenedHere.current) window.history.back();
|
||||||
|
else setViewer(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Back (the app turns the remote's Back into history.back()) closes the viewer.
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => {
|
||||||
|
viewerOpenedHere.current = false;
|
||||||
|
setViewer((current) => {
|
||||||
|
if (current !== null) setFocus(current);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startSlideshow = useCallback(() => {
|
||||||
|
// A full page load on purpose: the slideshow is a separate page with its own 12h self-reload,
|
||||||
|
// and the Shield app's Back key then returns here through WebView history.
|
||||||
|
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
|
||||||
|
window.location.assign("/photo");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Idle -> slideshow.
|
||||||
|
const idleTimer = useRef<number | undefined>(undefined);
|
||||||
|
const resetIdle = useCallback(() => {
|
||||||
|
window.clearTimeout(idleTimer.current);
|
||||||
|
idleTimer.current = window.setTimeout(startSlideshow, IDLE_SLIDESHOW_MS);
|
||||||
|
}, [startSlideshow]);
|
||||||
|
useEffect(() => {
|
||||||
|
resetIdle();
|
||||||
|
return () => window.clearTimeout(idleTimer.current);
|
||||||
|
}, [resetIdle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
resetIdle();
|
||||||
|
const key = event.key;
|
||||||
|
const handled = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", " ", "Escape", "Backspace"].includes(key);
|
||||||
|
if (!handled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!count && key !== "Enter") return;
|
||||||
|
|
||||||
|
if (viewer !== null) {
|
||||||
|
if (key === "ArrowRight") setViewer((viewer + 1) % count);
|
||||||
|
else if (key === "ArrowLeft") setViewer((viewer - 1 + count) % count);
|
||||||
|
else if (key === "Enter" || key === " " || key === "Escape" || key === "Backspace") closeViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Enter" || key === " ") {
|
||||||
|
if (focus === SLIDESHOW || !count) startSlideshow();
|
||||||
|
else openViewer(focus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFocus((current) => {
|
||||||
|
if (current === SLIDESHOW) return key === "ArrowDown" || key === "ArrowRight" ? 0 : SLIDESHOW;
|
||||||
|
if (key === "ArrowRight") return Math.min(count - 1, current + 1);
|
||||||
|
if (key === "ArrowLeft") return Math.max(0, current - 1);
|
||||||
|
if (key === "ArrowDown") return Math.min(count - 1, current + columns);
|
||||||
|
if (key === "ArrowUp") return current - columns < 0 ? SLIDESHOW : current - columns;
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [closeViewer, columns, count, focus, openViewer, resetIdle, startSlideshow, viewer]);
|
||||||
|
|
||||||
|
const focused = photos && focus >= 0 ? photos[focus] : undefined;
|
||||||
|
const shown = photos && viewer !== null ? photos[viewer] : undefined;
|
||||||
|
// Neighbours to prefetch while the viewer is open, so ◀ ▶ feel instant.
|
||||||
|
const neighbours = useMemo(() => {
|
||||||
|
if (!photos || viewer === null || count < 2) return [];
|
||||||
|
return [photos[(viewer + 1) % count], photos[(viewer - 1 + count) % count]];
|
||||||
|
}, [photos, viewer, count]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 overflow-hidden bg-neutral-950 text-white" style={{ cursor: viewer === null ? "default" : "none" }}>
|
||||||
|
<div ref={scrollerRef} className="h-full overflow-y-auto px-14 pb-16 pt-10" style={{ scrollPaddingTop: 40, scrollPaddingBottom: 56 }}>
|
||||||
|
<header className="mb-8 flex items-center gap-6">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">Family Photos</h1>
|
||||||
|
<p className="mt-1 text-lg text-white/55">
|
||||||
|
{photos === null ? (failed ? "Can't reach the photo server…" : "Loading…") : `${count} photo${count === 1 ? "" : "s"}`}
|
||||||
|
{focused?.takenAt ? <span className="text-white/80"> · {formatTaken(focused.takenAt)}</span> : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={slideshowRef}
|
||||||
|
onClick={startSlideshow}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-full px-7 py-3 text-xl font-semibold transition-all duration-150 ${
|
||||||
|
focus === SLIDESHOW ? "scale-105 bg-white text-neutral-950 shadow-[0_0_0_4px_rgb(255_255_255/0.35)]" : "bg-white/10 text-white/85"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden>▶</span> Slideshow
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{photos && !count && (
|
||||||
|
<div className="mt-24 text-center text-2xl text-white/60">No photos yet. Add a Google Photos album in the admin screen.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
||||||
|
{photos?.map((photo, index) => {
|
||||||
|
const active = index === focus && viewer === null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={photo.src}
|
||||||
|
ref={(element) => {
|
||||||
|
tileRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setFocus(index);
|
||||||
|
openViewer(index);
|
||||||
|
}}
|
||||||
|
className={`relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-white/5 transition-all duration-150 ${
|
||||||
|
active ? "z-10 scale-[1.08] shadow-[0_0_0_4px_#fff,0_18px_40px_rgb(0_0_0/0.6)]" : "opacity-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={photo.tile} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count > 0 && (
|
||||||
|
<p className="mt-10 text-center text-base text-white/35">Arrows to move · OK to open · Back to return</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown && (
|
||||||
|
<div key={shown.src} className="absolute inset-0 z-20 bg-black" style={{ animation: "gallery-zoom-in 260ms ease-out both" }}>
|
||||||
|
<img
|
||||||
|
src={shown.thumb}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
|
||||||
|
/>
|
||||||
|
{/* The tile is already cached, so it shows at once while the full photo loads on top. */}
|
||||||
|
<img src={shown.tile} alt="" aria-hidden className="absolute inset-0 h-full w-full object-contain" />
|
||||||
|
<img src={shown.src} alt="" className="absolute inset-0 h-full w-full object-contain" />
|
||||||
|
<div className="absolute bottom-6 left-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold text-white/85">
|
||||||
|
◀ {(viewer ?? 0) + 1} / {count} ▶
|
||||||
|
</div>
|
||||||
|
{shown.takenAt ? (
|
||||||
|
<div className="absolute bottom-6 right-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85">
|
||||||
|
{formatTaken(shown.takenAt)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{neighbours.map((photo) => (
|
||||||
|
<link key={photo.src} rel="prefetch" as="image" href={photo.src} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<style>{`@keyframes gallery-zoom-in { from { opacity: 0; transform: scale(0.82) } to { opacity: 1; transform: scale(1) } }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ name: hobbs-family-organizer
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
organizer:
|
organizer:
|
||||||
image: hobbs-family-organizer:1.2.0
|
image: hobbs-family-organizer:1.3.0
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hobbs-family-organizer",
|
"name": "hobbs-family-organizer",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hobbs-family-organizer",
|
"name": "hobbs-family-organizer",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ical.js": "^2.2.1",
|
"ical.js": "^2.2.1",
|
||||||
"lucide-react": "^1.31.0",
|
"lucide-react": "^1.31.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hobbs-family-organizer",
|
"name": "hobbs-family-organizer",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.13.0"
|
"node": ">=22.13.0"
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId "com.dayline.photos"
|
applicationId "com.dayline.photos"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "1.0"
|
versionName "1.1"
|
||||||
buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\""
|
buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -5,7 +5,7 @@ import android.os.Bundle;
|
|||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
|
||||||
/** Launcher tile: the photo frame, full screen, screen kept on. Back exits. */
|
/** Launcher tile: the photo gallery (remote-driven grid), full screen, screen kept on. Back steps back, then exits. */
|
||||||
public final class PhotoActivity extends Activity {
|
public final class PhotoActivity extends Activity {
|
||||||
private PhotoFrameView frame;
|
private PhotoFrameView frame;
|
||||||
|
|
||||||
@@ -13,9 +13,10 @@ public final class PhotoActivity extends Activity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
frame = new PhotoFrameView(this);
|
frame = new PhotoFrameView(this, "/gallery", true);
|
||||||
setContentView(frame.view());
|
setContentView(frame.view());
|
||||||
hideSystemUi();
|
hideSystemUi();
|
||||||
|
frame.view().requestFocus();
|
||||||
frame.load();
|
frame.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,13 @@ public final class PhotoActivity extends Activity {
|
|||||||
if (hasFocus) hideSystemUi();
|
if (hasFocus) hideSystemUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (frame != null && frame.goBack()) return;
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
@SuppressWarnings("deprecation")
|
||||||
private void hideSystemUi() {
|
private void hideSystemUi() {
|
||||||
getWindow().getDecorView().setSystemUiVisibility(
|
getWindow().getDecorView().setSystemUiVisibility(
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ public final class PhotoDreamService extends DreamService {
|
|||||||
setInteractive(false);
|
setInteractive(false);
|
||||||
setFullscreen(true);
|
setFullscreen(true);
|
||||||
setScreenBright(true);
|
setScreenBright(true);
|
||||||
frame = new PhotoFrameView(this);
|
frame = new PhotoFrameView(this, "", false);
|
||||||
setContentView(frame.view());
|
setContentView(frame.view());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-7
@@ -22,8 +22,9 @@ import java.io.IOException;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A locked-down full-screen WebView showing the organizer's /photo page.
|
* A locked-down full-screen WebView showing the organizer's photo pages.
|
||||||
* Shared by the launcher activity and the screen saver.
|
* Shared by the launcher activity (interactive gallery, /photo/gallery) and the screen saver
|
||||||
|
* (non-interactive slideshow, /photo).
|
||||||
*/
|
*/
|
||||||
final class PhotoFrameView {
|
final class PhotoFrameView {
|
||||||
private static final long RETRY_MS = 30_000;
|
private static final long RETRY_MS = 30_000;
|
||||||
@@ -34,17 +35,26 @@ final class PhotoFrameView {
|
|||||||
private final String url;
|
private final String url;
|
||||||
private final String origin;
|
private final String origin;
|
||||||
private boolean destroyed;
|
private boolean destroyed;
|
||||||
|
/** Set by load(): the next finished page becomes the bottom of the Back stack. */
|
||||||
|
private boolean clearHistoryWhenLoaded;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param subPath appended to the configured photo URL (e.g. "/gallery"), or "" for the slideshow
|
||||||
|
* @param interactive true to take remote (D-pad) input; the screen saver passes false
|
||||||
|
*/
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
PhotoFrameView(Context context) {
|
PhotoFrameView(Context context, String subPath, boolean interactive) {
|
||||||
url = configuredUrl(context);
|
String base = configuredUrl(context);
|
||||||
|
while (base.endsWith("/")) base = base.substring(0, base.length() - 1);
|
||||||
|
url = base + subPath;
|
||||||
Uri parsed = Uri.parse(url);
|
Uri parsed = Uri.parse(url);
|
||||||
origin = parsed.getScheme() + "://" + parsed.getAuthority();
|
origin = parsed.getScheme() + "://" + parsed.getAuthority();
|
||||||
|
|
||||||
WebView.setWebContentsDebuggingEnabled(false);
|
WebView.setWebContentsDebuggingEnabled(false);
|
||||||
webView = new WebView(context);
|
webView = new WebView(context);
|
||||||
webView.setBackgroundColor(Color.BLACK);
|
webView.setBackgroundColor(Color.BLACK);
|
||||||
webView.setFocusable(false);
|
webView.setFocusable(interactive);
|
||||||
|
webView.setFocusableInTouchMode(interactive);
|
||||||
|
|
||||||
WebSettings settings = webView.getSettings();
|
WebSettings settings = webView.getSettings();
|
||||||
settings.setJavaScriptEnabled(true);
|
settings.setJavaScriptEnabled(true);
|
||||||
@@ -58,7 +68,7 @@ final class PhotoFrameView {
|
|||||||
settings.setLoadWithOverviewMode(true);
|
settings.setLoadWithOverviewMode(true);
|
||||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||||
settings.setMediaPlaybackRequiresUserGesture(true);
|
settings.setMediaPlaybackRequiresUserGesture(true);
|
||||||
settings.setUserAgentString(settings.getUserAgentString() + " HobbsPhotoFrame/1.0");
|
settings.setUserAgentString(settings.getUserAgentString() + " HobbsPhotoFrame/" + BuildConfig.VERSION_NAME);
|
||||||
CookieManager.getInstance().setAcceptCookie(false);
|
CookieManager.getInstance().setAcceptCookie(false);
|
||||||
|
|
||||||
webView.setWebChromeClient(new WebChromeClient());
|
webView.setWebChromeClient(new WebChromeClient());
|
||||||
@@ -68,6 +78,15 @@ final class PhotoFrameView {
|
|||||||
return !sameOrigin(request.getUrl());
|
return !sameOrigin(request.getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageFinished(WebView view, String finishedUrl) {
|
||||||
|
// Drop the "waiting for server" page (and anything before it) from the Back stack.
|
||||||
|
if (clearHistoryWhenLoaded && finishedUrl != null && finishedUrl.startsWith(origin)) {
|
||||||
|
clearHistoryWhenLoaded = false;
|
||||||
|
view.clearHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onReceivedSslError(WebView view, SslErrorHandler sslHandler, SslError error) {
|
public void onReceivedSslError(WebView view, SslErrorHandler sslHandler, SslError error) {
|
||||||
sslHandler.cancel();
|
sslHandler.cancel();
|
||||||
@@ -88,7 +107,16 @@ final class PhotoFrameView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void load() {
|
void load() {
|
||||||
if (!destroyed) webView.loadUrl(url);
|
if (destroyed) return;
|
||||||
|
clearHistoryWhenLoaded = true;
|
||||||
|
webView.loadUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back key: step back inside the pages (e.g. close a full-screen photo). False = nothing left, exit. */
|
||||||
|
boolean goBack() {
|
||||||
|
if (destroyed || !webView.canGoBack()) return false;
|
||||||
|
webView.goBack();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void destroy() {
|
void destroy() {
|
||||||
|
|||||||
Reference in New Issue
Block a user