mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -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) }}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user