aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 15:26:45 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 15:26:45 +0200
commit1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (patch)
tree00cc3132ee05894350db12e8e5cb94f81b1d4876 /packages/meshbay-hub/src/meshbay_hub
parent1fd7bbcd038cddf40540bae7083b9adf4c728912 (diff)
downloadmeshbay-1521cc1ac0cca7ee8786056f84fa399d5cc268f9.tar.gz
feat(photos): show a phone's clips in their albums
Videos in a photo folder are thumbnailed by the node, counted and marked in the album, and played in the group's video player from the lightbox; the slideshow passes them by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css23
12 files changed, 133 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index ca15345..38cadef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -380,6 +380,11 @@ export default {
one: '{n} Foto',
other: '{n} Fotos',
},
+ 'photo.n_videos': {
+ one: "{n} Video",
+ other: "{n} Videos",
+ },
+ 'photo.play_video': "Video abspielen",
'photo.back': 'Zurück',
'photo.prev': 'Vorherige (←)',
'photo.next': 'Nächste (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 8619bdb..9dff21d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -378,6 +378,11 @@ export default {
one: '{n} photo',
other: '{n} photos',
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} videos",
+ },
+ 'photo.play_video': "Play the video",
'photo.back': 'Back',
'photo.prev': 'Previous (←)',
'photo.next': 'Next (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 19db8f7..6f4252a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -378,6 +378,11 @@ export default {
one: '{n} foto',
other: '{n} fotos',
},
+ 'photo.n_videos': {
+ one: "{n} vídeo",
+ other: "{n} vídeos",
+ },
+ 'photo.play_video': "Reproducir el vídeo",
'photo.back': 'Atrás',
'photo.prev': 'Anterior (←)',
'photo.next': 'Siguiente (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 3f3cd42..65956ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -379,6 +379,11 @@ export default {
one: '{n} photo',
other: '{n} photos',
},
+ 'photo.n_videos': {
+ one: "{n} vidéo",
+ other: "{n} vidéos",
+ },
+ 'photo.play_video': "Lire la vidéo",
'photo.back': 'Retour',
'photo.prev': 'Précédent (←)',
'photo.next': 'Suivant (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index f238495..a37d65c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -379,6 +379,11 @@ export default {
one: '{n} foto',
other: '{n} foto',
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} video",
+ },
+ 'photo.play_video': "Riproduci il video",
'photo.back': 'Indietro',
'photo.prev': 'Precedente (←)',
'photo.next': 'Successiva (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 6a59d1d..4910630 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -376,6 +376,11 @@ export default {
one: '{n}枚',
other: '{n}枚',
},
+ 'photo.n_videos': {
+ one: "動画 {n} 本",
+ other: "動画 {n} 本",
+ },
+ 'photo.play_video': "動画を再生",
'photo.back': '戻る',
'photo.prev': '前へ(←)',
'photo.next': '次へ(→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 4b68975..603f69d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -380,6 +380,11 @@ export default {
one: '{n} foto',
other: "{n} foto's",
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} video's",
+ },
+ 'photo.play_video': "Video afspelen",
'photo.back': 'Terug',
'photo.prev': 'Vorige (←)',
'photo.next': 'Volgende (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 8b1b5db..52b6a25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -391,6 +391,13 @@ export default {
many: '{n} zdjęć',
other: '{n} zdjęcia',
},
+ 'photo.n_videos': {
+ one: "{n} film",
+ few: "{n} filmy",
+ many: "{n} filmów",
+ other: "{n} filmu",
+ },
+ 'photo.play_video': "Odtwórz film",
'photo.back': 'Wstecz',
'photo.prev': 'Poprzednie (←)',
'photo.next': 'Następne (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9fc634b..0371dfe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -380,6 +380,11 @@ export default {
one: '{n} foto',
other: '{n} fotos',
},
+ 'photo.n_videos': {
+ one: "{n} vídeo",
+ other: "{n} vídeos",
+ },
+ 'photo.play_video': "Reproduzir o vídeo",
'photo.back': 'Voltar',
'photo.prev': 'Anterior (←)',
'photo.next': 'Próxima (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 5859934..7dd09bf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -373,6 +373,11 @@ export default {
one: '{n} 张照片',
other: '{n} 张照片',
},
+ 'photo.n_videos': {
+ one: "{n} 个视频",
+ other: "{n} 个视频",
+ },
+ 'photo.play_video': "播放视频",
'photo.back': '返回',
'photo.prev': '上一张(←)',
'photo.next': '下一张(→)',
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} />
`}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index ea017fb..1b8dd8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -5104,6 +5104,29 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
border: 1px solid var(--border);
}
.photo-tile:hover { border-color: var(--accent); }
+.photo-tile { position: relative; }
+/* A video among the photos: a play mark and its length over the thumbnail. */
+.photo-tile-video {
+ position: absolute; left: 6px; bottom: 6px;
+ display: inline-flex; align-items: center; gap: 3px;
+ padding: 2px 6px; border-radius: 10px;
+ background: rgba(0, 0, 0, 0.6); color: #fff;
+ font-size: 0.75em; font-variant-numeric: tabular-nums;
+ pointer-events: none;
+}
+.photo-tile-video .icon { width: 0.9em; height: 0.9em; }
+.photo-lightbox-video {
+ position: relative; display: flex; align-items: center; justify-content: center;
+ max-width: 100%; max-height: 100%;
+}
+.photo-lightbox-poster { max-width: 100%; max-height: 70vh; object-fit: contain; display: block; }
+.photo-lightbox-play {
+ position: absolute; width: 64px; height: 64px; border-radius: 50%;
+ border: 0; background: rgba(0, 0, 0, 0.6); color: #fff; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+}
+.photo-lightbox-play:hover { background: var(--accent); }
+.photo-lightbox-play .icon { width: 28px; height: 28px; }
.photo-tile-thumb {
width: 100%;
aspect-ratio: 1 / 1;