diff options
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.js | 98 |
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} /> `} `; } |