feat: Special Curation feature with waveform editor
- Database: SpecialSong model with startTime field - Backend: API endpoints for Special curation - Admin: Waveform editor with segment markers, zoom, and playback - Game: startTime support in AudioPlayer - UI: Manual save button, hover preview, playback cursor - Migration: Automatic migration on deployment
This commit is contained in:
@@ -690,7 +690,7 @@ export default function AdminPage() {
|
|||||||
fontSize: '0.875rem'
|
fontSize: '0.875rem'
|
||||||
}}>
|
}}>
|
||||||
<span>{special.name} ({special._count?.songs || 0})</span>
|
<span>{special.name} ({special._count?.songs || 0})</span>
|
||||||
<button onClick={() => startEditSpecial(special)} className="btn-primary" style={{ marginRight: '0.5rem' }}>Edit</button>
|
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
|
||||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
|
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
226
app/admin/specials/[id]/page.tsx
Normal file
226
app/admin/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import WaveformEditor from '@/components/WaveformEditor';
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecialSong {
|
||||||
|
id: number;
|
||||||
|
songId: number;
|
||||||
|
startTime: number;
|
||||||
|
order: number | null;
|
||||||
|
song: Song;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Special {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
maxAttempts: number;
|
||||||
|
unlockSteps: string;
|
||||||
|
songs: SpecialSong[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpecialEditorPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const specialId = params.id as string;
|
||||||
|
|
||||||
|
const [special, setSpecial] = useState<Special | null>(null);
|
||||||
|
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSpecial();
|
||||||
|
}, [specialId]);
|
||||||
|
|
||||||
|
const fetchSpecial = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/specials/${specialId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSpecial(data);
|
||||||
|
if (data.songs.length > 0) {
|
||||||
|
setSelectedSongId(data.songs[0].songId);
|
||||||
|
// Initialize pendingStartTime with the current startTime of the first song
|
||||||
|
setPendingStartTime(data.songs[0].startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching special:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTimeChange = (newStartTime: number) => {
|
||||||
|
setPendingStartTime(newStartTime);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!special || !selectedSongId || pendingStartTime === null) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Update local state
|
||||||
|
setSpecial(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
songs: prev.songs.map(ss =>
|
||||||
|
ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setPendingStartTime(null); // Reset pending state after saving
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating start time:', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!special) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>Special not found</p>
|
||||||
|
<button onClick={() => router.push('/admin')}>Back to Admin</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
|
||||||
|
const unlockSteps = JSON.parse(special.unlockSteps);
|
||||||
|
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/admin')}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back to Admin
|
||||||
|
</button>
|
||||||
|
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
Edit Special: {special.name}
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{special.songs.length === 0 ? (
|
||||||
|
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||||
|
<p>No songs assigned to this special yet.</p>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
Go back to the admin dashboard to add songs to this special.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
|
Select Song to Curate
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
||||||
|
{special.songs.map(ss => (
|
||||||
|
<div
|
||||||
|
key={ss.songId}
|
||||||
|
onClick={() => setSelectedSongId(ss.songId)}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
||||||
|
color: selectedSongId === ss.songId ? 'white' : 'black',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
||||||
|
Start: {ss.startTime}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSpecialSong && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
|
Curate: {selectedSpecialSong.song.title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
||||||
|
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasUnsavedChanges || saving}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.5rem',
|
||||||
|
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
||||||
|
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<WaveformEditor
|
||||||
|
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
||||||
|
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
||||||
|
duration={totalDuration}
|
||||||
|
unlockSteps={unlockSteps}
|
||||||
|
onStartTimeChange={handleStartTimeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,11 +12,15 @@ export async function GET() {
|
|||||||
include: {
|
include: {
|
||||||
puzzles: true,
|
puzzles: true,
|
||||||
genres: true,
|
genres: true,
|
||||||
specials: true,
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map to include activation count
|
// Map to include activation count and flatten specials
|
||||||
const songsWithActivations = songs.map(song => ({
|
const songsWithActivations = songs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
@@ -27,7 +31,7 @@ export async function GET() {
|
|||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
puzzles: song.puzzles,
|
puzzles: song.puzzles,
|
||||||
genres: song.genres,
|
genres: song.genres,
|
||||||
specials: song.specials,
|
specials: song.specials.map(ss => ss.special),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(songsWithActivations);
|
return NextResponse.json(songsWithActivations);
|
||||||
@@ -178,16 +182,51 @@ export async function PUT(request: Request) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (specialIds) {
|
// Handle SpecialSong relations separately
|
||||||
data.specials = {
|
if (specialIds !== undefined) {
|
||||||
set: specialIds.map((sId: number) => ({ id: sId }))
|
// First, get current special assignments
|
||||||
};
|
const currentSpecials = await prisma.specialSong.findMany({
|
||||||
|
where: { songId: Number(id) }
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
|
const newSpecialIds = specialIds as number[];
|
||||||
|
|
||||||
|
// Delete removed specials
|
||||||
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
await prisma.specialSong.deleteMany({
|
||||||
|
where: {
|
||||||
|
songId: Number(id),
|
||||||
|
specialId: { in: toDelete }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new specials
|
||||||
|
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await prisma.specialSong.createMany({
|
||||||
|
data: toAdd.map(specialId => ({
|
||||||
|
songId: Number(id),
|
||||||
|
specialId,
|
||||||
|
startTime: 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSong = await prisma.song.update({
|
const updatedSong = await prisma.song.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data,
|
data,
|
||||||
include: { genres: true, specials: true }
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(updatedSong);
|
return NextResponse.json(updatedSong);
|
||||||
|
|||||||
62
app/api/specials/[id]/route.ts
Normal file
62
app/api/specials/[id]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = parseInt(id);
|
||||||
|
|
||||||
|
const special = await prisma.special.findUnique({
|
||||||
|
where: { id: specialId },
|
||||||
|
include: {
|
||||||
|
songs: {
|
||||||
|
include: {
|
||||||
|
song: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
order: 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!special) {
|
||||||
|
return NextResponse.json({ error: 'Special not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(special);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching special:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = parseInt(id);
|
||||||
|
const { name, maxAttempts, unlockSteps } = await request.json();
|
||||||
|
|
||||||
|
const special = await prisma.special.update({
|
||||||
|
where: { id: specialId },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
maxAttempts,
|
||||||
|
unlockSteps: JSON.stringify(unlockSteps)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(special);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating special:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/api/specials/[id]/songs/route.ts
Normal file
89
app/api/specials/[id]/songs/route.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = parseInt(id);
|
||||||
|
const { songId, startTime = 0, order } = await request.json();
|
||||||
|
|
||||||
|
const specialSong = await prisma.specialSong.create({
|
||||||
|
data: {
|
||||||
|
specialId,
|
||||||
|
songId,
|
||||||
|
startTime,
|
||||||
|
order
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
song: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(specialSong);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding song to special:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = parseInt(id);
|
||||||
|
const { songId, startTime, order } = await request.json();
|
||||||
|
|
||||||
|
const specialSong = await prisma.specialSong.update({
|
||||||
|
where: {
|
||||||
|
specialId_songId: {
|
||||||
|
specialId,
|
||||||
|
songId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
startTime,
|
||||||
|
order
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
song: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(specialSong);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating special song:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = parseInt(id);
|
||||||
|
const { songId } = await request.json();
|
||||||
|
|
||||||
|
await prisma.specialSong.delete({
|
||||||
|
where: {
|
||||||
|
specialId_songId: {
|
||||||
|
specialId,
|
||||||
|
songId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing song from special:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
interface AudioPlayerProps {
|
interface AudioPlayerProps {
|
||||||
src: string;
|
src: string;
|
||||||
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
|
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
|
||||||
|
startTime?: number; // Start offset in seconds (for curated specials)
|
||||||
onPlay?: () => void;
|
onPlay?: () => void;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = false }: AudioPlayerProps) {
|
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
@@ -17,7 +18,7 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
audioRef.current.currentTime = 0;
|
audioRef.current.currentTime = startTime;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, unlockedSeconds, autoPlay]);
|
}, [src, unlockedSeconds, startTime, autoPlay]);
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
@@ -54,12 +55,13 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
const current = audioRef.current.currentTime;
|
const current = audioRef.current.currentTime;
|
||||||
const percent = (current / unlockedSeconds) * 100;
|
const elapsed = current - startTime;
|
||||||
|
const percent = (elapsed / unlockedSeconds) * 100;
|
||||||
setProgress(Math.min(percent, 100));
|
setProgress(Math.min(percent, 100));
|
||||||
|
|
||||||
if (current >= unlockedSeconds) {
|
if (elapsed >= unlockedSeconds) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
audioRef.current.currentTime = 0;
|
audioRef.current.currentTime = startTime;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface GameProps {
|
|||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
|
startTime?: number;
|
||||||
} | null;
|
} | null;
|
||||||
genre?: string | null;
|
genre?: string | null;
|
||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
@@ -195,6 +196,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
src={dailyPuzzle.audioUrl}
|
src={dailyPuzzle.audioUrl}
|
||||||
unlockedSeconds={unlockedSeconds}
|
unlockedSeconds={unlockedSeconds}
|
||||||
|
startTime={dailyPuzzle.startTime}
|
||||||
autoPlay={lastAction === 'SKIP'}
|
autoPlay={lastAction === 'SKIP'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
440
components/WaveformEditor.tsx
Normal file
440
components/WaveformEditor.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface WaveformEditorProps {
|
||||||
|
audioUrl: string;
|
||||||
|
startTime: number;
|
||||||
|
duration: number; // Total puzzle duration (e.g., 60s)
|
||||||
|
unlockSteps: number[]; // e.g., [2, 4, 7, 11, 16, 30, 60]
|
||||||
|
onStartTimeChange: (newStartTime: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
|
||||||
|
const [audioDuration, setAudioDuration] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
|
||||||
|
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
|
||||||
|
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
|
||||||
|
const [hoverPreviewTime, setHoverPreviewTime] = useState<number | null>(null); // Preview position on hover
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||||
|
const playbackStartTimeRef = useRef<number>(0); // When playback started
|
||||||
|
const playbackOffsetRef = useRef<number>(0); // Offset in the audio file
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAudio = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(audioUrl);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
|
||||||
|
const buffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
setAudioBuffer(buffer);
|
||||||
|
setAudioDuration(buffer.duration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading audio:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAudio();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (sourceRef.current) {
|
||||||
|
sourceRef.current.stop();
|
||||||
|
}
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioBuffer || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
|
||||||
|
// Calculate visible range based on zoom and offset
|
||||||
|
const visibleDuration = audioDuration / zoom;
|
||||||
|
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
|
||||||
|
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = '#f3f4f6';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw waveform for visible range
|
||||||
|
const data = audioBuffer.getChannelData(0);
|
||||||
|
const samplesPerPixel = Math.ceil((data.length * visibleDuration / audioDuration) / width);
|
||||||
|
const startSample = Math.floor(data.length * visibleStart / audioDuration);
|
||||||
|
const amp = height / 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#4f46e5';
|
||||||
|
for (let i = 0; i < width; i++) {
|
||||||
|
let min = 1.0;
|
||||||
|
let max = -1.0;
|
||||||
|
const sampleIndex = startSample + (i * samplesPerPixel);
|
||||||
|
for (let j = 0; j < samplesPerPixel && sampleIndex + j < data.length; j++) {
|
||||||
|
const datum = data[sampleIndex + j];
|
||||||
|
if (datum < min) min = datum;
|
||||||
|
if (datum > max) max = datum;
|
||||||
|
}
|
||||||
|
ctx.fillRect(i, (1 + min) * amp, 1, Math.max(1, (max - min) * amp));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw selection overlay
|
||||||
|
const selectionStartPx = ((startTime - visibleStart) / visibleDuration) * width;
|
||||||
|
const selectionWidthPx = (duration / visibleDuration) * width;
|
||||||
|
|
||||||
|
if (selectionStartPx + selectionWidthPx > 0 && selectionStartPx < width) {
|
||||||
|
ctx.fillStyle = 'rgba(79, 70, 229, 0.3)';
|
||||||
|
ctx.fillRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height);
|
||||||
|
|
||||||
|
// Draw selection borders
|
||||||
|
ctx.strokeStyle = '#4f46e5';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw segment markers (vertical lines)
|
||||||
|
ctx.strokeStyle = '#ef4444';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
|
||||||
|
let cumulativeTime = 0;
|
||||||
|
unlockSteps.forEach((step, index) => {
|
||||||
|
const segmentTime = startTime + cumulativeTime;
|
||||||
|
const segmentPx = ((segmentTime - visibleStart) / visibleDuration) * width;
|
||||||
|
|
||||||
|
if (segmentPx >= 0 && segmentPx <= width) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(segmentPx, 0);
|
||||||
|
ctx.lineTo(segmentPx, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw segment number
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.fillText(`${index + 1}`, segmentPx + 3, 15);
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cumulativeTime = step;
|
||||||
|
});
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Draw hover preview (semi-transparent)
|
||||||
|
if (hoverPreviewTime !== null) {
|
||||||
|
const previewStartPx = ((hoverPreviewTime - visibleStart) / visibleDuration) * width;
|
||||||
|
const previewWidthPx = (duration / visibleDuration) * width;
|
||||||
|
|
||||||
|
if (previewStartPx + previewWidthPx > 0 && previewStartPx < width) {
|
||||||
|
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)'; // Light green
|
||||||
|
ctx.fillRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height);
|
||||||
|
|
||||||
|
// Draw preview borders
|
||||||
|
ctx.strokeStyle = '#10b981';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
ctx.strokeRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw playback cursor
|
||||||
|
if (playbackPosition !== null) {
|
||||||
|
const cursorPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
|
||||||
|
if (cursorPx >= 0 && cursorPx <= width) {
|
||||||
|
ctx.strokeStyle = '#10b981'; // Green
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cursorPx, 0);
|
||||||
|
ctx.lineTo(cursorPx, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw playhead triangle
|
||||||
|
ctx.fillStyle = '#10b981';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cursorPx, 0);
|
||||||
|
ctx.lineTo(cursorPx - 5, 10);
|
||||||
|
ctx.lineTo(cursorPx + 5, 10);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps, playbackPosition, hoverPreviewTime]);
|
||||||
|
|
||||||
|
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!canvasRef.current || !audioDuration) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const visibleDuration = audioDuration / zoom;
|
||||||
|
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
|
||||||
|
const clickedTime = visibleStart + (x / rect.width) * visibleDuration;
|
||||||
|
|
||||||
|
// Center the selection on the clicked point
|
||||||
|
let newStartTime = clickedTime - (duration / 2);
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
newStartTime = Math.max(0, Math.min(newStartTime, audioDuration - duration));
|
||||||
|
|
||||||
|
onStartTimeChange(Math.floor(newStartTime));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!canvasRef.current || !audioDuration) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const visibleDuration = audioDuration / zoom;
|
||||||
|
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
|
||||||
|
const hoveredTime = visibleStart + (x / rect.width) * visibleDuration;
|
||||||
|
|
||||||
|
// Calculate where the selection would be centered on this point
|
||||||
|
let previewStartTime = hoveredTime - (duration / 2);
|
||||||
|
previewStartTime = Math.max(0, Math.min(previewStartTime, audioDuration - duration));
|
||||||
|
|
||||||
|
setHoverPreviewTime(previewStartTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseLeave = () => {
|
||||||
|
setHoverPreviewTime(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPlayback = () => {
|
||||||
|
sourceRef.current?.stop();
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPlayingSegment(null);
|
||||||
|
setPlaybackPosition(null);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Animation loop for playback cursor
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !audioContextRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!audioContextRef.current || !isPlaying) return;
|
||||||
|
|
||||||
|
const elapsed = audioContextRef.current.currentTime - playbackStartTimeRef.current;
|
||||||
|
const currentPos = playbackOffsetRef.current + elapsed;
|
||||||
|
setPlaybackPosition(currentPos);
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handlePlaySegment = (segmentIndex: number) => {
|
||||||
|
if (!audioBuffer || !audioContextRef.current) return;
|
||||||
|
|
||||||
|
stopPlayback();
|
||||||
|
|
||||||
|
const source = audioContextRef.current.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContextRef.current.destination);
|
||||||
|
|
||||||
|
// Calculate segment start and duration
|
||||||
|
const segmentStart = startTime + (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0);
|
||||||
|
const segmentDuration = unlockSteps[segmentIndex] - (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0);
|
||||||
|
|
||||||
|
playbackStartTimeRef.current = audioContextRef.current.currentTime;
|
||||||
|
playbackOffsetRef.current = segmentStart;
|
||||||
|
|
||||||
|
source.start(0, segmentStart, segmentDuration);
|
||||||
|
sourceRef.current = source;
|
||||||
|
setIsPlaying(true);
|
||||||
|
setPlayingSegment(segmentIndex);
|
||||||
|
setPlaybackPosition(segmentStart);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPlayingSegment(null);
|
||||||
|
setPlaybackPosition(null);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayFull = () => {
|
||||||
|
if (!audioBuffer || !audioContextRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
stopPlayback();
|
||||||
|
} else {
|
||||||
|
const source = audioContextRef.current.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContextRef.current.destination);
|
||||||
|
|
||||||
|
playbackStartTimeRef.current = audioContextRef.current.currentTime;
|
||||||
|
playbackOffsetRef.current = startTime;
|
||||||
|
|
||||||
|
source.start(0, startTime, duration);
|
||||||
|
sourceRef.current = source;
|
||||||
|
setIsPlaying(true);
|
||||||
|
setPlaybackPosition(startTime);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPlaybackPosition(null);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
|
||||||
|
const handleZoomOut = () => setZoom(prev => Math.max(prev / 1.5, 1));
|
||||||
|
const handlePanLeft = () => {
|
||||||
|
const visibleDuration = audioDuration / zoom;
|
||||||
|
setViewOffset(prev => Math.max(0, prev - visibleDuration * 0.2));
|
||||||
|
};
|
||||||
|
const handlePanRight = () => {
|
||||||
|
const visibleDuration = audioDuration / zoom;
|
||||||
|
setViewOffset(prev => Math.min(audioDuration - visibleDuration, prev + visibleDuration * 0.2));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
{/* Zoom and Pan Controls */}
|
||||||
|
<div style={{ marginBottom: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoom <= 1}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: zoom <= 1 ? '#e5e7eb' : '#4f46e5',
|
||||||
|
color: zoom <= 1 ? '#9ca3af' : 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: zoom <= 1 ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoom >= 10}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: zoom >= 10 ? '#e5e7eb' : '#4f46e5',
|
||||||
|
color: zoom >= 10 ? '#9ca3af' : 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: zoom >= 10 ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍+
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#666' }}>Zoom: {zoom.toFixed(1)}x</span>
|
||||||
|
{zoom > 1 && (
|
||||||
|
<>
|
||||||
|
<button onClick={handlePanLeft} style={{ padding: '0.25rem 0.5rem', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button onClick={handlePanRight} style={{ padding: '0.25rem 0.5rem', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={800}
|
||||||
|
height={150}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onMouseMove={handleCanvasMouseMove}
|
||||||
|
onMouseLeave={handleCanvasMouseLeave}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '0.5rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={handlePlayFull}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#4f46e5',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying && playingSegment === null ? '⏸ Pause' : '▶ Play Full Selection'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
Start: {startTime}s | Duration: {duration}s | Total: {Math.floor(audioDuration)}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment Playback Buttons */}
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: '0.875rem', color: '#666', marginRight: '0.5rem' }}>Play Segments:</span>
|
||||||
|
{unlockSteps.map((step, index) => {
|
||||||
|
const segmentStart = index > 0 ? unlockSteps[index - 1] : 0;
|
||||||
|
const segmentDuration = step - segmentStart;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => handlePlaySegment(index)}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.75rem',
|
||||||
|
background: playingSegment === index ? '#ef4444' : '#f3f4f6',
|
||||||
|
color: playingSegment === index ? 'white' : '#374151',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: playingSegment === index ? 'bold' : 'normal'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1} ({segmentDuration}s)
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -146,32 +146,36 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
if (!dailyPuzzle) {
|
||||||
// Get songs available for this special
|
// Get songs available for this special through SpecialSong
|
||||||
const allSongs = await prisma.song.findMany({
|
const specialSongs = await prisma.specialSong.findMany({
|
||||||
where: { specials: { some: { id: special.id } } },
|
where: { specialId: special.id },
|
||||||
include: {
|
include: {
|
||||||
puzzles: {
|
song: {
|
||||||
where: { specialId: special.id }
|
include: {
|
||||||
},
|
puzzles: {
|
||||||
},
|
where: { specialId: special.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allSongs.length === 0) return null;
|
if (specialSongs.length === 0) return null;
|
||||||
|
|
||||||
// Calculate weights
|
// Calculate weights
|
||||||
const weightedSongs = allSongs.map(song => ({
|
const weightedSongs = specialSongs.map(specialSong => ({
|
||||||
song,
|
specialSong,
|
||||||
weight: 1.0 / (song.puzzles.length + 1),
|
weight: 1.0 / (specialSong.song.puzzles.length + 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||||
let random = Math.random() * totalWeight;
|
let random = Math.random() * totalWeight;
|
||||||
let selectedSong = weightedSongs[0].song;
|
let selectedSpecialSong = weightedSongs[0].specialSong;
|
||||||
|
|
||||||
for (const item of weightedSongs) {
|
for (const item of weightedSongs) {
|
||||||
random -= item.weight;
|
random -= item.weight;
|
||||||
if (random <= 0) {
|
if (random <= 0) {
|
||||||
selectedSong = item.song;
|
selectedSpecialSong = item.specialSong;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +184,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||||
data: {
|
data: {
|
||||||
date: today,
|
date: today,
|
||||||
songId: selectedSong.id,
|
songId: selectedSpecialSong.songId,
|
||||||
specialId: special.id
|
specialId: special.id
|
||||||
},
|
},
|
||||||
include: { song: true },
|
include: { song: true },
|
||||||
@@ -198,6 +202,16 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
|
|
||||||
if (!dailyPuzzle) return null;
|
if (!dailyPuzzle) return null;
|
||||||
|
|
||||||
|
// Fetch the startTime from SpecialSong
|
||||||
|
const specialSong = await prisma.specialSong.findUnique({
|
||||||
|
where: {
|
||||||
|
specialId_songId: {
|
||||||
|
specialId: special.id,
|
||||||
|
songId: dailyPuzzle.songId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate puzzle number
|
// Calculate puzzle number
|
||||||
const puzzleCount = await prisma.dailyPuzzle.count({
|
const puzzleCount = await prisma.dailyPuzzle.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -218,7 +232,8 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
special: specialName,
|
special: specialName,
|
||||||
maxAttempts: special.maxAttempts,
|
maxAttempts: special.maxAttempts,
|
||||||
unlockSteps: JSON.parse(special.unlockSteps)
|
unlockSteps: JSON.parse(special.unlockSteps),
|
||||||
|
startTime: specialSong?.startTime || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SpecialSong" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"specialId" INTEGER NOT NULL,
|
||||||
|
"songId" INTEGER NOT NULL,
|
||||||
|
"startTime" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"order" INTEGER,
|
||||||
|
CONSTRAINT "SpecialSong_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "SpecialSong_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate data from _SongToSpecial to SpecialSong
|
||||||
|
INSERT INTO "SpecialSong" ("specialId", "songId", "startTime")
|
||||||
|
SELECT "B" as "specialId", "A" as "songId", 0 as "startTime"
|
||||||
|
FROM "_SongToSpecial";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "_SongToSpecial";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SpecialSong_specialId_songId_key" ON "SpecialSong"("specialId", "songId");
|
||||||
@@ -19,7 +19,7 @@ model Song {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
genres Genre[]
|
genres Genre[]
|
||||||
specials Special[]
|
specials SpecialSong[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Genre {
|
model Genre {
|
||||||
@@ -35,10 +35,22 @@ model Special {
|
|||||||
maxAttempts Int @default(7)
|
maxAttempts Int @default(7)
|
||||||
unlockSteps String // JSON array: "[2,4,7,11,16,30,60]"
|
unlockSteps String // JSON array: "[2,4,7,11,16,30,60]"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
songs Song[]
|
songs SpecialSong[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SpecialSong {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
specialId Int
|
||||||
|
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
|
||||||
|
songId Int
|
||||||
|
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||||
|
startTime Int @default(0) // Start time in seconds
|
||||||
|
order Int? // For manual ordering
|
||||||
|
|
||||||
|
@@unique([specialId, songId])
|
||||||
|
}
|
||||||
|
|
||||||
model DailyPuzzle {
|
model DailyPuzzle {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date String // Format: YYYY-MM-DD
|
date String // Format: YYYY-MM-DD
|
||||||
|
|||||||
Reference in New Issue
Block a user