aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/photos-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js98
1 files changed, 80 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index 73b8c15..afc815a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -9,6 +9,9 @@ import {
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 ───────────────────────────────────────────────────────────────────
//
@@ -21,6 +24,8 @@ import { SourceTag } from './group-name.js';
// 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 || '';
@@ -115,11 +120,11 @@ function AlbumLanding({ albums, transportRef, gekRef, onOpen }) {
// ── open album: grid of its own photos ──────────────────────────────────────
-function PhotoTile({ entry, transportRef, gekRef, onOpen }) {
+function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) {
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
return html`
- <div class="photo-tile" onClick=${onOpen}>
+ <div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}>
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef}
emptyIcon="image" />
@@ -133,6 +138,10 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen }) {
// _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
@@ -142,7 +151,11 @@ const ZOOM_STEP = 25;
const ZOOM_MIN = 25;
const ZOOM_MAX = 400;
-function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
+// 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;
@@ -155,7 +168,9 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
// same way the browser renders the <img> 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);
@@ -171,8 +186,10 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
const chunks = await pipelinedDownload(
transport, gRef.current, entry.id, totalChunks);
if (cancelled) return;
- const url = URL.createObjectURL(new Blob(chunks));
+ 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 */
@@ -187,6 +204,23 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
// 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();
@@ -245,6 +279,17 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
<button class="photo-icon-btn photo-icon-btn-text ${zoomPercent === 100 ? 'active' : ''}"
disabled=${!blobUrl} onClick=${zoomActual} title=${t('photo.zoom_100')}>100%</button>
</div>
+ <button class="video-close ${slideshow ? 'cast-active' : ''}"
+ disabled=${photos.length < 2}
+ onClick=${() => setSlideshow(!slideshow)}
+ title=${slideshow ? t('photo.slideshow_stop') : t('photo.slideshow')}
+ aria-pressed=${slideshow ? 'true' : 'false'}>
+ <${Icon} name=${slideshow ? 'pause' : 'play'} /></button>
+ <${CastButton} variant="overlay" cls="video-close" />
+ ${linkFor && linkFor(entry) && html`
+ <button class="video-close" onClick=${() => copyLink(linkFor(entry))}
+ title=${t('link.copy')} aria-label=${t('link.copy')}>
+ <${Icon} name="link" /></button>`}
<button class="video-close" onClick=${onClose} title=${t('video.close')}>
<${Icon} name="close" /></button>
</div>
@@ -272,8 +317,16 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
`;
}
-function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) {
+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;
@@ -300,22 +353,29 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
${year && html`<span class="photo-album-heading-year">${year}</span>`}
</div>
</div>
- ${!readOnly && html`<button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}>
- <${Icon} name="archive" cls="photo-icon-btn-icon" /></button>`}
+ <div class="photo-album-actions">
+ ${/* 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`<button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}>
+ <${Icon} name="archive" cls="photo-icon-btn-icon" /></button>`}
+ </div>
</div>
<div class="photo-grid">
${album.photos.map((e, i) => html`
<${LazyTile} key=${e.id} cls="photo-tile-slot">
<${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef}
- onOpen=${() => setLightboxIndex(i)} />
+ onOpen=${() => setLightboxIndex(i)} onMenu=${(ev) => onTileMenu(ev, e)} />
</${LazyTile}>
`)}
</div>
${lightboxIndex !== null && html`
<${Lightbox} photos=${album.photos} index=${lightboxIndex}
transportRef=${transportRef} gekRef=${gekRef}
- onClose=${() => setLightboxIndex(null)} onNav=${navigate} />
+ onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor} />
`}
+ ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
}
@@ -324,7 +384,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
function PhotosApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
photoDirectories, setError,
- hideFilter, readOnly,
+ hideFilter, readOnly, linkFor,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -353,17 +413,19 @@ function PhotosApp({
<p class="page-message">${t('photo.no_roots_configured')}</p>
`}
${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html`
- ${/* The filter is the only thing in it, so under `hideFilter` 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 && 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`
<div class="photo-toolbar">
- <div class="tb-search">
+ <${CastButton} />
+ ${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
value=${filter} onInput=${(e) => setFilter(e.target.value)} />
- </div>
+ </div>`}
</div>
`}
${filteredAlbums.length === 0 && html`
@@ -376,7 +438,7 @@ function PhotosApp({
${status === 'connected' && openAlbum && html`
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
- onBack=${() => setOpenDir(null)} readOnly=${readOnly} />
+ onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} />
`}
`;
}