Fix video streaming (Range requests) and add silent video preview to gallery tiles

This commit is contained in:
Jannik committed 2026-10-03 15:47:48 +02:00
1 parent 25e5f3d0a6
commit 366072d023
5 files changed
+311 -91

No files matched your search

+35 -5
View File
@@ -82,25 +82,52 @@ export async function GET(
// Get file stats for Content-Length
const stat = await fs.promises.stat(filePath);
const fileSize = stat.size;
// Handle Range Requests (CRITICAL for video streaming in Safari/Chrome)
const rangeHeader = request.headers.get('range');
let start = 0;
let end = fileSize - 1;
let isPartial = false;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const partialStart = parts[0];
const partialEnd = parts[1];
start = parseInt(partialStart, 10);
end = partialEnd ? parseInt(partialEnd, 10) : fileSize - 1;
if (start >= fileSize) {
return new NextResponse('Requested range not satisfiable', { status: 416 });
}
isPartial = true;
}
const contentLength = (end - start) + 1;
// Build response headers
const contentType = getSafeContentType(contentTypeExt);
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Content-Length', stat.size.toString());
headers.set('Content-Length', contentLength.toString());
headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
headers.set('Accept-Ranges', 'bytes');
if (isPartial) {
headers.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
}
// SVG: force download to prevent XSS
if (contentType === 'image/svg+xml') {
headers.set('Content-Disposition', `attachment; filename="${path.basename(relativePath)}"`);
}
// Stream the file
const fileStream = fs.createReadStream(filePath);
// Stream the file chunk
const fileStream = fs.createReadStream(filePath, { start, end });
const webStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
@@ -109,7 +136,10 @@ export async function GET(
},
});
return new NextResponse(webStream, { headers });
return new NextResponse(webStream, {
status: isPartial ? 206 : 200,
headers
});
} catch (error) {
console.error('[MediaServe] Error:', error);
return new NextResponse('Internal server error', { status: 500 });
+119
View File
@@ -440,3 +440,122 @@ body {
white-space: nowrap;
border-width: 0;
}
/* Music Player Styles */
.music-player-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
font-family: var(--font-body);
}
.music-player-fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-primary);
color: white;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
}
.music-player-fab:hover {
transform: scale(1.05);
background: var(--color-primary-dark);
}
.music-player-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 16px;
width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex;
flex-direction: column;
gap: 12px;
}
.music-player-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.music-player-info {
display: flex;
flex-direction: column;
}
.music-player-title {
font-weight: 600;
font-size: 1rem;
color: var(--color-text);
margin-bottom: 2px;
}
.icon-btn {
background: transparent;
border: none;
color: var(--color-text);
opacity: 0.7;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease, color 0.2s ease;
}
.icon-btn:hover {
opacity: 1;
}
.music-player-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-top: 8px;
}
.play-btn {
color: var(--color-primary);
opacity: 1;
transform: scale(1.2);
}
.lyrics-btn {
margin-left: auto;
}
.lyrics-btn.active {
color: var(--color-primary);
opacity: 1;
}
.music-player-lyrics {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border);
max-height: 200px;
overflow-y: auto;
font-family: var(--font-display), serif;
font-size: 0.95rem;
line-height: 1.5;
color: var(--color-text);
white-space: pre-wrap;
padding-right: 8px;
}
.lyrics-loading, .lyrics-error {
text-align: center;
color: var(--color-text);
opacity: 0.6;
font-size: 0.9rem;
padding: 10px 0;
}
+5 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import './globals.css';
import MusicPlayer from '@/components/MusicPlayer';
export const metadata: Metadata = {
title: 'Nele & Till - Hochzeit',
@@ -18,7 +19,10 @@ export default function RootLayout({
}) {
return (
<html lang="de">
<body>{children}</body>
<body>
{children}
<MusicPlayer />
</body>
</html>
);
}
+125 -26
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useRef, useCallback } from 'react';
import React, { useEffect, useRef, useCallback, useState } from 'react';
import LikeButton from './LikeButton';
import ShareButton from './ShareButton';
@@ -33,6 +33,10 @@ export default function FullscreenViewer({
const isAudio = item.mediaType === 'AUDIO';
const touchStartX = useRef<number | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const [lyrics, setLyrics] = useState<string | null>(null);
const [loadingLyrics, setLoadingLyrics] = useState(false);
// Update URL to reflect current media
useEffect(() => {
window.history.replaceState(
@@ -42,6 +46,37 @@ export default function FullscreenViewer({
);
}, [item.relativePath]);
// Pause background music if opening video or audio
useEffect(() => {
if (isVideo || isAudio) {
window.dispatchEvent(new Event('pause-background-music'));
}
}, [isVideo, isAudio, item.relativePath]);
// Reset lyrics state when item changes
useEffect(() => {
setShowLyrics(false);
setLyrics(null);
}, [item.relativePath]);
// Fetch lyrics when requested
useEffect(() => {
if (isAudio && showLyrics && !lyrics && !loadingLyrics) {
setLoadingLyrics(true);
fetch(`/api/music/lyrics?path=${encodeURIComponent(item.relativePath)}`)
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.lyrics) {
setLyrics(data.lyrics);
} else {
setLyrics("Kein Songtext verfügbar.");
}
})
.catch(() => setLyrics("Fehler beim Laden des Songtexts."))
.finally(() => setLoadingLyrics(false));
}
}, [isAudio, showLyrics, lyrics, loadingLyrics, item.relativePath]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -62,6 +97,8 @@ export default function FullscreenViewer({
}, []);
const handlePointerDown = (e: React.PointerEvent) => {
// Only capture swipes if we are not scrolling a lyrics container
if ((e.target as HTMLElement).closest('.lyrics-scroll-container')) return;
touchStartX.current = e.clientX;
};
@@ -121,7 +158,7 @@ export default function FullscreenViewer({
/>
) : isAudio ? (
<div
className="viewer-content"
className={`viewer-content audio-card ${showLyrics ? 'has-lyrics' : ''}`}
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
@@ -131,45 +168,107 @@ export default function FullscreenViewer({
background: 'rgba(30, 36, 28, 0.95)',
border: '1px solid rgba(124, 154, 110, 0.4)',
borderRadius: '16px',
padding: '40px 30px',
maxWidth: '460px',
padding: showLyrics ? '30px' : '40px 30px',
maxWidth: '560px',
width: '90%',
maxHeight: '90vh',
boxShadow: '0 20px 50px rgba(0,0,0,0.6)',
color: '#fff',
textAlign: 'center',
}}
>
<div
style={{
width: '100px',
height: '100px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
boxShadow: '0 8px 24px rgba(0,0,0,0.3)',
}}
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
</div>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.5rem', marginBottom: '8px', color: '#fff' }}>
{!showLyrics && (
<div
style={{
width: '100px',
height: '100px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
boxShadow: '0 8px 24px rgba(0,0,0,0.3)',
flexShrink: 0,
}}
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
</div>
)}
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.5rem', marginBottom: '8px', color: '#fff', flexShrink: 0 }}>
{item.filename.replace(/\.[^/.]+$/, '')}
</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem', marginBottom: '24px' }}>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem', marginBottom: '24px', flexShrink: 0 }}>
Nele & Till • Hochzeit
</p>
<audio
src={`/api/media/${item.relativePath}`}
controls
autoPlay
style={{ width: '100%', outline: 'none', borderRadius: '8px' }}
style={{ width: '100%', outline: 'none', borderRadius: '8px', flexShrink: 0 }}
/>
<button
onClick={() => setShowLyrics(!showLyrics)}
style={{
marginTop: '20px',
background: 'transparent',
border: '1px solid var(--color-primary)',
color: 'var(--color-primary)',
padding: '8px 16px',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s ease',
flexShrink: 0,
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'var(--color-primary)';
e.currentTarget.style.color = '#fff';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-primary)';
}}
>
{showLyrics ? 'Songtext verbergen' : 'Songtext anzeigen'}
</button>
{showLyrics && (
<div
className="lyrics-scroll-container"
style={{
marginTop: '20px',
width: '100%',
overflowY: 'auto',
flexGrow: 1,
background: 'rgba(0,0,0,0.2)',
borderRadius: '8px',
padding: '20px',
textAlign: 'left',
}}
>
{loadingLyrics ? (
<p style={{ textAlign: 'center', opacity: 0.6 }}>Lade Songtext...</p>
) : (
<pre style={{
fontFamily: 'var(--font-display), serif',
whiteSpace: 'pre-wrap',
fontSize: '1.1rem',
lineHeight: '1.6',
margin: 0
}}>
{lyrics}
</pre>
)}
</div>
)}
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
+27 -59
View File
@@ -72,81 +72,46 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
data-index={index}
title={item.filename}
>
{/*
This padding block forces the container to strictly maintain its aspect ratio.
Since flexbox assigns the width, the height is automatically derived from it.
*/}
<div style={{ display: 'block', paddingTop: `${(1 / displayRatio) * 100}%` }} />
{isVisible && (
<>
{isAudio ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #4A5D40 0%, #2F3E28 100%)',
color: '#fff',
padding: '16px',
textAlign: 'center',
}}
ref={(el) => {
if (el) el.parentElement?.classList.remove('loading-skeleton');
}}
>
<div style={{
width: '56px',
height: '56px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '10px',
backdropFilter: 'blur(4px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<div className="audio-tile-placeholder" onLoad={() => {
if (tileRef.current) tileRef.current.classList.remove('loading-skeleton');
}}>
<span className="audio-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
</div>
<div style={{
fontFamily: 'var(--font-display)',
fontSize: '1.1rem',
fontWeight: 600,
letterSpacing: '0.02em',
marginBottom: '4px',
lineHeight: 1.2,
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}>
{songTitle}
</div>
<span style={{
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
opacity: 0.8,
}}>
Hochzeitslied
</span>
</div>
) : isVideo ? (
<video
src={`/api/media/${item.relativePath}?type=preview`}
poster={`/api/media/${item.relativePath}?type=poster`}
autoPlay
muted
loop
playsInline
onLoadedData={(e) => {
const target = e.target;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={isVideo ? `/api/media/${item.relativePath}?type=poster` : `/api/media/${item.relativePath}?type=thumb`}
src={`/api/media/${item.relativePath}?type=thumb`}
alt={item.filename}
loading="lazy"
onLoad={(e) => {
const target = e.target as HTMLImageElement;
const target = e.target;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
)}
{(isVideo || isAudio) && (
<div className="play-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none">
@@ -154,9 +119,12 @@ export default function MediaTile({ item, index, onClick, votingOpen }: MediaTil
</svg>
</div>
)}
{votingOpen && (
<div className="tile-like-wrapper">
<LikeButton mediaId={item.id} initialLikes={likesCount} className="tile-like-btn" />
{votingOpen && likesCount > 0 && (
<div className="badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>{likesCount}</span>
</div>
)}
</>