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 0000000..ba00f9a Binary files /dev/null and b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Photos-1.1.apk differ 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() {