MotorSignal-App/website/components/web-telemetry-replay-modal.tsx

682 lines
29 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Ride } from './app-cloud-data';
import {
TelemetryPoint,
CornerApexSummary,
synthesizeTelemetryTrack,
generateRideGpx,
generateRideCsv,
downloadFile,
analyzeCorners,
} from '../lib/telemetry-export';
interface WebTelemetryReplayModalProps {
ride: Ride;
onClose: () => void;
}
export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayModalProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<number>(1.0);
const [currentIndex, setCurrentIndex] = useState(0);
const [activeTab, setActiveTab] = useState<'map' | 'corners'>('map');
const [exportNotice, setExportNotice] = useState<string | null>(null);
// Generate or load telemetry points
const points = useMemo<TelemetryPoint[]>(() => {
return synthesizeTelemetryTrack(ride);
}, [ride]);
const corners = useMemo<CornerApexSummary[]>(() => {
return analyzeCorners(points);
}, [points]);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationFrameRef = useRef<number | null>(null);
const lastTimeRef = useRef<number | null>(null);
const totalPoints = points.length;
const currentPoint = points[currentIndex] || points[0];
// Bounding box for canvas projection
const bounds = useMemo(() => {
if (points.length === 0) return { minLat: 0, maxLat: 1, minLon: 0, maxLon: 1 };
let minLat = points[0].latitude;
let maxLat = points[0].latitude;
let minLon = points[0].longitude;
let maxLon = points[0].longitude;
for (const p of points) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
// Add padding (10%)
const latPad = (maxLat - minLat) * 0.1 || 0.001;
const lonPad = (maxLon - minLon) * 0.1 || 0.001;
return {
minLat: minLat - latPad,
maxLat: maxLat + latPad,
minLon: minLon - lonPad,
maxLon: maxLon + lonPad,
};
}, [points]);
// Project lat/lon to canvas coordinates
const project = useCallback(
(lat: number, lon: number, width: number, height: number) => {
const x = ((lon - bounds.minLon) / (bounds.maxLon - bounds.minLon)) * width;
// Invert Y since latitude increases northward
const y = height - ((lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * height;
return { x, y };
},
[bounds]
);
// Draw the route on canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Clear background
ctx.fillStyle = '#070a0f';
ctx.fillRect(0, 0, width, height);
// Subtle tactical grid
ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)';
ctx.lineWidth = 1;
const gridSize = 40;
for (let x = 0; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
if (points.length < 2) return;
// Draw segmented polyline with lean angle heatmap colors
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const pt1 = project(p1.latitude, p1.longitude, width, height);
const pt2 = project(p2.latitude, p2.longitude, width, height);
const absLean = Math.abs(p1.leanAngle);
// Multi-color lean angle heatmap:
// Green/Cyan <= 20° | Yellow/Amber 20°-35° | Red/Crimson > 35°
let strokeColor = '#00f2fe'; // Cyan (straight / mild)
let lineWidth = 3.5;
if (absLean > 35) {
strokeColor = '#ef4444'; // Red (Aggressive Apex)
lineWidth = 5.0;
} else if (absLean > 20) {
strokeColor = '#f59e0b'; // Amber (Moderate Cornering)
lineWidth = 4.0;
}
// Past path vs upcoming path opacity
ctx.beginPath();
ctx.moveTo(pt1.x, pt1.y);
ctx.lineTo(pt2.x, pt2.y);
ctx.strokeStyle = i <= currentIndex ? strokeColor : 'rgba(100, 116, 139, 0.35)';
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
// Start marker
const startPt = project(points[0].latitude, points[0].longitude, width, height);
ctx.beginPath();
ctx.arc(startPt.x, startPt.y, 6, 0, Math.PI * 2);
ctx.fillStyle = '#10b981';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.stroke();
// Finish marker
const endPt = project(points[points.length - 1].latitude, points[points.length - 1].longitude, width, height);
ctx.beginPath();
ctx.arc(endPt.x, endPt.y, 6, 0, Math.PI * 2);
ctx.fillStyle = '#8b5cf6';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.stroke();
// Current rider position & heading beacon
if (currentPoint) {
const riderPt = project(currentPoint.latitude, currentPoint.longitude, width, height);
// Pulsing outer halo
ctx.beginPath();
ctx.arc(riderPt.x, riderPt.y, 14, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 242, 254, 0.25)';
ctx.fill();
// Heading arrow / dot
ctx.save();
ctx.translate(riderPt.x, riderPt.y);
ctx.rotate((currentPoint.heading * Math.PI) / 180);
// Arrow path
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.lineTo(6, 8);
ctx.lineTo(0, 4);
ctx.lineTo(-6, 8);
ctx.closePath();
ctx.fillStyle = '#00f2fe';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
}, [points, currentIndex, project, currentPoint]);
// Handle Playback Loop
useEffect(() => {
if (!isPlaying) {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
lastTimeRef.current = null;
return;
}
const animate = (timestamp: number) => {
if (!lastTimeRef.current) lastTimeRef.current = timestamp;
const delta = timestamp - lastTimeRef.current;
// 20 Hz step interval is 50 ms. With playback multiplier:
const stepInterval = 50 / playbackSpeed;
if (delta >= stepInterval) {
lastTimeRef.current = timestamp;
setCurrentIndex((prev) => {
if (prev >= totalPoints - 1) {
setIsPlaying(false);
return prev;
}
return prev + 1;
});
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
};
}, [isPlaying, playbackSpeed, totalPoints]);
// Keyboard navigation (Space to toggle play)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
setIsPlaying((prev) => !prev);
} else if (e.code === 'ArrowRight') {
setCurrentIndex((prev) => Math.min(totalPoints - 1, prev + 20));
} else if (e.code === 'ArrowLeft') {
setCurrentIndex((prev) => Math.max(0, prev - 20));
} else if (e.code === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [totalPoints, onClose]);
// Export Handlers
const handleExportGpx = () => {
const gpx = generateRideGpx(ride, points);
const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'ride';
const filename = `motorsignal_${ride.id}_${dateStr}.gpx`;
downloadFile(gpx, filename, 'application/gpx+xml');
setExportNotice(`Exported GPX: ${filename}`);
setTimeout(() => setExportNotice(null), 3500);
};
const handleExportCsv = () => {
const csv = generateRideCsv(ride, points);
const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'ride';
const filename = `motorsignal_20hz_${ride.id}_${dateStr}.csv`;
downloadFile(csv, filename, 'text/csv;charset=utf-8;');
setExportNotice(`Exported 20 Hz CSV: ${filename}`);
setTimeout(() => setExportNotice(null), 3500);
};
const elapsedSeconds = totalPoints > 0 ? (currentIndex / totalPoints) * (ride.movingTime || 60) : 0;
const totalSeconds = ride.movingTime || 60;
const formatTime = (secs: number) => {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const absLeanAngle = Math.abs(currentPoint?.leanAngle || 0);
const leanDirection = (currentPoint?.leanAngle || 0) < 0 ? 'LEFT' : 'RIGHT';
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/85 backdrop-blur-md animate-fadeIn"
>
<div className="glass-card rounded-2xl border border-brand-cyan/40 max-w-5xl w-full max-h-[92vh] flex flex-col bg-slate-950 text-white shadow-2xl overflow-hidden">
{/* Header Bar */}
<div className="flex flex-wrap items-center justify-between p-4 sm:p-5 border-b border-outline-variant/30 gap-3 bg-surface-container-low/70">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse"></span>
<h2 className="text-lg sm:text-xl font-black font-mono tracking-wide text-white">
TELEMETRY FORENSIC REPLAY
</h2>
</div>
<p className="text-xs font-mono text-slate-400">
{ride.motorcycle || 'Motorcycle'} • {ride.distanceMiles.toFixed(2)} mi •{' '}
{ride.startedAt ? ride.startedAt.toLocaleDateString() : 'Recorded Session'}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={handleExportGpx}
data-testid="export-gpx-btn"
className="px-3 py-1.5 rounded-lg bg-surface-container-high hover:bg-brand-cyan hover:text-black border border-outline-variant/50 text-xs font-mono font-semibold transition flex items-center gap-1.5 cursor-pointer"
title="Download GPX 1.1 format for Garmin, Strava, Google Earth"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Export GPX</span>
</button>
<button
onClick={handleExportCsv}
data-testid="export-csv-btn"
className="px-3 py-1.5 rounded-lg bg-surface-container-high hover:bg-emerald-400 hover:text-black border border-outline-variant/50 text-xs font-mono font-semibold transition flex items-center gap-1.5 cursor-pointer"
title="Download full 20 Hz telemetry CSV (speed, lean, 3-axis G)"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>20 Hz CSV</span>
</button>
<button
onClick={onClose}
data-testid="close-replay-modal"
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-surface-container-high transition cursor-pointer"
aria-label="Close replay modal"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Feedback Alert Toast */}
{exportNotice && (
<div className="bg-brand-cyan/20 border-b border-brand-cyan/50 text-brand-cyan text-xs font-mono px-4 py-2 flex items-center gap-2 animate-fadeIn">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan"></span>
<span>{exportNotice}</span>
</div>
)}
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Cockpit HUD Gauges */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 font-mono">
{/* Speed Gauge */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<span className="text-[10px] text-slate-400">VEHICLE SPEED</span>
<div className="my-1">
<span className="text-3xl font-extrabold text-brand-cyan">
{Math.round(currentPoint?.speed || 0)}
</span>
<span className="text-xs text-slate-400 ml-1">mph</span>
</div>
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div
className="bg-brand-cyan h-full transition-all duration-75"
style={{ width: `${Math.min(100, ((currentPoint?.speed || 0) / (ride.topSpeedMph || 100)) * 100)}%` }}
></div>
</div>
</div>
{/* Lean Angle Gauge */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<div className="flex items-center justify-between text-[10px]">
<span className="text-slate-400">LEAN ANGLE</span>
<span className={absLeanAngle > 35 ? 'text-rose-400 font-bold' : absLeanAngle > 20 ? 'text-amber-400 font-bold' : 'text-slate-400'}>
{leanDirection}
</span>
</div>
<div className="my-1 flex items-baseline gap-1">
<span
className={`text-3xl font-extrabold ${
absLeanAngle > 35 ? 'text-rose-400' : absLeanAngle > 20 ? 'text-amber-400' : 'text-emerald-400'
}`}
>
{absLeanAngle.toFixed(1)}°
</span>
</div>
<div className="flex items-center gap-1 text-[9px] text-slate-500">
<span>L 50°</span>
<div className="flex-1 bg-slate-800 h-1.5 rounded-full relative overflow-hidden">
<div
className={`absolute top-0 bottom-0 transition-all duration-75 ${
absLeanAngle > 35 ? 'bg-rose-500' : absLeanAngle > 20 ? 'bg-amber-400' : 'bg-brand-cyan'
}`}
style={{
left: (currentPoint?.leanAngle || 0) < 0 ? `${50 - (absLeanAngle / 50) * 50}%` : '50%',
width: `${Math.min(50, (absLeanAngle / 50) * 50)}%`,
}}
></div>
</div>
<span>50° R</span>
</div>
</div>
{/* G-Force HUD */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<span className="text-[10px] text-slate-400">G-FORCE DYNAMICS</span>
<div className="my-1 flex items-center justify-between">
<div>
<span className="text-[10px] text-slate-500 block">LONGITUDINAL</span>
<span
className={`text-lg font-bold ${
(currentPoint?.longitudinalG || 0) >= 0 ? 'text-brand-cyan' : 'text-rose-400'
}`}
>
{(currentPoint?.longitudinalG || 0) >= 0 ? '+' : ''}
{(currentPoint?.longitudinalG || 0).toFixed(2)} G
</span>
</div>
<div>
<span className="text-[10px] text-slate-500 block">LATERAL</span>
<span className="text-lg font-bold text-amber-400">
{(currentPoint?.lateralG || 0).toFixed(2)} G
</span>
</div>
</div>
<div className="text-[9px] text-slate-400">
Heading: {Math.round(currentPoint?.heading || 0)}°
</div>
</div>
{/* Elevation / Progress */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<span className="text-[10px] text-slate-400">ALTITUDE & TIME</span>
<div className="my-1">
<span className="text-2xl font-extrabold text-white">
{Math.round(currentPoint?.altitude || 0)}
</span>
<span className="text-xs text-slate-400 ml-1">ft MSL</span>
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{formatTime(elapsedSeconds)}</span>
<span className="text-slate-600">/</span>
<span>{formatTime(totalSeconds)}</span>
</div>
</div>
</div>
{/* View Mode Tabs: Interactive Map vs Corner Apex Grading */}
<div className="flex items-center justify-between border-b border-outline-variant/30 pb-2">
<div className="flex items-center gap-2">
<button
onClick={() => setActiveTab('map')}
data-testid="tab-map-replay"
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-bold transition cursor-pointer ${
activeTab === 'map'
? 'bg-brand-cyan text-black shadow-md'
: 'bg-surface-container text-slate-300 hover:text-white'
}`}
>
Route Heatmap & Trace
</button>
<button
onClick={() => setActiveTab('corners')}
data-testid="tab-corner-grading"
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-bold transition flex items-center gap-1.5 cursor-pointer ${
activeTab === 'corners'
? 'bg-brand-cyan text-black shadow-md'
: 'bg-surface-container text-slate-300 hover:text-white'
}`}
>
<span>Corner Apex Grading</span>
<span className="px-1.5 py-0.2 rounded-full bg-slate-900/60 text-[10px]">
{corners.length}
</span>
</button>
</div>
{/* Heatmap Legend */}
<div className="hidden sm:flex items-center gap-3 text-[11px] font-mono">
<span className="flex items-center gap-1 text-slate-400">
<span className="w-2.5 h-2.5 rounded-full bg-brand-cyan"></span>
Straight / Mild (&le;20°)
</span>
<span className="flex items-center gap-1 text-slate-400">
<span className="w-2.5 h-2.5 rounded-full bg-amber-400"></span>
Corner (20°-35°)
</span>
<span className="flex items-center gap-1 text-slate-400">
<span className="w-2.5 h-2.5 rounded-full bg-rose-500"></span>
Apex Peak (&gt;35°)
</span>
</div>
</div>
{/* View Tab 1: Map Replay */}
{activeTab === 'map' && (
<div className="relative rounded-xl overflow-hidden border border-outline-variant/40 bg-slate-950 h-80 sm:h-96">
<canvas
ref={canvasRef}
width={800}
height={400}
className="w-full h-full object-cover"
/>
{/* In-canvas Mini Overlay Tag */}
<div className="absolute top-3 left-3 bg-black/80 backdrop-blur-md px-3 py-1 rounded-lg border border-slate-700 text-[10px] font-mono text-slate-300">
GPS: {currentPoint?.latitude.toFixed(5)}, {currentPoint?.longitude.toFixed(5)}
</div>
</div>
)}
{/* View Tab 2: Corner Apex Grading Table */}
{activeTab === 'corners' && (
<div className="rounded-xl border border-outline-variant/40 overflow-hidden bg-surface-container-low/60">
<div className="overflow-x-auto">
<table className="w-full text-left font-mono text-xs">
<thead className="bg-surface-container-high/80 text-slate-400 uppercase text-[10px] tracking-wider border-b border-outline-variant/30">
<tr>
<th className="py-2.5 px-3">Corner</th>
<th className="py-2.5 px-3">Direction</th>
<th className="py-2.5 px-3">Entry Spd</th>
<th className="py-2.5 px-3">Apex Spd</th>
<th className="py-2.5 px-3">Apex Lean</th>
<th className="py-2.5 px-3">Exit Spd</th>
<th className="py-2.5 px-3">Grade</th>
<th className="py-2.5 px-3 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/20">
{corners.length > 0 ? (
corners.map((corner) => (
<tr
key={corner.id}
className="hover:bg-brand-cardAlt/50 transition cursor-pointer"
onClick={() => {
setCurrentIndex(corner.apexIndex);
setActiveTab('map');
}}
>
<td className="py-2.5 px-3 font-bold text-white">Turn {corner.id}</td>
<td className="py-2.5 px-3">
<span
className={`px-1.5 py-0.5 rounded text-[10px] ${
corner.direction === 'Left'
? 'bg-blue-500/20 text-blue-400'
: 'bg-emerald-500/20 text-emerald-400'
}`}
>
{corner.direction === 'Left' ? '↰ Left' : '↱ Right'}
</span>
</td>
<td className="py-2.5 px-3 text-slate-300">{corner.entrySpeed} mph</td>
<td className="py-2.5 px-3 font-semibold text-brand-cyan">
{corner.apexSpeed} mph
</td>
<td className="py-2.5 px-3 font-bold text-amber-400">
{corner.apexLeanAngle}°
</td>
<td className="py-2.5 px-3 text-slate-300">{corner.exitSpeed} mph</td>
<td className="py-2.5 px-3">
<span
className={`px-2 py-0.5 rounded font-bold text-[11px] ${
corner.grade === 'A+' || corner.grade === 'A'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: corner.grade === 'B'
? 'bg-brand-cyan/20 text-brand-cyan border border-brand-cyan/30'
: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
}`}
>
{corner.grade}
</span>
</td>
<td className="py-2.5 px-3 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex(corner.apexIndex);
setActiveTab('map');
}}
className="px-2 py-1 rounded bg-surface-container-high hover:bg-brand-cyan hover:text-black text-[10px] transition cursor-pointer"
>
Seek Apex
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="py-8 text-center text-slate-400">
No distinct corners detected over 16° lean threshold.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Footer Playback Controls */}
<div className="p-4 sm:p-5 border-t border-outline-variant/30 bg-surface-container-low/70 space-y-3">
{/* Timeline Scrubber */}
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-slate-400 w-12 text-right">
{formatTime(elapsedSeconds)}
</span>
<input
type="range"
min={0}
max={Math.max(1, totalPoints - 1)}
value={currentIndex}
onChange={(e) => setCurrentIndex(Number(e.target.value))}
className="flex-1 accent-brand-cyan cursor-pointer h-2 bg-slate-800 rounded-lg"
data-testid="replay-timeline-slider"
/>
<span className="text-xs font-mono text-slate-400 w-12">{formatTime(totalSeconds)}</span>
</div>
{/* Transport Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentIndex(0)}
title="Restart from beginning"
className="p-2 rounded-lg bg-surface-container hover:bg-slate-700 text-slate-300 hover:text-white transition cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" />
</svg>
</button>
<button
onClick={() => setIsPlaying((prev) => !prev)}
data-testid="play-pause-btn"
className="px-5 py-2 rounded-xl bg-gradient-to-r from-brand-cyan to-blue-600 hover:opacity-95 text-black font-extrabold font-mono text-sm shadow-[0_0_16px_rgba(0,242,254,0.3)] transition flex items-center gap-2 cursor-pointer"
>
{isPlaying ? (
<>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
<span>PAUSE</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span>PLAY</span>
</>
)}
</button>
</div>
{/* Playback Speed Multiplier */}
<div className="flex items-center gap-1 bg-surface-container p-1 rounded-lg border border-outline-variant/30 text-xs font-mono">
{[0.5, 1.0, 2.0, 5.0].map((spd) => (
<button
key={spd}
onClick={() => setPlaybackSpeed(spd)}
className={`px-2 py-1 rounded transition cursor-pointer ${
playbackSpeed === spd
? 'bg-brand-cyan text-black font-bold'
: 'text-slate-400 hover:text-white'
}`}
>
{spd}x
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}