From 00e0cc42cf9427f7b5b71ec01a616badd934d5d5 Mon Sep 17 00:00:00 2001 From: jeremy Date: Wed, 4 Nov 2026 21:19:24 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3 --- CLAUDE.md | 2 +- .../RELEASE-NOTES.md | 15 + .../VERSION | 2 +- .../app/_lib/photos.ts | 32 +- .../app/admin/page.tsx | 8 +- .../app/api/admin/photos/upload/route.ts | 12 +- .../app/api/health/route.ts | 2 +- .../app/photo/gallery/page.tsx | 274 ++++++++++++++++++ .../compose.yaml | 2 +- .../package-lock.json | 4 +- .../package.json | 2 +- .../shield/Hobbs-Family-Photos-1.1.apk | Bin 0 -> 15946 bytes .../shield/photos-source/app/build.gradle | 4 +- .../com/dayline/photos/PhotoActivity.java | 12 +- .../com/dayline/photos/PhotoDreamService.java | 2 +- .../com/dayline/photos/PhotoFrameView.java | 42 ++- 16 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/gallery/page.tsx create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.1.apk diff --git a/CLAUDE.md b/CLAUDE.md index 5b253ff..4332771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) ├── [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] **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 `). **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 (`.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 `). **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] 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 diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md index 28d0f1c..533ae48 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md @@ -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 (`.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) - `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken. diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION index 26aaba0..f0bb29e 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts index 6725fd8..0a3efe1 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/_lib/photos.ts @@ -17,10 +17,12 @@ export const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; export const STALE_AFTER_MS = 48 * 60 * 60 * 1000; const PHOTO_EDGE = 1920; 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 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 = { title: string; firstAttemptAt: number; @@ -40,7 +42,7 @@ export function validPhotoDir(name: string) { return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name); } 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); @@ -222,12 +224,22 @@ export async function syncAlbum(album: PhotoAlbum) { 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}.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) { 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); const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position])); @@ -296,7 +308,7 @@ function serializedUploads(task: () => Promise) { 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 () => { const index = await readIndex(UPLOADS_DIR); 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 writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo); 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.lastSuccessAt = Date.now(); await writeIndex(UPLOADS_DIR, index); @@ -321,14 +334,14 @@ export function removeUpload(key: string) { index.photos = index.photos.filter((entry) => entry.key !== key); if (index.photos.length === before) return false; 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; }); } // ---------- 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() { const settings = await readSettings(); @@ -339,11 +352,12 @@ export async function currentSlideshow() { const fingerprint = createHash("sha1").update(settings.updatedAt); for (const source of sources) { 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) { photos.push({ src: `/api/photos/img/${source}/${photo.key}.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, width: photo.width, height: photo.height, diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx index bf1ba88..e83aa20 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx @@ -381,7 +381,7 @@ function ago(timestamp: number | null) { 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) { const bitmap = await createImageBitmap(file); const encode = async (edge: number, quality: number) => { @@ -395,9 +395,10 @@ async function resizeForUpload(file: File) { return { blob, width: canvas.width, height: canvas.height }; }; const photo = await encode(1920, 0.88); + const tile = await encode(400, 0.82); const thumb = await encode(64, 0.7); bitmap.close(); - return { photo, thumb }; + return { photo, tile, thumb }; } function PhotosTab() { @@ -500,10 +501,11 @@ function PhotosTab() { for (const file of Array.from(files)) { setUploading(`Uploading ${done + failed + 1} of ${files.length}…`); try { - const { photo, thumb } = await resizeForUpload(file); + const { photo, tile, thumb } = await resizeForUpload(file); const form = new FormData(); form.append("photo", photo.blob, "photo.jpg"); form.append("thumb", thumb.blob, "thumb.jpg"); + form.append("tile", tile.blob, "tile.jpg"); form.append("width", String(photo.width)); form.append("height", String(photo.height)); form.append("takenAt", String(file.lastModified || "")); diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts index 8226d72..9cfd69a 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/admin/photos/upload/route.ts @@ -4,7 +4,7 @@ import { addUpload, isJpeg, removeUpload } from "../../../../_lib/photos"; 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. */ export async function POST(request: Request) { @@ -23,9 +23,17 @@ export async function POST(request: Request) { const photoBytes = new Uint8Array(await photo.arrayBuffer()); const thumbBytes = new Uint8Array(await thumb.arrayBuffer()); 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")); try { - const result = await addUpload(photoBytes, thumbBytes, { + const result = await addUpload(photoBytes, thumbBytes, tileBytes, { takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null, width: Math.max(0, Math.round(Number(form.get("width")) || 0)), height: Math.max(0, Math.round(Number(form.get("height")) || 0)), diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts index efd4ba8..766a1f9 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts @@ -15,7 +15,7 @@ export async function GET() { { status: "ok", service: "hobbs-family-organizer", - version: "1.2.0", + version: "1.3.0", calendarConfigurationValid: calendars.some((calendar) => calendar.enabled), calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length, adminConfigured, diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/gallery/page.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/gallery/page.tsx new file mode 100644 index 0000000..e18cc50 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/photo/gallery/page.tsx @@ -0,0 +1,274 @@ +/* eslint-disable @next/next/no-img-element -- plain 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(null); + const [failed, setFailed] = useState(false); + const [focus, setFocus] = useState(0); + const [viewer, setViewer] = useState(null); + const columns = useColumns(); + const versionRef = useRef(""); + const focusSrc = useRef(""); + const tileRefs = useRef<(HTMLDivElement | null)[]>([]); + const slideshowRef = useRef(null); + const scrollerRef = useRef(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(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 ( +
+
+
+
+

Family Photos

+

+ {photos === null ? (failed ? "Can't reach the photo server…" : "Loading…") : `${count} photo${count === 1 ? "" : "s"}`} + {focused?.takenAt ? · {formatTaken(focused.takenAt)} : null} +

+
+
+ ▶ Slideshow +
+
+ + {photos && !count && ( +
No photos yet. Add a Google Photos album in the admin screen.
+ )} + +
+ {photos?.map((photo, index) => { + const active = index === focus && viewer === null; + return ( +
{ + 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" + }`} + > + +
+ ); + })} +
+ + {count > 0 && ( +

Arrows to move · OK to open · Back to return

+ )} +
+ + {shown && ( +
+ + {/* The tile is already cached, so it shows at once while the full photo loads on top. */} + + +
+ ◀ {(viewer ?? 0) + 1} / {count} ▶ +
+ {shown.takenAt ? ( +
+ {formatTaken(shown.takenAt)} +
+ ) : null} +
+ )} + {neighbours.map((photo) => ( + + ))} + + +
+ ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml index a72d99b..946f152 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml @@ -2,7 +2,7 @@ name: hobbs-family-organizer services: organizer: - image: hobbs-family-organizer:1.2.0 + image: hobbs-family-organizer:1.3.0 build: context: . dockerfile: Dockerfile diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json index 56793ce..98d462c 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json @@ -1,12 +1,12 @@ { "name": "hobbs-family-organizer", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hobbs-family-organizer", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "ical.js": "^2.2.1", "lucide-react": "^1.31.0", diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json index 8365419..d588c72 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json @@ -1,6 +1,6 @@ { "name": "hobbs-family-organizer", - "version": "1.2.0", + "version": "1.3.0", "private": true, "engines": { "node": ">=22.13.0" diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.1.apk b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.1.apk new file mode 100644 index 0000000000000000000000000000000000000000..ba00f9a53b14fd80ff86874df220dfa4de9f8c3f GIT binary patch literal 15946 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 literal 0 HcmV?d00001 diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/build.gradle b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/build.gradle index 4002b18..2167b60 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/build.gradle +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId "com.dayline.photos" minSdk 26 targetSdk 35 - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "1.1" buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\"" } diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java index 761bd9c..4a6fa41 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoActivity.java @@ -5,7 +5,7 @@ import android.os.Bundle; import android.view.View; 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 { private PhotoFrameView frame; @@ -13,9 +13,10 @@ public final class PhotoActivity extends Activity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - frame = new PhotoFrameView(this); + frame = new PhotoFrameView(this, "/gallery", true); setContentView(frame.view()); hideSystemUi(); + frame.view().requestFocus(); frame.load(); } @@ -25,6 +26,13 @@ public final class PhotoActivity extends Activity { if (hasFocus) hideSystemUi(); } + @Override + @SuppressWarnings("deprecation") + public void onBackPressed() { + if (frame != null && frame.goBack()) return; + super.onBackPressed(); + } + @SuppressWarnings("deprecation") private void hideSystemUi() { getWindow().getDecorView().setSystemUiVisibility( diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoDreamService.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoDreamService.java index 75c9c35..6479d94 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoDreamService.java +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoDreamService.java @@ -12,7 +12,7 @@ public final class PhotoDreamService extends DreamService { setInteractive(false); setFullscreen(true); setScreenBright(true); - frame = new PhotoFrameView(this); + frame = new PhotoFrameView(this, "", false); setContentView(frame.view()); } diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java index d65c13e..bd254ea 100644 --- a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/photos-source/app/src/main/java/com/dayline/photos/PhotoFrameView.java @@ -22,8 +22,9 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; /** - * A locked-down full-screen WebView showing the organizer's /photo page. - * Shared by the launcher activity and the screen saver. + * A locked-down full-screen WebView showing the organizer's photo pages. + * Shared by the launcher activity (interactive gallery, /photo/gallery) and the screen saver + * (non-interactive slideshow, /photo). */ final class PhotoFrameView { private static final long RETRY_MS = 30_000; @@ -34,17 +35,26 @@ final class PhotoFrameView { private final String url; private final String origin; 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") - PhotoFrameView(Context context) { - url = configuredUrl(context); + PhotoFrameView(Context context, String subPath, boolean interactive) { + String base = configuredUrl(context); + while (base.endsWith("/")) base = base.substring(0, base.length() - 1); + url = base + subPath; Uri parsed = Uri.parse(url); origin = parsed.getScheme() + "://" + parsed.getAuthority(); WebView.setWebContentsDebuggingEnabled(false); webView = new WebView(context); webView.setBackgroundColor(Color.BLACK); - webView.setFocusable(false); + webView.setFocusable(interactive); + webView.setFocusableInTouchMode(interactive); WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); @@ -58,7 +68,7 @@ final class PhotoFrameView { settings.setLoadWithOverviewMode(true); settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); settings.setMediaPlaybackRequiresUserGesture(true); - settings.setUserAgentString(settings.getUserAgentString() + " HobbsPhotoFrame/1.0"); + settings.setUserAgentString(settings.getUserAgentString() + " HobbsPhotoFrame/" + BuildConfig.VERSION_NAME); CookieManager.getInstance().setAcceptCookie(false); webView.setWebChromeClient(new WebChromeClient()); @@ -68,6 +78,15 @@ final class PhotoFrameView { 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 public void onReceivedSslError(WebView view, SslErrorHandler sslHandler, SslError error) { sslHandler.cancel(); @@ -88,7 +107,16 @@ final class PhotoFrameView { } 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() {