diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx
index fc34ab2..4e31cd5 100644
--- a/app/[...path]/AlbumDetailView.tsx
+++ b/app/[...path]/AlbumDetailView.tsx
@@ -34,9 +34,9 @@ export function AlbumDetailView({
{subtitle}
)}
+ {album.description && {album.description}
}
{images.length} {images.length === 1 ? 'photo' : 'photos'}
- {album.description && ` · ${album.description}`}
diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx
index 5ac46d5..d69bf11 100644
--- a/app/[...path]/PhotoGrid.tsx
+++ b/app/[...path]/PhotoGrid.tsx
@@ -15,9 +15,11 @@ import { FadeIn } from '@/components/FadeIn';
export interface PhotoItem {
id: string;
+ type: 'image' | 'video';
thumbUrl: string;
previewUrl: string;
originalUrl: string;
+ videoUrl?: string;
exifUrl: string;
blurDataURL?: string;
dominantColor?: string;
@@ -173,6 +175,14 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
+ {asset.type === 'video' && (
+
+ )}
{(asset.camera || asset.lens) && (
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx
index 73ad8f6..2019d73 100644
--- a/app/[...path]/page.tsx
+++ b/app/[...path]/page.tsx
@@ -20,6 +20,7 @@ import { PhotoGrid, type PhotoItem } from './PhotoGrid';
import {
imageUrl,
exifUrl,
+ videoUrl,
assetPlaceholder,
assetExifSummary,
assetAspectRatio,
@@ -50,7 +51,7 @@ export async function generateMetadata({ params }: PathPageProps): Promise
a.type === 'IMAGE').length;
+ const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else if (immich.isSubpageSlug(slug)) {
@@ -61,7 +62,7 @@ export async function generateMetadata({ params }: PathPageProps): Promise a.type === 'IMAGE').length;
+ const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else {
@@ -72,7 +73,7 @@ export async function generateMetadata({ params }: PathPageProps): Promise a.type === 'IMAGE').length;
+ const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
}
@@ -89,15 +90,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise a.type === 'IMAGE')
+ .filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((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 {
id: a.id,
- thumbUrl: imageUrl(a.id, 'preview'),
+ type: isVideo ? 'video' : 'image',
+ thumbUrl: imageUrl(a.id, 'thumbnail'),
previewUrl: imageUrl(a.id, 'preview'),
originalUrl: imageUrl(a.id, 'original'),
+ ...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
diff --git a/app/globals.css b/app/globals.css
index 85a44f4..cdd1160 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -846,10 +846,19 @@
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 {
margin-top: 8px;
font-size: 0.9rem;
- color: var(--text-secondary);
+ color: var(--text-muted);
font-weight: 300;
}
@@ -1005,6 +1014,27 @@
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 ───────────────────────────────── */
.skeleton {
background: linear-gradient(
diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx
index 090e88e..a5492f6 100644
--- a/components/Lightbox.tsx
+++ b/components/Lightbox.tsx
@@ -67,10 +67,10 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
}
}, [showExif, current, fetchExif]);
- // Preload adjacent images
+ // Preload adjacent images (skip videos — they stream on demand)
useEffect(() => {
const preload = (index: number) => {
- if (index >= 0 && index < assets.length) {
+ if (index >= 0 && index < assets.length && assets[index].type !== 'video') {
const img = new Image();
img.src = assets[index].previewUrl;
}
@@ -158,25 +158,41 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
- {/* Image */}
+ {/* Image or Video */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-

setImageLoaded(true)}
- onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
- />
+ {current.type === 'video' && current.videoUrl ? (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+
{/* Next button */}