import {
html, useState, useEffect, useMemo, useCallback, useRef,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
import {
formatSize, CHUNK_SIZE, pipelinedDownload, downloadDirectory,
} from './file-utils.js';
import { transfers } from './transfers.js';
import { MediaThumb, LazyTile } from './media-tiles.js';
import { SourceTag } from './group-name.js';
import { CastButton, castSession, useCastDevice, showPhoto } from './cast-session.js';
import { Menu, useMenu } from './menu.js';
import { copyLink } from './copy-link.js';
// ── Photos ───────────────────────────────────────────────────────────────────
//
// docs/MESHBAY_DESIGN.md §9.9. Unlike Videos/Music: several root folders per group
// (photoRoots is a list, §2.1), one album-grid view with no mode toggle and
// no third-party matching step (§2.3), and per-photo info read from the
// file's own EXIF at index time rather than fetched live. Every directory
// containing at least one image under a configured root is one album card;
// opening one shows its photos in a grid with a lightbox (next/previous,
// keyboard arrows, EXIF info when present) and a "zip this album" button
// that reuses Files' own zip mechanism unchanged (file-utils.js's
// downloadDirectory, lifted out of files-app.js for exactly this reuse).
// With a television chosen (cast-session.js), the photo the lightbox shows
// is the photo the television shows, and a slideshow pages through both.
function underAnyPhotoRoot(entry, photoRoots) {
const p = entry.path || '';
return (photoRoots || []).some((r) => p === r || p.startsWith(r + '/'));
}
function groupPhotoAlbums(entries, photoRoots) {
const byDir = new Map();
for (const e of entries) {
if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue;
// e.path is already the file's containing directory, not the full
// path+filename (files-app.js's own convention, also relied on by
// zipstream.js's entriesUnder) — it must not be stripped a second time,
// or every album collapses one level up into its parent (found live:
// a "backup" root with several subfolders showed as a single "backup"
// album holding everything, because this line was extracting the
// dirname of a value that was already a dirname).
const dir = e.path || '';
if (!byDir.has(dir)) byDir.set(dir, []);
byDir.get(dir).push(e);
}
return [...byDir.entries()]
.map(([dir, photos]) => ({
dir, photos: photos.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.dir.localeCompare(b.dir));
}
// Underscores replaced with spaces for display only — this never touches
// the folder on disk or anything sent to the node, purely how the name
// reads in the grid/heading (a raw "mariage_joce" reads worse than
// "mariage joce" for something meant to look like an album, not a filename).
function albumTitle(dir) {
return dir ? dir.split('/').pop().replace(/_/g, ' ') : t('photo.root_album');
}
function formatTakenAt(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
// A single year when every dated photo in the album agrees, a range when
// they don't (an album spanning New Year's Eve, or just a loosely-sorted
// folder) — never guessed for photos with no EXIF date at all, which just
// don't count toward it.
function albumYearLabel(photos) {
const years = [...new Set(
photos.filter((p) => p.taken_at).map((p) => new Date(p.taken_at * 1000).getFullYear()),
)].sort((a, b) => a - b);
if (years.length === 0) return '';
if (years.length === 1) return String(years[0]);
return `${years[0]}–${years[years.length - 1]}`;
}
// ── landing grid: one card per album (directory containing images) ─────────
function AlbumCard({ album, transportRef, gekRef, onOpen }) {
const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0];
const tRef = cover._tRef || transportRef;
const gRef = cover._gRef || gekRef;
const year = albumYearLabel(album.photos);
return html`
`;
}
// ── lightbox: full image, next/previous, per-photo info, zoom ──────────────
//
// Cached per session by file id, same shape as media-tiles.js's
// _thumbBlobCache — clicking back and forth between two photos decrypts
// each once, not once per visit.
const _fullBlobCache = new Map();
// The same photos as blobs, for the television: a cast scales the picture
// itself, and reading it back through its URL would be a fetch the page's
// content policy has no reason to allow.
const _fullBlobs = new Map();
// Zoom is only ever meaningful for the lightbox's own full-resolution
// image — nowhere else in the app shows one, so there is nothing to gate
// this behind beyond the component itself only ever being mounted for a
// photo.
const ZOOM_STEP = 25;
const ZOOM_MIN = 25;
const ZOOM_MAX = 400;
// How long a slideshow stays on a photo, counted from when it has loaded:
// a slow node must not skip a photo nobody saw.
const SLIDESHOW_MS = 5000;
function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor }) {
const entry = photos[index];
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null);
const [loading, setLoading] = useState(!_fullBlobCache.has(entry.id));
// null = "fit to window" (the default, object-fit: contain); a number is
// an explicit percentage of the image's own natural size, read off the
// loaded itself rather than trusted from EXIF — accurate whether or
// not enrichment ever ran, and already EXIF-orientation-corrected the
// same way the browser renders the itself.
const [zoomPercent, setZoomPercent] = useState(null);
const [naturalSize, setNaturalSize] = useState(null);
const [slideshow, setSlideshow] = useState(false);
const slotRef = useRef(null);
const castDevice = useCastDevice();
useEffect(() => {
const cached = _fullBlobCache.get(entry.id);
if (cached) { setBlobUrl(cached); setLoading(false); return; }
setBlobUrl(null);
setLoading(true);
let cancelled = false;
(async () => {
const transport = tRef.current;
if (!transport || !transport.connected) { setLoading(false); return; }
try {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
const chunks = await pipelinedDownload(
transport, gRef.current, entry.id, totalChunks);
if (cancelled) return;
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
_fullBlobCache.set(entry.id, url);
_fullBlobs.set(entry.id, blob);
setBlobUrl(url);
} catch {
/* leave the placeholder — a transient fetch failure isn't fatal, next/close still work */
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [entry.id]);
// Every photo opens fit-to-window, same as any other viewer — a zoom
// level chosen for one picture saying nothing about the next.
useEffect(() => { setZoomPercent(null); setNaturalSize(null); }, [entry.id]);
// The television follows the lightbox, from the moment one is chosen.
useEffect(() => {
const blob = _fullBlobs.get(entry.id);
if (castDevice && blobUrl && blob) showPhoto(blob);
}, [castDevice, blobUrl, entry.id]);
// Next photo once this one has been on screen for SLIDESHOW_MS; the last
// photo ends the slideshow rather than wrapping round.
useEffect(() => {
if (!slideshow || !blobUrl) return undefined;
const id = setTimeout(() => {
if (index < photos.length - 1) onNav(1);
else setSlideshow(false);
}, SLIDESHOW_MS);
return () => clearTimeout(id);
}, [slideshow, blobUrl, index, photos.length, onNav]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft' && index > 0) onNav(-1);
else if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, onNav, index, photos.length]);
const handleImgLoad = (e) => {
setNaturalSize({ w: e.target.naturalWidth, h: e.target.naturalHeight });
};
// The percentage "fit to window" actually renders at, so the first zoom
// step moves from *there* rather than silently snapping to 100% first —
// object-fit: contain never upscales past the image's own natural size
// (nothing here sets width/height:100% to force it to), so fit is never
// above 100% either.
const fitPercent = () => {
if (!naturalSize || !slotRef.current) return 100;
const rect = slotRef.current.getBoundingClientRect();
return Math.min(1, rect.width / naturalSize.w, rect.height / naturalSize.h) * 100;
};
const zoomIn = () => setZoomPercent(
(z) => Math.min(ZOOM_MAX, Math.round(z ?? fitPercent()) + ZOOM_STEP));
const zoomOut = () => setZoomPercent(
(z) => Math.max(ZOOM_MIN, Math.round(z ?? fitPercent()) - ZOOM_STEP));
const zoomFit = () => setZoomPercent(null);
const zoomActual = () => setZoomPercent(100);
const zoomed = zoomPercent != null;
const imgStyle = zoomed && naturalSize
? `width:${Math.round(naturalSize.w * zoomPercent / 100)}px; `
+ `height:${Math.round(naturalSize.h * zoomPercent / 100)}px;`
: '';
return html`
{
if (e.target.classList.contains('photo-lightbox')) onClose();
}}>
`;
}
function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor }) {
const [lightboxIndex, setLightboxIndex] = useState(null);
// Right-click a photo for its link. A tile has no dots to press, so on a
// touchscreen the link is in the lightbox's bar instead.
const { menu, openAt, close: closeMenu } = useMenu();
const onTileMenu = (e, entry) => {
const link = linkFor ? linkFor(entry) : null;
if (!link) return;
openAt(e, [{ label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }]);
};
const zip = useCallback(async () => {
const transport = transportRef.current;
await downloadDirectory(
transfers, transport, gekRef.current, entries, album.dir, { setError });
}, [entries, album.dir]);
const navigate = useCallback((delta) => {
setLightboxIndex((i) => {
const next = i + delta;
return next >= 0 && next < album.photos.length ? next : i;
});
}, [album.photos.length]);
const year = albumYearLabel(album.photos);
return html`
${albumTitle(album.dir)}
${year && html`${year}`}
${/* Choosing a television from here shows the album on it: the first
photo opens, since nothing is on screen yet to send. */''}
<${CastButton} cls="photo-icon-btn" iconCls="photo-icon-btn-icon"
onChosen=${() => setLightboxIndex((i) => (i === null ? 0 : i))} />
${!readOnly && html``}
`}
${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html`
${/* The filter and the cast button are all it holds, so with neither —
Search hides the filter, a browser cannot cast — there is no
toolbar rather than an empty one: an empty band still pins, and
would hold a strip of the page open under the search field for
nothing. */
(!hideFilter || castSession.supported) && html`
`}
<${AlbumLanding} albums=${filteredAlbums}
transportRef=${transportRef} gekRef=${gekRef}
onOpen=${(dir) => setOpenDir(dir)} />
`}
${status === 'connected' && openAlbum && html`
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} />
`}
`;
}
// groupPhotoAlbums is exported for the Search page, which needs the album a
// photo belongs to in order to merge duplicate sources per album rather than
// per file (docs/MESHBAY_DESIGN.md §9.11). It calls this one, never a copy:
// a second implementation of the album key would keep agreeing with this one
// right up until one of them changed.
export { PhotosApp, groupPhotoAlbums };