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:
@@ -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<T>(task: () => Promise<T>) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user