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.js67
1 files changed, 58 insertions, 9 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 f18acd2..df207ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -36,7 +36,9 @@ function underAnyPhotoRoot(entry, photoRoots) {
function groupPhotoAlbums(entries, photoRoots) {
const byDir = new Map();
for (const e of entries) {
- if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue;
+ // A video in a photo folder is a phone's clip, backed up beside its
+ // photos (§9.12): it belongs to the album as it did on the phone.
+ if ((e.type !== 'image' && e.type !== 'video') || !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,
@@ -85,6 +87,25 @@ function albumYearLabel(photos) {
// ── landing grid: one card per album (directory containing images) ─────────
+/** A clip's length as a phone shows it: `0:42`, `1:02:05`. */
+function clipLength(seconds) {
+ if (!seconds) return '';
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const sec = String(total % 60).padStart(2, '0');
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
+}
+
+/** "12 photos", "12 photos · 3 videos", or "3 videos". */
+function albumCount(items) {
+ const videos = items.filter((e) => e.type === 'video').length;
+ const photos = items.length - videos;
+ if (!videos) return t('photo.n_photos', { n: photos });
+ const clips = t('photo.n_videos', { n: videos });
+ return photos ? `${t('photo.n_photos', { n: photos })} · ${clips}` : clips;
+}
+
function AlbumCard({ album, transportRef, gekRef, onOpen }) {
const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0];
const tRef = cover._tRef || transportRef;
@@ -98,7 +119,7 @@ function AlbumCard({ album, transportRef, gekRef, onOpen }) {
<div class="photo-album-info">
<div class="photo-album-title">${albumTitle(album.dir)}</div>
<div class="photo-album-sub">
- ${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })}
+ ${year}${year ? ' · ' : ''}${albumCount(album.photos)}
</div>
<${SourceTag} entries=${album.photos} cls="photo-card-group" />
</div>
@@ -128,7 +149,9 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) {
<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" />
+ emptyIcon=${entry.type === 'video' ? 'video' : 'image'} />
+ ${entry.type === 'video' && html`
+ <span class="photo-tile-video"><${Icon} name="play" />${clipLength(entry.duration)}</span>`}
</div>
`;
}
@@ -156,8 +179,13 @@ const ZOOM_MAX = 400;
// a slow node must not skip a photo nobody saw.
const SLIDESHOW_MS = 5000;
-function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete }) {
+function Lightbox({
+ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete, onPlay,
+}) {
const entry = photos[index];
+ // A video is shown by its thumbnail and played in the group's video
+ // player: never downloaded whole into this page.
+ const isVideo = entry.type === 'video';
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null);
@@ -174,8 +202,9 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
const castDevice = useCastDevice();
useEffect(() => {
+ if (isVideo) { setBlobUrl(null); setLoading(false); return undefined; }
const cached = _fullBlobCache.get(entry.id);
- if (cached) { setBlobUrl(cached); setLoading(false); return; }
+ if (cached) { setBlobUrl(cached); setLoading(false); return undefined; }
setBlobUrl(null);
setLoading(true);
let cancelled = false;
@@ -211,6 +240,14 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
if (castDevice && blobUrl && blob) showPhoto(blob);
}, [castDevice, blobUrl, entry.id]);
+ // A slideshow is of photos: it passes a video by rather than wait on a
+ // clip nobody started.
+ useEffect(() => {
+ if (!slideshow || !isVideo) return;
+ if (index < photos.length - 1) onNav(1);
+ else setSlideshow(false);
+ }, [slideshow, isVideo, index, photos.length, onNav]);
+
// Next photo once this one has been on screen for SLIDESHOW_MS; the last
// photo ends the slideshow rather than wrapping round.
useEffect(() => {
@@ -224,6 +261,8 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
useEffect(() => {
const onKey = (e) => {
+ // The video player is over this, and its keys are its own.
+ if (document.querySelector('.video-player-overlay')) return;
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);
@@ -304,6 +343,15 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
<${Icon} name="chevron" cls="photo-nav-icon photo-nav-prev-icon" /></button>
<div ref=${slotRef} class="photo-lightbox-image-slot ${zoomed ? 'zoomed' : ''}">
${loading && html`<span class="spinner"></span>`}
+ ${isVideo && html`
+ <div class="photo-lightbox-video">
+ <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
+ cls="photo-lightbox-poster" transportRef=${tRef} gekRef=${gRef} emptyIcon="video" />
+ ${onPlay && html`
+ <button class="photo-lightbox-play" onClick=${() => onPlay(entry)}
+ title=${t('photo.play_video')} aria-label=${t('photo.play_video')}>
+ <${Icon} name="play" /></button>`}
+ </div>`}
${blobUrl && html`<img class="photo-lightbox-image ${zoomed ? 'zoomed' : ''}"
style=${imgStyle} src=${blobUrl} alt=${entry.name} onLoad=${handleImgLoad} />`}
</div>
@@ -313,6 +361,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
</div>
<div class="photo-lightbox-info">
${entry.width && entry.height && html`<span>${entry.width}×${entry.height}</span>`}
+ ${isVideo && entry.duration && html`<span>${clipLength(entry.duration)}</span>`}
<span>${formatSize(entry.size)}</span>
${entry.taken_at && html`<span>${formatTakenAt(entry.taken_at)}</span>`}
${entry.camera && html`<span>${entry.camera}</span>`}
@@ -323,7 +372,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
}
function AlbumView({
- album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete,
+ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete, onPlay,
}) {
const [lightboxIndex, setLightboxIndex] = useState(null);
// Right-click a photo for its link, or to delete it. A tile has no dots to
@@ -393,7 +442,7 @@ function AlbumView({
<${Lightbox} photos=${album.photos} index=${lightboxIndex}
transportRef=${transportRef} gekRef=${gekRef}
onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor}
- onDelete=${onDelete} />
+ onDelete=${onDelete} onPlay=${onPlay} />
`}
${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
@@ -405,7 +454,7 @@ function PhotosApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
photoDirectories, setError,
hideFilter, readOnly, linkFor,
- isNodeAdmin, userId, applyIndex,
+ isNodeAdmin, userId, applyIndex, onPreview,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -479,7 +528,7 @@ function PhotosApp({
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor}
- onDelete=${deleteFor} />
+ onDelete=${deleteFor} onPlay=${readOnly ? null : onPreview} />
`}
`;
}