feat(video): play local gallery videos in the journey gallery

Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.
This commit is contained in:
Maurice
2026-06-30 10:40:39 +02:00
committed by Maurice
parent 993d9bf713
commit c92c02e1b8
10 changed files with 241 additions and 21 deletions
+7
View File
@@ -582,6 +582,13 @@ export const journeyApi = {
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
@@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from 'react'
import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
import { RefreshCw, Camera, Image, Plus, X, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi } from '../../api/client'
@@ -66,7 +67,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
if (!files?.length) return
setGalleryProgress({ done: 0, total: files.length })
try {
const normalized = await normalizeImageFiles(files)
// Videos skip HEIC normalization; only images are converted (#823).
const all = Array.from(files)
const videos = all.filter(isVideoFile)
const images = all.filter(f => !isVideoFile(f))
const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos]
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
})
@@ -110,7 +115,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
return (
<div>
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
@@ -165,6 +170,13 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{photo.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="w-9 h-9 rounded-full bg-black/55 backdrop-blur flex items-center justify-center text-white">
<Play size={16} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
{/* Delete button */}
<button
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
+17 -11
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import VideoPlayer from './VideoPlayer'
interface LightboxPhoto {
id: string
@@ -8,6 +9,7 @@ interface LightboxPhoto {
provider?: string
asset_id?: string | null
owner_id?: number | null
mediaType?: string | null
}
interface Props {
@@ -107,17 +109,21 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
</button>
)}
{/* Photo */}
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Photo or video */}
{photo.mediaType === 'video' ? (
<VideoPlayer key={photo.id} src={photo.src} />
) : (
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
)}
{/* Next button */}
{hasNext && (
@@ -0,0 +1,40 @@
import React from 'react'
/**
* Lightweight video player for gallery/lightbox playback (#823). Uses the native
* <video> element with controls — local videos stream with HTTP Range (seeking
* works out of the box) and the source carries the correct video MIME from the
* server. Kept as a thin wrapper so the player can later be re-skinned (e.g. Plyr)
* without touching every call site.
*/
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
return (
<video
key={src}
src={src}
poster={poster}
controls
playsInline
autoPlay={autoPlay}
preload="metadata"
style={{
maxWidth: '92vw',
maxHeight: '92vh',
borderRadius: 4,
background: '#000',
animation: 'fadeIn 0.15s ease',
...style,
}}
/>
)
}
+4 -4
View File
@@ -95,7 +95,7 @@ export default function JourneyDetailPage() {
onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
@@ -384,7 +384,7 @@ export default function JourneyDetailPage() {
readOnly={!canEditEntries}
onEdit={() => setEditingEntry(entry)}
onDelete={() => setDeleteTarget(entry)}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
</div>
@@ -408,7 +408,7 @@ export default function JourneyDetailPage() {
journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
</div>
@@ -538,7 +538,7 @@ export default function JourneyDetailPage() {
{/* Lightbox */}
{lightbox && (
<PhotoLightbox
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))}
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.mediaType }))}
startIndex={lightbox.index}
onClose={() => setLightbox(null)}
/>
@@ -39,7 +39,7 @@ export function useJourneyDetail() {
const feedRef = useRef<HTMLDivElement>(null)
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null; mediaType?: string | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
const [showInvite, setShowInvite] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
+14 -2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { journeyApi } from '../api/client'
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
import { captureVideoPoster, isVideoFile } from '../utils/videoPoster'
export interface Journey {
id: number
@@ -276,8 +277,19 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
files,
async (file, opts) => {
const fd = new FormData()
fd.append('photos', file)
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
let data: { photos?: GalleryPhoto[] }
if (isVideoFile(file)) {
// Video: grab a poster frame + duration in the browser, then upload the
// raw video + poster (#823). No server-side transcoding.
const { poster, durationMs } = await captureVideoPoster(file)
fd.append('video', file)
if (poster) fd.append('poster', poster, 'poster.jpg')
if (durationMs != null) fd.append('duration_ms', String(durationMs))
data = await journeyApi.uploadGalleryVideo(journeyId, fd, opts)
} else {
fd.append('photos', file)
data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
}
const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
+57
View File
@@ -0,0 +1,57 @@
/**
* Capture a poster frame and duration from a video file entirely in the browser
* (#823). This avoids any server-side transcoding: the picked video is decoded by
* the browser, a frame is drawn to a canvas and exported as a JPEG that is
* uploaded alongside the video and stored as its thumbnail.
*
* Resolves with a null poster (and best-effort duration) if anything fails — the
* caller still uploads the video; the gallery just shows a placeholder tile.
*/
export async function captureVideoPoster(file: File): Promise<{ poster: Blob | null; durationMs: number | null }> {
return new Promise((resolve) => {
if (typeof document === 'undefined') { resolve({ poster: null, durationMs: null }); return }
const url = URL.createObjectURL(file)
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.playsInline = true
video.src = url
let settled = false
const finish = (poster: Blob | null, durationMs: number | null) => {
if (settled) return
settled = true
URL.revokeObjectURL(url)
resolve({ poster, durationMs })
}
// Don't hang forever on a codec the browser can't decode.
const timer = setTimeout(() => finish(null, null), 10_000)
video.onerror = () => { clearTimeout(timer); finish(null, null) }
video.onloadedmetadata = () => {
const durationMs = Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : null
// Seek slightly in to dodge an all-black first frame.
const target = Math.min(0.1, (video.duration || 1) / 2)
video.onseeked = () => {
clearTimeout(timer)
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth || 640
canvas.height = video.videoHeight || 360
const ctx = canvas.getContext('2d')
if (!ctx) return finish(null, durationMs)
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => finish(blob, durationMs), 'image/jpeg', 0.8)
} catch {
finish(null, durationMs)
}
}
try { video.currentTime = target } catch { clearTimeout(timer); finish(null, durationMs) }
}
})
}
/** True for a File the user picked that should go through the video upload path. */
export function isVideoFile(file: File): boolean {
return typeof file.type === 'string' && file.type.startsWith('video/')
}
@@ -0,0 +1,20 @@
/**
* videoPoster unit tests (#823). The poster-capture path needs a real <video>
* decoder + canvas, which jsdom does not provide, so we cover the pure file-type
* gate here; poster capture is exercised manually / in the browser.
*/
import { describe, it, expect } from 'vitest'
import { isVideoFile } from '../../../src/utils/videoPoster'
describe('isVideoFile', () => {
it('is true for a video MIME type', () => {
expect(isVideoFile(new File([], 'clip.mp4', { type: 'video/mp4' }))).toBe(true)
expect(isVideoFile(new File([], 'clip.webm', { type: 'video/webm' }))).toBe(true)
})
it('is false for images and other files', () => {
expect(isVideoFile(new File([], 'photo.jpg', { type: 'image/jpeg' }))).toBe(false)
expect(isVideoFile(new File([], 'doc.pdf', { type: 'application/pdf' }))).toBe(false)
expect(isVideoFile(new File([], 'noext', { type: '' }))).toBe(false)
})
})
@@ -0,0 +1,66 @@
/**
* trek_photos media_type persistence (#823): a local or provider photo row can
* be registered as a video and the discriminator round-trips.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
// FKs off: this suite only checks media_type persistence, not owner/user integrity.
db.exec('PRAGMA foreign_keys = OFF');
const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => null, isOwner: () => false };
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createUser } from '../../helpers/factories';
import { getOrCreateLocalTrekPhoto, getOrCreateTrekPhoto, resolveTrekPhoto } from '../../../src/services/memories/photoResolverService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
testDb.prepare('DELETE FROM trek_photos').run();
});
afterAll(() => {
testDb.close();
});
describe('trek_photos media_type', () => {
it('migration added media_type (default image) and duration_ms', () => {
const cols = (testDb.prepare("PRAGMA table_info('trek_photos')").all() as { name: string }[]).map(c => c.name);
expect(cols).toContain('media_type');
expect(cols).toContain('duration_ms');
});
it('a local photo defaults to image', () => {
const id = getOrCreateLocalTrekPhoto('journey/a.jpg');
expect(resolveTrekPhoto(id)!.media_type).toBe('image');
});
it('a local video stores media_type=video + duration', () => {
const id = getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200);
const row = resolveTrekPhoto(id)!;
expect(row.media_type).toBe('video');
expect(row.duration_ms).toBe(4200);
expect(row.thumbnail_path).toBe('journey/poster.jpg');
});
it('a provider photo can be registered as video', () => {
const { user } = createUser(testDb);
const id = getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video');
expect(resolveTrekPhoto(id)!.media_type).toBe('video');
});
});