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
|
// Get file stats for Content-Length
|
||||||
const stat = await fs.promises.stat(filePath);
|
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
|
// Build response headers
|
||||||
const contentType = getSafeContentType(contentTypeExt);
|
const contentType = getSafeContentType(contentTypeExt);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set('Content-Type', contentType);
|
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('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
|
||||||
headers.set('Accept-Ranges', 'bytes');
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
if (isPartial) {
|
||||||
|
headers.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||||
|
}
|
||||||
|
|
||||||
// SVG: force download to prevent XSS
|
// SVG: force download to prevent XSS
|
||||||
if (contentType === 'image/svg+xml') {
|
if (contentType === 'image/svg+xml') {
|
||||||
headers.set('Content-Disposition', `attachment; filename="${path.basename(relativePath)}"`);
|
headers.set('Content-Disposition', `attachment; filename="${path.basename(relativePath)}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the file
|
// Stream the file chunk
|
||||||
const fileStream = fs.createReadStream(filePath);
|
const fileStream = fs.createReadStream(filePath, { start, end });
|
||||||
const webStream = new ReadableStream({
|
const webStream = new ReadableStream({
|
||||||
start(controller) {
|
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('end', () => controller.close());
|
||||||
fileStream.on('error', (err) => controller.error(err));
|
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) {
|
} catch (error) {
|
||||||
console.error('[MediaServe] Error:', error);
|
console.error('[MediaServe] Error:', error);
|
||||||
return new NextResponse('Internal server error', { status: 500 });
|
return new NextResponse('Internal server error', { status: 500 });
|
||||||
|
|||||||
@@ -440,3 +440,122 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-width: 0;
|
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 type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import MusicPlayer from '@/components/MusicPlayer';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Nele & Till - Hochzeit',
|
title: 'Nele & Till - Hochzeit',
|
||||||
@@ -18,7 +19,10 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
{children}
|
||||||
|
<MusicPlayer />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useRef, useCallback } from 'react';
|
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import LikeButton from './LikeButton';
|
import LikeButton from './LikeButton';
|
||||||
import ShareButton from './ShareButton';
|
import ShareButton from './ShareButton';
|
||||||
|
|
||||||
@@ -33,6 +33,10 @@ export default function FullscreenViewer({
|
|||||||
const isAudio = item.mediaType === 'AUDIO';
|
const isAudio = item.mediaType === 'AUDIO';
|
||||||
const touchStartX = useRef<number | null>(null);
|
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
|
// Update URL to reflect current media
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
@@ -42,6 +46,37 @@ export default function FullscreenViewer({
|
|||||||
);
|
);
|
||||||
}, [item.relativePath]);
|
}, [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
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -62,6 +97,8 @@ export default function FullscreenViewer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent) => {
|
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;
|
touchStartX.current = e.clientX;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +158,7 @@ export default function FullscreenViewer({
|
|||||||
/>
|
/>
|
||||||
) : isAudio ? (
|
) : isAudio ? (
|
||||||
<div
|
<div
|
||||||
className="viewer-content"
|
className={`viewer-content audio-card ${showLyrics ? 'has-lyrics' : ''}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -131,45 +168,107 @@ export default function FullscreenViewer({
|
|||||||
background: 'rgba(30, 36, 28, 0.95)',
|
background: 'rgba(30, 36, 28, 0.95)',
|
||||||
border: '1px solid rgba(124, 154, 110, 0.4)',
|
border: '1px solid rgba(124, 154, 110, 0.4)',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
padding: '40px 30px',
|
padding: showLyrics ? '30px' : '40px 30px',
|
||||||
maxWidth: '460px',
|
maxWidth: '560px',
|
||||||
width: '90%',
|
width: '90%',
|
||||||
|
maxHeight: '90vh',
|
||||||
boxShadow: '0 20px 50px rgba(0,0,0,0.6)',
|
boxShadow: '0 20px 50px rgba(0,0,0,0.6)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{!showLyrics && (
|
||||||
style={{
|
<div
|
||||||
width: '100px',
|
style={{
|
||||||
height: '100px',
|
width: '100px',
|
||||||
borderRadius: '50%',
|
height: '100px',
|
||||||
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
|
borderRadius: '50%',
|
||||||
display: 'flex',
|
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: '20px',
|
justifyContent: 'center',
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.3)',
|
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>
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="18" cy="16" r="3"></circle>
|
<path d="M9 18V5l12-2v13"></path>
|
||||||
</svg>
|
<circle cx="6" cy="18" r="3"></circle>
|
||||||
</div>
|
<circle cx="18" cy="16" r="3"></circle>
|
||||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.5rem', marginBottom: '8px', color: '#fff' }}>
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.5rem', marginBottom: '8px', color: '#fff', flexShrink: 0 }}>
|
||||||
{item.filename.replace(/\.[^/.]+$/, '')}
|
{item.filename.replace(/\.[^/.]+$/, '')}
|
||||||
</h2>
|
</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
|
Nele & Till • Hochzeit
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
src={`/api/media/${item.relativePath}`}
|
src={`/api/media/${item.relativePath}`}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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}
|
data-index={index}
|
||||||
title={item.filename}
|
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 && (
|
{isVisible && (
|
||||||
<>
|
<>
|
||||||
{isAudio ? (
|
{isAudio ? (
|
||||||
<div
|
<div className="audio-tile-placeholder" onLoad={() => {
|
||||||
style={{
|
if (tileRef.current) tileRef.current.classList.remove('loading-skeleton');
|
||||||
position: 'absolute',
|
}}>
|
||||||
inset: 0,
|
<span className="audio-icon">
|
||||||
display: 'flex',
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
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">
|
|
||||||
<path d="M9 18V5l12-2v13"></path>
|
<path d="M9 18V5l12-2v13"></path>
|
||||||
<circle cx="6" cy="18" r="3"></circle>
|
<circle cx="6" cy="18" r="3"></circle>
|
||||||
<circle cx="18" cy="16" r="3"></circle>
|
<circle cx="18" cy="16" r="3"></circle>
|
||||||
</svg>
|
</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>
|
</span>
|
||||||
</div>
|
</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 */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<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}
|
alt={item.filename}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target;
|
||||||
target.parentElement?.classList.remove('loading-skeleton');
|
target.parentElement?.classList.remove('loading-skeleton');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isVideo || isAudio) && (
|
{(isVideo || isAudio) && (
|
||||||
<div className="play-icon">
|
<div className="play-icon">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{votingOpen && (
|
{votingOpen && likesCount > 0 && (
|
||||||
<div className="tile-like-wrapper">
|
<div className="badge">
|
||||||
<LikeButton mediaId={item.id} initialLikes={likesCount} className="tile-like-btn" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in new issue
Block a user