Correct files to integrate with the site for a good audio to a/v osciloscope converter
Now has to be only implemented in the actual website
This commit is contained in:
Executable
+78
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Upload, Music } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AudioUploaderProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
isLoading: boolean;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export function AudioUploader({ onFileSelect, isLoading, fileName }: AudioUploaderProps) {
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-2 border-dashed border-primary/40 rounded-lg p-8 text-center",
|
||||
"hover:border-primary/70 transition-all duration-300 cursor-pointer",
|
||||
"bg-secondary/20 hover:bg-secondary/30",
|
||||
isLoading && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileInput}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{fileName ? (
|
||||
<>
|
||||
<Music className="w-12 h-12 text-primary phosphor-glow" />
|
||||
<div>
|
||||
<p className="text-lg font-crt text-primary text-glow">{fileName}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Click or drop to replace</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-12 h-12 text-primary/60" />
|
||||
<div>
|
||||
<p className="text-lg font-crt text-primary/80">Drop audio file here</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">or click to browse</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">MP3, WAV, FLAC, OGG supported</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 rounded-lg">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Regular → Executable
+122
-288
@@ -1,308 +1,142 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Play, Download, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Mic, Radio, Move, Upload, Play, Pause, Square, Music, Video, Download, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type { ExportStage } from '@/hooks/useOfflineVideoExport';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
|
||||
|
||||
interface ControlPanelProps {
|
||||
mode: 'normal' | 'xy';
|
||||
onModeChange: (mode: 'normal' | 'xy') => void;
|
||||
isActive: boolean;
|
||||
mode: OscilloscopeMode;
|
||||
onModeChange: (mode: OscilloscopeMode) => void;
|
||||
canGenerate: boolean;
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
exportedUrl: string | null;
|
||||
onGenerate: () => void;
|
||||
onReset: () => void;
|
||||
isPlaying: boolean;
|
||||
source: 'microphone' | 'file' | null;
|
||||
fileName: string | null;
|
||||
onStartMicrophone: () => void;
|
||||
onLoadAudioFile: (file: File) => void;
|
||||
onTogglePlayPause: () => void;
|
||||
onStop: () => void;
|
||||
onGainChange: (value: number) => void;
|
||||
error: string | null;
|
||||
isExporting: boolean;
|
||||
exportProgress: number;
|
||||
exportStage: ExportStage;
|
||||
exportFps: number;
|
||||
onExportVideo: (format: 'webm' | 'mp4') => void;
|
||||
onCancelExport: () => void;
|
||||
onPreview: () => void;
|
||||
canPreview: boolean;
|
||||
}
|
||||
|
||||
export const ControlPanel = ({
|
||||
export function ControlPanel({
|
||||
mode,
|
||||
onModeChange,
|
||||
isActive,
|
||||
canGenerate,
|
||||
isGenerating,
|
||||
progress,
|
||||
exportedUrl,
|
||||
onGenerate,
|
||||
onReset,
|
||||
isPlaying,
|
||||
source,
|
||||
fileName,
|
||||
onStartMicrophone,
|
||||
onLoadAudioFile,
|
||||
onTogglePlayPause,
|
||||
onStop,
|
||||
onGainChange,
|
||||
error,
|
||||
isExporting,
|
||||
exportProgress,
|
||||
exportStage,
|
||||
exportFps,
|
||||
onExportVideo,
|
||||
onCancelExport,
|
||||
}: ControlPanelProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onLoadAudioFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
if (isExporting) {
|
||||
onCancelExport();
|
||||
} else {
|
||||
setShowExportDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormatSelect = (format: 'webm' | 'mp4') => {
|
||||
setShowExportDialog(false);
|
||||
onExportVideo(format);
|
||||
};
|
||||
|
||||
onPreview,
|
||||
canPreview,
|
||||
}: ControlPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5 p-5 bg-bezel rounded-lg border border-border">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-300 ${
|
||||
isActive
|
||||
? 'bg-primary shadow-[0_0_10px_hsl(var(--primary))]'
|
||||
: 'bg-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground uppercase tracking-wider">
|
||||
{isExporting ? 'Exporting' : isActive ? (source === 'microphone' ? 'Mic Active' : 'Playing') : 'Standby'}
|
||||
</span>
|
||||
{isExporting && (
|
||||
<div className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Source */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Input Source
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className={`w-full justify-start ${source === 'microphone' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
|
||||
onClick={onStartMicrophone}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
Microphone
|
||||
</Button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className={`w-full justify-start ${source === 'file' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Load File
|
||||
</Button>
|
||||
<div className="flex flex-col gap-6 p-6 bg-card border border-border rounded-lg">
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-crt text-lg text-primary text-glow">DISPLAY MODE</Label>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as OscilloscopeMode)}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="combined" id="combined" className="border-primary" />
|
||||
<Label htmlFor="combined" className="font-mono-crt text-sm cursor-pointer">
|
||||
Combined (L+R merged)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File name display */}
|
||||
{fileName && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/50 rounded border border-border/50">
|
||||
<Music className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-xs text-foreground truncate">{fileName}</span>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="separate" id="separate" className="border-primary" />
|
||||
<Label htmlFor="separate" className="font-mono-crt text-sm cursor-pointer">
|
||||
Separate (L/R stacked)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playback controls */}
|
||||
{isActive && !isExporting && (
|
||||
<div className="flex gap-2">
|
||||
{source === 'file' && (
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
size="icon"
|
||||
onClick={onTogglePlayPause}
|
||||
>
|
||||
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className="flex-1"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
Stop
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="all" id="all" className="border-primary" />
|
||||
<Label htmlFor="all" className="font-mono-crt text-sm cursor-pointer">
|
||||
All (L/R + XY below)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Export */}
|
||||
{source === 'file' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Video Export
|
||||
</label>
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className={`w-full justify-start ${isExporting ? 'border-destructive shadow-[0_0_15px_hsl(var(--destructive)/0.4)]' : ''}`}
|
||||
onClick={handleExportClick}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<X className="w-4 h-4" />
|
||||
Cancel Export
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Video className="w-4 h-4" />
|
||||
Export Video
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isExporting && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={exportProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground/60 text-center">
|
||||
{exportStage === 'preparing' && 'Preparing audio...'}
|
||||
{exportStage === 'rendering' && `Rendering: ${exportProgress}% ${exportFps > 0 ? `(${exportFps} fps)` : ''}`}
|
||||
{exportStage === 'encoding' && 'Encoding final video...'}
|
||||
{exportStage === 'complete' && 'Finalizing...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isExporting && (
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Generates video from the entire audio file offline.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensitivity / Gain control */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Sensitivity
|
||||
</label>
|
||||
<Slider
|
||||
defaultValue={[3]}
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
onValueChange={(value) => onGainChange(value[0])}
|
||||
className="w-full"
|
||||
disabled={isExporting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Increase for quiet audio sources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Display Mode
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className={`flex-1 ${mode === 'normal' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
|
||||
onClick={() => onModeChange('normal')}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Radio className="w-4 h-4" />
|
||||
Normal
|
||||
</Button>
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className={`flex-1 ${mode === 'xy' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
|
||||
onClick={() => onModeChange('xy')}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Move className="w-4 h-4" />
|
||||
X-Y
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode description */}
|
||||
<div className="p-3 bg-secondary/50 rounded border border-border/50">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{mode === 'normal'
|
||||
? 'Time-domain waveform display. Shows amplitude over time.'
|
||||
: 'Lissajous (X-Y) mode. Left channel controls X, Right controls Y. Creates patterns from stereo audio.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/50 rounded">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-auto pt-4 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground/60 text-center">
|
||||
Audio Oscilloscope v1.3
|
||||
</p>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Export Format Dialog */}
|
||||
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
|
||||
<DialogContent className="bg-bezel border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Choose Export Format</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
The video will be generated from the entire audio file. This works offline and supports large files.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-3 mt-4">
|
||||
{/* Preview Button */}
|
||||
<Button
|
||||
onClick={onPreview}
|
||||
disabled={!canPreview || isGenerating}
|
||||
variant="outline"
|
||||
className="w-full font-crt text-lg h-12 border-primary/50 hover:bg-primary/10 hover:border-primary"
|
||||
>
|
||||
<Play className="mr-2 h-5 w-5" />
|
||||
{isPlaying ? 'PLAYING...' : 'PREVIEW'}
|
||||
</Button>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
disabled={!canGenerate || isGenerating}
|
||||
className="w-full font-crt text-lg h-14 bg-primary hover:bg-primary/80 text-primary-foreground"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-primary-foreground border-t-transparent rounded-full animate-spin mr-2" />
|
||||
GENERATING...
|
||||
</>
|
||||
) : (
|
||||
'GENERATE VIDEO'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isGenerating && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-3 bg-secondary" />
|
||||
<p className="text-center font-mono-crt text-sm text-muted-foreground">
|
||||
{progress}% complete
|
||||
</p>
|
||||
<p className="text-center font-mono-crt text-xs text-muted-foreground/70">
|
||||
Keep this tab in foreground
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Button */}
|
||||
{exportedUrl && (
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href={exportedUrl}
|
||||
download="oscilloscope-video.webm"
|
||||
className="block"
|
||||
>
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className="flex-1"
|
||||
onClick={() => handleFormatSelect('webm')}
|
||||
variant="outline"
|
||||
className="w-full font-crt text-lg h-12 border-accent hover:bg-accent/10 text-accent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
WebM (recommended)
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
DOWNLOAD VIDEO
|
||||
</Button>
|
||||
<Button
|
||||
variant="oscilloscope"
|
||||
className="flex-1"
|
||||
onClick={() => handleFormatSelect('mp4')}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
MP4
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</a>
|
||||
|
||||
<Button
|
||||
onClick={onReset}
|
||||
variant="ghost"
|
||||
className="w-full font-mono-crt text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-muted-foreground font-mono-crt space-y-1 pt-4 border-t border-border">
|
||||
<p>Output: 1920×1080 WebM</p>
|
||||
<p>Frame Rate: 60 FPS</p>
|
||||
<p>Supports files up to 6+ hours</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { OscilloscopeScreen, OscilloscopeScreenHandle } from './OscilloscopeScreen';
|
||||
import { ControlPanel } from './ControlPanel';
|
||||
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
|
||||
import { useOfflineVideoExport } from '@/hooks/useOfflineVideoExport';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Oscilloscope = () => {
|
||||
const [mode, setMode] = useState<'normal' | 'xy'>('normal');
|
||||
const screenRef = useRef<OscilloscopeScreenHandle>(null);
|
||||
const audioFileRef = useRef<File | null>(null);
|
||||
|
||||
const {
|
||||
isActive,
|
||||
isPlaying,
|
||||
source,
|
||||
fileName,
|
||||
error,
|
||||
startMicrophone,
|
||||
loadAudioFile,
|
||||
togglePlayPause,
|
||||
stop,
|
||||
setGain,
|
||||
getTimeDomainData,
|
||||
getStereoData,
|
||||
} = useAudioAnalyzer();
|
||||
|
||||
const {
|
||||
isExporting,
|
||||
progress,
|
||||
stage,
|
||||
fps: exportFps,
|
||||
generateVideoWithAudio,
|
||||
cancelExport,
|
||||
downloadBlob,
|
||||
} = useOfflineVideoExport();
|
||||
|
||||
const handleLoadAudioFile = useCallback((file: File) => {
|
||||
audioFileRef.current = file;
|
||||
loadAudioFile(file);
|
||||
}, [loadAudioFile]);
|
||||
|
||||
const handleExportVideo = useCallback(async (format: 'webm' | 'mp4') => {
|
||||
if (!audioFileRef.current) {
|
||||
toast.error('Please load an audio file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const drawFrame = screenRef.current?.drawFrameWithData;
|
||||
if (!drawFrame) {
|
||||
toast.error('Canvas not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info('Starting video export... This may take a while for large files.');
|
||||
|
||||
const blob = await generateVideoWithAudio(
|
||||
audioFileRef.current,
|
||||
drawFrame,
|
||||
{
|
||||
fps: 60,
|
||||
format,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
}
|
||||
);
|
||||
|
||||
if (blob) {
|
||||
const baseName = fileName?.replace(/\.[^/.]+$/, '') || 'oscilloscope';
|
||||
const extension = format === 'mp4' ? 'mp4' : 'webm';
|
||||
downloadBlob(blob, `${baseName}.${extension}`);
|
||||
toast.success('Video exported successfully!');
|
||||
}
|
||||
}, [fileName, generateVideoWithAudio, downloadBlob]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 w-full max-w-7xl mx-auto p-4 lg:p-8">
|
||||
{/* Main oscilloscope display */}
|
||||
<div className="flex-1 min-h-[400px] lg:min-h-[600px]">
|
||||
<div className="h-full bg-bezel p-4 lg:p-6 rounded-xl border border-border box-glow">
|
||||
{/* Screen bezel */}
|
||||
<div className="h-full rounded-lg overflow-hidden border-4 border-secondary">
|
||||
<OscilloscopeScreen
|
||||
ref={screenRef}
|
||||
mode={mode}
|
||||
getTimeDomainData={getTimeDomainData}
|
||||
getStereoData={getStereoData}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control panel */}
|
||||
<div className="w-full lg:w-72 shrink-0">
|
||||
<ControlPanel
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
isActive={isActive}
|
||||
isPlaying={isPlaying}
|
||||
source={source}
|
||||
fileName={fileName}
|
||||
onStartMicrophone={startMicrophone}
|
||||
onLoadAudioFile={handleLoadAudioFile}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onStop={stop}
|
||||
onGainChange={setGain}
|
||||
error={error}
|
||||
isExporting={isExporting}
|
||||
exportProgress={progress}
|
||||
exportStage={stage}
|
||||
exportFps={exportFps}
|
||||
onExportVideo={handleExportVideo}
|
||||
onCancelExport={cancelExport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import type { AudioData } from '@/hooks/useAudioAnalyzer';
|
||||
import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
|
||||
|
||||
interface OscilloscopeDisplayProps {
|
||||
audioData: AudioData | null;
|
||||
mode: OscilloscopeMode;
|
||||
isPlaying: boolean;
|
||||
onPlaybackEnd?: () => void;
|
||||
}
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
const FPS = 60;
|
||||
|
||||
export function OscilloscopeDisplay({
|
||||
audioData,
|
||||
mode,
|
||||
isPlaying,
|
||||
onPlaybackEnd
|
||||
}: OscilloscopeDisplayProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const currentSampleRef = useRef(0);
|
||||
|
||||
const drawGraticule = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Horizontal center line (X axis)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, HEIGHT / 2);
|
||||
ctx.lineTo(WIDTH, HEIGHT / 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Vertical center line (Y axis)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(WIDTH / 2, 0);
|
||||
ctx.lineTo(WIDTH / 2, HEIGHT);
|
||||
ctx.stroke();
|
||||
}, []);
|
||||
|
||||
const drawFrame = useCallback(() => {
|
||||
if (!audioData || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const samplesPerFrame = Math.floor(audioData.sampleRate / FPS);
|
||||
const startSample = currentSampleRef.current;
|
||||
const endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length);
|
||||
|
||||
// Clear to pure black
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Draw graticule first
|
||||
drawGraticule(ctx);
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
const leftColor = '#00ff00';
|
||||
const rightColor = '#00ccff';
|
||||
const xyColor = '#ff8800';
|
||||
const dividerColor = '#333333';
|
||||
|
||||
if (mode === 'combined') {
|
||||
// Combined: both channels merged
|
||||
ctx.strokeStyle = leftColor;
|
||||
ctx.beginPath();
|
||||
const samplesPerPixel = samplesPerFrame / WIDTH;
|
||||
const centerY = HEIGHT / 2;
|
||||
for (let x = 0; x < WIDTH; x++) {
|
||||
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
|
||||
if (sampleIndex >= audioData.leftChannel.length) break;
|
||||
const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2;
|
||||
const y = centerY - sample * (HEIGHT * 0.4);
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
} else if (mode === 'separate') {
|
||||
// Separate: Left on top, Right on bottom
|
||||
const halfHeight = HEIGHT / 2;
|
||||
const samplesPerPixel = samplesPerFrame / WIDTH;
|
||||
|
||||
// Left channel (top)
|
||||
ctx.strokeStyle = leftColor;
|
||||
ctx.beginPath();
|
||||
const leftCenterY = halfHeight / 2;
|
||||
for (let x = 0; x < WIDTH; x++) {
|
||||
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
|
||||
if (sampleIndex >= audioData.leftChannel.length) break;
|
||||
const sample = audioData.leftChannel[sampleIndex];
|
||||
const y = leftCenterY - sample * (halfHeight * 0.35);
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Right channel (bottom)
|
||||
ctx.strokeStyle = rightColor;
|
||||
ctx.beginPath();
|
||||
const rightCenterY = halfHeight + halfHeight / 2;
|
||||
for (let x = 0; x < WIDTH; x++) {
|
||||
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
|
||||
if (sampleIndex >= audioData.rightChannel.length) break;
|
||||
const sample = audioData.rightChannel[sampleIndex];
|
||||
const y = rightCenterY - sample * (halfHeight * 0.35);
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Divider
|
||||
ctx.strokeStyle = dividerColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, halfHeight);
|
||||
ctx.lineTo(WIDTH, halfHeight);
|
||||
ctx.stroke();
|
||||
} else if (mode === 'all') {
|
||||
// All: L/R on top row, XY on bottom
|
||||
const topHeight = HEIGHT / 2;
|
||||
const bottomHeight = HEIGHT / 2;
|
||||
const halfWidth = WIDTH / 2;
|
||||
const samplesPerPixel = samplesPerFrame / halfWidth;
|
||||
|
||||
// Left channel (top-left)
|
||||
ctx.strokeStyle = leftColor;
|
||||
ctx.beginPath();
|
||||
const leftCenterY = topHeight / 2;
|
||||
for (let x = 0; x < halfWidth; x++) {
|
||||
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
|
||||
if (sampleIndex >= audioData.leftChannel.length) break;
|
||||
const sample = audioData.leftChannel[sampleIndex];
|
||||
const y = leftCenterY - sample * (topHeight * 0.35);
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Right channel (top-right)
|
||||
ctx.strokeStyle = rightColor;
|
||||
ctx.beginPath();
|
||||
const rightCenterY = topHeight / 2;
|
||||
for (let x = 0; x < halfWidth; x++) {
|
||||
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
|
||||
if (sampleIndex >= audioData.rightChannel.length) break;
|
||||
const sample = audioData.rightChannel[sampleIndex];
|
||||
const y = rightCenterY - sample * (topHeight * 0.35);
|
||||
if (x === 0) ctx.moveTo(halfWidth + x, y);
|
||||
else ctx.lineTo(halfWidth + x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// XY mode (bottom half)
|
||||
ctx.strokeStyle = xyColor;
|
||||
ctx.beginPath();
|
||||
const xyCenterX = WIDTH / 2;
|
||||
const xyCenterY = topHeight + bottomHeight / 2;
|
||||
const xyScale = Math.min(halfWidth, bottomHeight) * 0.35;
|
||||
for (let i = startSample; i < endSample; i++) {
|
||||
const x = xyCenterX + audioData.leftChannel[i] * xyScale;
|
||||
const y = xyCenterY - audioData.rightChannel[i] * xyScale;
|
||||
if (i === startSample) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Dividers
|
||||
ctx.strokeStyle = dividerColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, topHeight);
|
||||
ctx.lineTo(WIDTH, topHeight);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(halfWidth, 0);
|
||||
ctx.lineTo(halfWidth, topHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
currentSampleRef.current = endSample;
|
||||
|
||||
if (endSample >= audioData.leftChannel.length) {
|
||||
onPlaybackEnd?.();
|
||||
return;
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(drawFrame);
|
||||
}, [audioData, mode, drawGraticule, onPlaybackEnd]);
|
||||
|
||||
// Initialize canvas
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
drawGraticule(ctx);
|
||||
}
|
||||
}, [drawGraticule]);
|
||||
|
||||
// Handle playback
|
||||
useEffect(() => {
|
||||
if (isPlaying && audioData) {
|
||||
currentSampleRef.current = 0;
|
||||
animationRef.current = requestAnimationFrame(drawFrame);
|
||||
} else {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, audioData, drawFrame]);
|
||||
|
||||
const getModeLabel = () => {
|
||||
switch (mode) {
|
||||
case 'combined': return 'L+R';
|
||||
case 'separate': return 'L / R';
|
||||
case 'all': return 'ALL';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crt-bezel">
|
||||
<div className="screen-curve relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
|
||||
{/* Mode indicator */}
|
||||
<div className="absolute top-4 left-4 font-crt text-primary/60 text-sm">
|
||||
{getModeLabel()}
|
||||
</div>
|
||||
|
||||
{/* Idle state */}
|
||||
{!audioData && !isPlaying && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="font-crt text-2xl text-primary/40 text-glow animate-pulse">
|
||||
NO SIGNAL
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface OscilloscopeScreenProps {
|
||||
mode: 'normal' | 'xy';
|
||||
getTimeDomainData: () => Uint8Array | null;
|
||||
getStereoData: () => { left: Uint8Array; right: Uint8Array } | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface OscilloscopeScreenHandle {
|
||||
getCanvas: () => HTMLCanvasElement | null;
|
||||
drawFrameWithData: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void;
|
||||
}
|
||||
|
||||
export const OscilloscopeScreen = forwardRef<OscilloscopeScreenHandle, OscilloscopeScreenProps>(({
|
||||
mode,
|
||||
getTimeDomainData,
|
||||
getStereoData,
|
||||
isActive,
|
||||
}, ref) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>();
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const targetFPS = 120;
|
||||
const frameInterval = 1000 / targetFPS;
|
||||
|
||||
const drawGrid = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
||||
ctx.strokeStyle = '#1a3a1a';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const vDivisions = 10;
|
||||
for (let i = 0; i <= vDivisions; i++) {
|
||||
const x = Math.round((width / vDivisions) * i) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const hDivisions = 8;
|
||||
for (let i = 0; i <= hDivisions; i++) {
|
||||
const y = Math.round((height / hDivisions) * i) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#2a5a2a';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const centerX = Math.round(width / 2) + 0.5;
|
||||
const centerY = Math.round(height / 2) + 0.5;
|
||||
const tickLength = 6;
|
||||
const tickSpacing = width / 50;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, 0);
|
||||
ctx.lineTo(centerX, height);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, centerY);
|
||||
ctx.lineTo(width, centerY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = '#2a5a2a';
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const x = Math.round(i * tickSpacing) + 0.5;
|
||||
const y = Math.round(i * tickSpacing * (height / width)) + 0.5;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, centerY - tickLength / 2);
|
||||
ctx.lineTo(x, centerY + tickLength / 2);
|
||||
ctx.stroke();
|
||||
|
||||
if (y < height) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - tickLength / 2, y);
|
||||
ctx.lineTo(centerX + tickLength / 2, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const drawNormalMode = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number, data: Uint8Array) => {
|
||||
const centerY = height / 2;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
|
||||
const step = Math.max(1, Math.floor(data.length / (width * 2)));
|
||||
|
||||
for (let i = 0; i < data.length; i += step) {
|
||||
const x = (i / data.length) * width;
|
||||
const normalizedValue = (data[i] - 128) / 128;
|
||||
const y = centerY - (normalizedValue * (height / 2) * 0.85);
|
||||
points.push({ x, y });
|
||||
}
|
||||
|
||||
if (points.length < 2) return;
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const xc = (points[i].x + points[i + 1].x) / 2;
|
||||
const yc = (points[i].y + points[i + 1].y) / 2;
|
||||
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}, []);
|
||||
|
||||
const drawXYMode = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const scale = Math.min(width, height) / 2 * 0.85;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
|
||||
const step = Math.max(1, Math.floor(leftData.length / 2048));
|
||||
|
||||
for (let i = 0; i < leftData.length; i += step) {
|
||||
const xNorm = (leftData[i] - 128) / 128;
|
||||
const yNorm = (rightData[i] - 128) / 128;
|
||||
|
||||
const x = centerX + xNorm * scale;
|
||||
const y = centerY - yNorm * scale;
|
||||
points.push({ x, y });
|
||||
}
|
||||
|
||||
if (points.length < 2) return;
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const xc = (points[i].x + points[i + 1].x) / 2;
|
||||
const yc = (points[i].y + points[i + 1].y) / 2;
|
||||
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}, []);
|
||||
|
||||
const drawIdleWave = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
||||
const centerY = height / 2;
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, centerY);
|
||||
ctx.lineTo(width, centerY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCanvas: () => canvasRef.current,
|
||||
drawFrameWithData: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => {
|
||||
ctx.fillStyle = '#0a0f0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
drawGrid(ctx, width, height);
|
||||
if (mode === 'normal') {
|
||||
drawNormalMode(ctx, width, height, leftData);
|
||||
} else {
|
||||
drawXYMode(ctx, width, height, leftData, rightData);
|
||||
}
|
||||
},
|
||||
}), [mode, drawGrid, drawNormalMode, drawXYMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
const render = (currentTime: number) => {
|
||||
const deltaTime = currentTime - lastTimeRef.current;
|
||||
|
||||
if (deltaTime >= frameInterval) {
|
||||
lastTimeRef.current = currentTime - (deltaTime % frameInterval);
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.width / dpr;
|
||||
const height = canvas.height / dpr;
|
||||
|
||||
ctx.fillStyle = '#0a0f0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
drawGrid(ctx, width, height);
|
||||
|
||||
if (isActive) {
|
||||
if (mode === 'normal') {
|
||||
const data = getTimeDomainData();
|
||||
if (data) {
|
||||
drawNormalMode(ctx, width, height, data);
|
||||
}
|
||||
} else {
|
||||
const stereoData = getStereoData();
|
||||
if (stereoData) {
|
||||
drawXYMode(ctx, width, height, stereoData.left, stereoData.right);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drawIdleWave(ctx, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [mode, isActive, getTimeDomainData, getStereoData, drawGrid, drawNormalMode, drawXYMode, drawIdleWave, frameInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.scale(dpr, dpr);
|
||||
}
|
||||
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden rounded-lg" style={{ backgroundColor: '#0a0f0a' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user