Auto-Commit: 2026-10-03 16:52:16
This commit is contained in:
1 parent
6dd4a45e1c
commit
f0a1a40d49
7 files changed
+496
No files matched your search
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Fix route.ts
|
||||
let route = fs.readFileSync('src/app/api/media/[...path]/route.ts', 'utf8');
|
||||
route = route.replace(
|
||||
"fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));",
|
||||
"fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer)));"
|
||||
);
|
||||
fs.writeFileSync('src/app/api/media/[...path]/route.ts', route, 'utf8');
|
||||
|
||||
// Fix MediaTile.tsx
|
||||
let tile = fs.readFileSync('src/components/MediaTile.tsx', 'utf8');
|
||||
tile = tile.replace(
|
||||
"const target = e.target;",
|
||||
"const target = e.target as HTMLVideoElement;"
|
||||
).replace(
|
||||
"const target = e.target;",
|
||||
"const target = e.target as HTMLImageElement;"
|
||||
);
|
||||
fs.writeFileSync('src/components/MediaTile.tsx', tile, 'utf8');
|
||||
@@ -0,0 +1,73 @@
|
||||
const fs = require('fs');
|
||||
|
||||
let route = fs.readFileSync('src/app/api/media/[...path]/route.ts', 'utf8');
|
||||
|
||||
const rangeLogic = `
|
||||
// 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', 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 chunk
|
||||
const fileStream = fs.createReadStream(filePath, { start, end });
|
||||
const webStream = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
|
||||
fileStream.on('end', () => controller.close());
|
||||
fileStream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
fileStream.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(webStream, {
|
||||
status: isPartial ? 206 : 200,
|
||||
headers
|
||||
});
|
||||
`;
|
||||
|
||||
route = route.replace(
|
||||
/\/\/ Get file stats for Content-Length[\s\S]+return new NextResponse\(webStream, \{ headers \}\);/,
|
||||
rangeLogic.trim()
|
||||
);
|
||||
|
||||
fs.writeFileSync('src/app/api/media/[...path]/route.ts', route, 'utf8');
|
||||
@@ -0,0 +1,50 @@
|
||||
const fs = require('fs');
|
||||
let tile = fs.readFileSync('src/components/MediaTile.tsx', 'utf8');
|
||||
|
||||
const oldRender = `{isVisible && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={isVideo ? \`/api/media/\${item.relativePath}?type=poster\` : \`/api/media/\${item.relativePath}?type=thumb\`}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.parentElement?.classList.remove('loading-skeleton');
|
||||
}}
|
||||
/>
|
||||
{isVideo && (
|
||||
<div className="play-icon">`;
|
||||
|
||||
const newRender = `{isVisible && (
|
||||
<>
|
||||
{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 as HTMLVideoElement;
|
||||
target.parentElement?.classList.remove('loading-skeleton');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={\`/api/media/\${item.relativePath}?type=thumb\`}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.parentElement?.classList.remove('loading-skeleton');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<div className="play-icon">`;
|
||||
|
||||
tile = tile.replace(oldRender, newRender);
|
||||
fs.writeFileSync('src/components/MediaTile.tsx', tile, 'utf8');
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require('fs');
|
||||
let tile = fs.readFileSync('src/components/MediaTile.tsx', 'utf8');
|
||||
|
||||
const regex = /return\s*\(\s*<div[\s\S]*?<\/div>\s*\);/;
|
||||
|
||||
const newReturn = `return (
|
||||
<div
|
||||
className="media-tile loading-skeleton"
|
||||
ref={tileRef}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
data-index={index}
|
||||
title={item.filename}
|
||||
>
|
||||
{isVisible && (
|
||||
<>
|
||||
{isAudio ? (
|
||||
<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>
|
||||
</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={\`/api/media/\${item.relativePath}?type=thumb\`}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
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">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);`;
|
||||
|
||||
tile = tile.replace(regex, newReturn);
|
||||
fs.writeFileSync('src/components/MediaTile.tsx', tile, 'utf8');
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const mediaPath = searchParams.get('path');
|
||||
|
||||
if (!mediaPath) {
|
||||
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Protect against directory traversal
|
||||
const normalizedPath = path.normalize(mediaPath).replace(/^(\.\.[\/\\])+/, '');
|
||||
if (normalizedPath.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid path parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const title = path.basename(normalizedPath).replace(/\.[^/.]+$/, "");
|
||||
const dir = path.dirname(normalizedPath);
|
||||
|
||||
const mediaRoot = process.env.MEDIA_ROOT || path.join(process.cwd(), 'media');
|
||||
const txtPath = path.join(mediaRoot, dir, title + '.txt');
|
||||
|
||||
if (!fs.existsSync(txtPath)) {
|
||||
return NextResponse.json({ error: 'Lyrics not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const lyricsContent = await fs.promises.readFile(txtPath, 'utf-8');
|
||||
|
||||
return NextResponse.json({
|
||||
title,
|
||||
lyrics: lyricsContent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching lyrics:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const songs = await prisma.mediaItem.findMany({
|
||||
where: {
|
||||
mediaType: 'AUDIO',
|
||||
published: true,
|
||||
},
|
||||
orderBy: {
|
||||
filename: 'asc', // Or sortOrder
|
||||
},
|
||||
});
|
||||
|
||||
const mediaRoot = process.env.MEDIA_ROOT || path.join(process.cwd(), 'media');
|
||||
|
||||
const result = songs.map((song) => {
|
||||
const title = song.filename.replace(/\.[^/.]+$/, "");
|
||||
|
||||
const txtPath = path.join(mediaRoot, song.directory, title + '.txt');
|
||||
const hasLyrics = fs.existsSync(txtPath);
|
||||
|
||||
return {
|
||||
id: song.id,
|
||||
title,
|
||||
relativePath: song.relativePath,
|
||||
duration: song.duration,
|
||||
hasLyrics
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching music:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
type Song = {
|
||||
id: number;
|
||||
title: string;
|
||||
relativePath: string;
|
||||
duration: number | null;
|
||||
hasLyrics: boolean;
|
||||
};
|
||||
|
||||
export default function MusicPlayer() {
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
const [lyrics, setLyrics] = useState<string | null>(null);
|
||||
const [isLoadingLyrics, setIsLoadingLyrics] = useState(false);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/music')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
setSongs(data);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Error fetching music:", err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePauseEvent = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pause-background-music', handlePauseEvent);
|
||||
return () => window.removeEventListener('pause-background-music', handlePauseEvent);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
if (isPlaying) {
|
||||
audio.play().catch(e => console.error("Playback prevented", e));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
}, [isPlaying, currentIndex]);
|
||||
|
||||
const loadLyrics = async (song: Song) => {
|
||||
setIsLoadingLyrics(true);
|
||||
try {
|
||||
const res = await fetch(`/api/music/lyrics?path=${encodeURIComponent(song.relativePath)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLyrics(data.lyrics);
|
||||
} else {
|
||||
setLyrics(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setLyrics(null);
|
||||
}
|
||||
setIsLoadingLyrics(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentSong = songs[currentIndex];
|
||||
if (currentSong && currentSong.hasLyrics && showLyrics) {
|
||||
loadLyrics(currentSong);
|
||||
} else {
|
||||
setLyrics(null);
|
||||
}
|
||||
}, [currentIndex, showLyrics, songs]);
|
||||
|
||||
const nextSong = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % songs.length);
|
||||
};
|
||||
|
||||
const prevSong = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + songs.length) % songs.length);
|
||||
};
|
||||
|
||||
if (songs.length === 0) return null;
|
||||
|
||||
const currentSong = songs[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="music-player-container">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={`/media/${currentSong.relativePath}`}
|
||||
onEnded={nextSong}
|
||||
/>
|
||||
|
||||
{isMinimized ? (
|
||||
<button
|
||||
className="music-player-fab"
|
||||
onClick={() => setIsMinimized(false)}
|
||||
title="Hochzeitsmusik"
|
||||
>
|
||||
{/* Note Icon */}
|
||||
<svg width="24" height="24" 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>
|
||||
</button>
|
||||
) : (
|
||||
<div className="music-player-card">
|
||||
<div className="music-player-header">
|
||||
<div className="music-player-info">
|
||||
<span className="music-player-title">{currentSong.title}</span>
|
||||
</div>
|
||||
<button className="icon-btn" onClick={() => setIsMinimized(true)}>
|
||||
{/* Close / Minimize Icon */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="music-player-controls">
|
||||
<button className="icon-btn" onClick={prevSong}>
|
||||
{/* Prev Icon */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="19 20 9 12 19 4 19 20"></polygon>
|
||||
<line x1="5" y1="19" x2="5" y2="5"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="icon-btn play-btn" onClick={() => setIsPlaying(!isPlaying)}>
|
||||
{isPlaying ? (
|
||||
/* Pause Icon */
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="6" y="4" width="4" height="16"></rect>
|
||||
<rect x="14" y="4" width="4" height="16"></rect>
|
||||
</svg>
|
||||
) : (
|
||||
/* Play Icon */
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button className="icon-btn" onClick={nextSong}>
|
||||
{/* Next Icon */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="5 4 15 12 5 20 5 4"></polygon>
|
||||
<line x1="19" y1="5" x2="19" y2="19"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{currentSong.hasLyrics && (
|
||||
<button
|
||||
className={`icon-btn lyrics-btn ${showLyrics ? 'active' : ''}`}
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
title="Songtext anzeigen"
|
||||
>
|
||||
{/* Lyrics / Text Icon */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
<polyline points="10 9 9 9 8 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLyrics && (
|
||||
<div className="music-player-lyrics">
|
||||
{isLoadingLyrics ? (
|
||||
<div className="lyrics-loading">Lade Text...</div>
|
||||
) : lyrics ? (
|
||||
<pre>{lyrics}</pre>
|
||||
) : (
|
||||
<div className="lyrics-error">Kein Text verfügbar</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user