feat: add video playback support in lightbox and photo grid
- Lightbox renders <video> with controls for video assets - PhotoGrid shows video indicator overlay on video thumbnails - Skip video preloading in lightbox (streams on demand) - Add video-related CSS styles - Update catch-all page to pass video URLs to components
This commit is contained in:
@@ -34,9 +34,9 @@ export function AlbumDetailView({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||||
<p className="album-header__meta">
|
<p className="album-header__meta">
|
||||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||||
{album.description && ` · ${album.description}`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
|
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { FadeIn } from '@/components/FadeIn';
|
|||||||
|
|
||||||
export interface PhotoItem {
|
export interface PhotoItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
type: 'image' | 'video';
|
||||||
thumbUrl: string;
|
thumbUrl: string;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
|
videoUrl?: string;
|
||||||
exifUrl: string;
|
exifUrl: string;
|
||||||
blurDataURL?: string;
|
blurDataURL?: string;
|
||||||
dominantColor?: string;
|
dominantColor?: string;
|
||||||
@@ -173,6 +175,14 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
|||||||
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
|
{asset.type === 'video' && (
|
||||||
|
<div className="photo-grid__item-play" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="12" fillOpacity="0.55" />
|
||||||
|
<polygon points="10,8 10,16 17,12" fill="white" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(asset.camera || asset.lens) && (
|
{(asset.camera || asset.lens) && (
|
||||||
<div className="photo-grid__item-exif" aria-hidden="true">
|
<div className="photo-grid__item-exif" aria-hidden="true">
|
||||||
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { PhotoGrid, type PhotoItem } from './PhotoGrid';
|
|||||||
import {
|
import {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
exifUrl,
|
exifUrl,
|
||||||
|
videoUrl,
|
||||||
assetPlaceholder,
|
assetPlaceholder,
|
||||||
assetExifSummary,
|
assetExifSummary,
|
||||||
assetAspectRatio,
|
assetAspectRatio,
|
||||||
@@ -50,7 +51,7 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
|||||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||||
if (album) {
|
if (album) {
|
||||||
title = album.albumName;
|
title = album.albumName;
|
||||||
const count = album.assets.filter((a) => a.type === 'IMAGE').length;
|
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
} else if (immich.isSubpageSlug(slug)) {
|
} else if (immich.isSubpageSlug(slug)) {
|
||||||
@@ -61,7 +62,7 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
|||||||
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
||||||
if (album) {
|
if (album) {
|
||||||
title = album.albumName;
|
title = album.albumName;
|
||||||
const count = album.assets.filter((a) => a.type === 'IMAGE').length;
|
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -72,7 +73,7 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
|||||||
const album = await immich.getAlbumBySlug(slug);
|
const album = await immich.getAlbumBySlug(slug);
|
||||||
if (album) {
|
if (album) {
|
||||||
title = album.albumName;
|
title = album.albumName;
|
||||||
const count = album.assets.filter((a) => a.type === 'IMAGE').length;
|
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,15 +90,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
|||||||
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
|
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
|
||||||
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||||
return assets
|
return assets
|
||||||
.filter((a) => a.type === 'IMAGE')
|
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
const ph = assetPlaceholder(a);
|
const ph = assetPlaceholder(a);
|
||||||
const exif = showExif ? assetExifSummary(a) : undefined;
|
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||||
|
const isVideo = a.type === 'VIDEO';
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
thumbUrl: imageUrl(a.id, 'preview'),
|
type: isVideo ? 'video' : 'image',
|
||||||
|
thumbUrl: imageUrl(a.id, 'thumbnail'),
|
||||||
previewUrl: imageUrl(a.id, 'preview'),
|
previewUrl: imageUrl(a.id, 'preview'),
|
||||||
originalUrl: imageUrl(a.id, 'original'),
|
originalUrl: imageUrl(a.id, 'original'),
|
||||||
|
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
|
||||||
exifUrl: exifUrl(a.id),
|
exifUrl: exifUrl(a.id),
|
||||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||||
...(exif ?? {}),
|
...(exif ?? {}),
|
||||||
|
|||||||
@@ -846,10 +846,19 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.album-header__description {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
.album-header__meta {
|
.album-header__meta {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,6 +1014,27 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-grid__item-play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid__item-play svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid__item:hover .photo-grid__item-play svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Loading States ───────────────────────────────── */
|
/* ── Loading States ───────────────────────────────── */
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
|||||||
}
|
}
|
||||||
}, [showExif, current, fetchExif]);
|
}, [showExif, current, fetchExif]);
|
||||||
|
|
||||||
// Preload adjacent images
|
// Preload adjacent images (skip videos — they stream on demand)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preload = (index: number) => {
|
const preload = (index: number) => {
|
||||||
if (index >= 0 && index < assets.length) {
|
if (index >= 0 && index < assets.length && assets[index].type !== 'video') {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = assets[index].previewUrl;
|
img.src = assets[index].previewUrl;
|
||||||
}
|
}
|
||||||
@@ -158,25 +158,41 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image or Video */}
|
||||||
<div
|
<div
|
||||||
className={styles.imageContainer}
|
className={styles.imageContainer}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: current.dominantColor || '#000',
|
backgroundColor: current.dominantColor || '#000',
|
||||||
backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined,
|
backgroundImage:
|
||||||
|
current.type !== 'video' && current.blurDataURL
|
||||||
|
? `url(${current.blurDataURL})`
|
||||||
|
: undefined,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{current.type === 'video' && current.videoUrl ? (
|
||||||
<img
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
|
<video
|
||||||
src={current.previewUrl}
|
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
|
||||||
alt=""
|
src={current.videoUrl}
|
||||||
draggable={false}
|
controls
|
||||||
onLoad={() => setImageLoaded(true)}
|
autoPlay={false}
|
||||||
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
|
playsInline
|
||||||
/>
|
onCanPlay={() => setImageLoaded(true)}
|
||||||
|
onError={() => console.error(`[Lightbox] Failed to load video: ${current.videoUrl}`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
|
||||||
|
src={current.previewUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next button */}
|
{/* Next button */}
|
||||||
|
|||||||
Reference in New Issue
Block a user