diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:26:45 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:26:45 +0200 |
| commit | 1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (patch) | |
| tree | 00cc3132ee05894350db12e8e5cb94f81b1d4876 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 1fd7bbcd038cddf40540bae7083b9adf4c728912 (diff) | |
| download | meshbay-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/static')
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; |