diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 01:16:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 01:16:12 +0200 |
| commit | 243bcf017b4957237b3ddf556bd0a37296eb8798 (patch) | |
| tree | 7d0820796737aad4dcfb89de7bec1e7271759af6 /packages/meshbay-hub/src/meshbay_hub/static/photos-app.js | |
| parent | ac8ed437659d765120d1b0cc9f83e942549b479f (diff) | |
| download | meshbay-243bcf017b4957237b3ddf556bd0a37296eb8798.tar.gz | |
feat(cast): photos on the TV, and a television chosen once for the session
A cast button in Videos' toolbar, at the top of Photos, in an album's bar and
in the lightbox, in a group and in Search alike. A television chosen there is
kept for the session: a film opened plays on it with the player as its remote
from the start, and a photo opened in the lightbox is shown on it, scaled to
1920x1080, upright, as JPEG. The lightbox gains a slideshow.
The relay serves one photo at /image behind the stream's token, on the desktop
and on Android; the shell, not the page, decides that the receiver loads it as
a picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 | 67 |
1 files changed, 57 insertions, 10 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..00bc288 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,7 @@ 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'; // ── Photos ─────────────────────────────────────────────────────────────────── // @@ -21,6 +22,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 || ''; @@ -133,6 +136,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,6 +149,10 @@ 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 }) { const entry = photos[index]; const tRef = entry._tRef || transportRef; @@ -155,7 +166,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 +184,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 +202,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 +277,13 @@ 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" /> <button class="video-close" onClick=${onClose} title=${t('video.close')}> <${Icon} name="close" /></button> </div> @@ -300,8 +339,14 @@ 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` @@ -353,17 +398,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` |