Fix video streaming (Range requests) and add silent video preview to gallery tiles
This commit is contained in:
1 parent
25e5f3d0a6
commit
366072d023
5 files changed
+311
-91
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user