mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-03-03 00:27:01 +00:00
feat: stream audio in chunks - new ember player and visualizer
This commit is contained in:
committed by
Rishikanth Chandrasekaran
parent
80a7808d37
commit
503a6d714f
@@ -1425,13 +1425,37 @@ func (h *Handler) GetAudioFile(c *gin.Context) {
|
||||
c.Header("Content-Type", "audio/mpeg")
|
||||
}
|
||||
|
||||
// Add CORS headers for audio
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-API-Key")
|
||||
// Add CORS headers for audio visualization and streaming
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
} else {
|
||||
// Fallback for non-browser/direct access
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length")
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
|
||||
// Serve the audio file
|
||||
c.File(job.AudioPath)
|
||||
// Open the file
|
||||
file, err := os.Open(audioPath)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to open audio file: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open audio file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file stats
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to stat audio file: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stat audio file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Use http.ServeContent for efficient streaming and range request support
|
||||
http.ServeContent(c.Writer, c.Request, filepath.Base(audioPath), fileInfo.ModTime(), file)
|
||||
}
|
||||
|
||||
// @Summary Login
|
||||
@@ -1476,6 +1500,20 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set access token cookie for streaming/media access
|
||||
// We use Lax mode to allow top-level navigation authentication if needed, but Strict is safer for API.
|
||||
// Since we use this for <audio> src which is a cross-origin-like request (even if same origin technically),
|
||||
// SameSite=Strict should work for same-site.
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: "scriberr_access_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(24 * time.Hour), // Match your token duration constant
|
||||
HttpOnly: true,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
response := LoginResponse{Token: token}
|
||||
response.User.ID = user.ID
|
||||
response.User.Username = user.Username
|
||||
|
||||
@@ -27,22 +27,29 @@ func AuthMiddleware(authService *auth.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Check for JWT token
|
||||
var token string
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
if authHeader != "" {
|
||||
// Extract token from "Bearer <token>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
token = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to cookie if no header
|
||||
if token == "" {
|
||||
if cookie, err := c.Cookie("scriberr_access_token"); err == nil {
|
||||
token = cookie
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authentication"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
claims, err := authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
|
||||
210
web/frontend/src/components/audio/AudioVisualizer.tsx
Normal file
210
web/frontend/src/components/audio/AudioVisualizer.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
audioRef: React.RefObject<HTMLAudioElement | null>;
|
||||
isPlaying: boolean;
|
||||
isHovering?: boolean;
|
||||
hoverPercent?: number;
|
||||
}
|
||||
|
||||
// Global cache to prevent "InvalidStateError" when React re-renders
|
||||
// This ensures we don't try to create a new SourceNode for an audio element that already has one.
|
||||
const audioSourceMap = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>();
|
||||
|
||||
export function AudioVisualizer({
|
||||
audioRef,
|
||||
isPlaying,
|
||||
isHovering = false,
|
||||
hoverPercent = 0,
|
||||
}: AudioVisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contextRef = useRef<AudioContext | null>(null);
|
||||
const analyzerRef = useRef<AnalyserNode | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const peakPositionsRef = useRef<number[]>([]);
|
||||
const peakDropsRef = useRef<number[]>([]);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
// 1. Handle Window/Container Resizing
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const { clientWidth, clientHeight } = containerRef.current;
|
||||
setDimensions({
|
||||
width: clientWidth * window.devicePixelRatio,
|
||||
height: clientHeight * window.devicePixelRatio,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 2. Initialize Audio Context & Analyzer
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const initAudio = () => {
|
||||
if (!contextRef.current) {
|
||||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||||
contextRef.current = new AudioContextClass();
|
||||
}
|
||||
const ctx = contextRef.current;
|
||||
|
||||
if (!analyzerRef.current) {
|
||||
const analyzer = ctx.createAnalyser();
|
||||
analyzer.fftSize = 256;
|
||||
analyzer.smoothingTimeConstant = 0.8;
|
||||
analyzerRef.current = analyzer;
|
||||
}
|
||||
|
||||
const audioEl = audioRef.current!;
|
||||
|
||||
// Check cache to reuse existing MediaElementSource
|
||||
if (audioSourceMap.has(audioEl)) {
|
||||
try {
|
||||
const source = audioSourceMap.get(audioEl)!;
|
||||
source.connect(analyzerRef.current!);
|
||||
analyzerRef.current!.connect(ctx.destination);
|
||||
} catch (e) { /* ignore already connected errors */ }
|
||||
} else {
|
||||
try {
|
||||
const source = ctx.createMediaElementSource(audioEl);
|
||||
source.connect(analyzerRef.current!);
|
||||
analyzerRef.current!.connect(ctx.destination);
|
||||
audioSourceMap.set(audioEl, source);
|
||||
} catch (e) {
|
||||
console.error("Audio Graph Error:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAudio();
|
||||
|
||||
if (isPlaying && contextRef.current?.state === "suspended") {
|
||||
contextRef.current.resume();
|
||||
}
|
||||
}, [audioRef, isPlaying]);
|
||||
|
||||
// 3. The Drawing Loop (The "Electric Ember" Design)
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !analyzerRef.current || dimensions.width === 0)
|
||||
return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const SCALE = window.devicePixelRatio;
|
||||
const TILE_SIZE = 5 * SCALE;
|
||||
const COL_GAP = 2 * SCALE;
|
||||
const ROW_GAP = 2 * SCALE;
|
||||
const TOTAL_ROW_HEIGHT = TILE_SIZE + ROW_GAP;
|
||||
const COL_WIDTH = TILE_SIZE + COL_GAP;
|
||||
|
||||
// --- THEME GRADIENT (Electric Ember) ---
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, dimensions.height);
|
||||
gradient.addColorStop(0, "#FFAB40"); // Top: Amber
|
||||
gradient.addColorStop(0.5, "#FF6D1F"); // Mid: Orange
|
||||
gradient.addColorStop(1, "#FF3D00"); // Bottom: Deep Red
|
||||
|
||||
const dataArray = new Uint8Array(analyzerRef.current.frequencyBinCount);
|
||||
|
||||
const draw = () => {
|
||||
if (!analyzerRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
analyzerRef.current.getByteFrequencyData(dataArray);
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, dimensions.width, dimensions.height);
|
||||
const barCount = Math.floor(dimensions.width / COL_WIDTH);
|
||||
|
||||
if (peakPositionsRef.current.length !== barCount) {
|
||||
peakPositionsRef.current = new Array(barCount).fill(0);
|
||||
peakDropsRef.current = new Array(barCount).fill(0);
|
||||
}
|
||||
|
||||
const maxTilesColumn = Math.floor(dimensions.height / TOTAL_ROW_HEIGHT);
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const binIndex = Math.floor(i * (dataArray.length / barCount) * 0.7);
|
||||
let value = dataArray[binIndex] || 0;
|
||||
value = Math.min(255, value * 1.2);
|
||||
|
||||
const x = i * COL_WIDTH;
|
||||
const activeTiles = Math.floor((value / 255) * maxTilesColumn);
|
||||
|
||||
// Peak Logic
|
||||
if (activeTiles > peakPositionsRef.current[i]) {
|
||||
peakPositionsRef.current[i] = activeTiles;
|
||||
peakDropsRef.current[i] = 0;
|
||||
} else {
|
||||
peakDropsRef.current[i]++;
|
||||
if (peakDropsRef.current[i] > 5) { // Hold peak for 5 frames
|
||||
peakPositionsRef.current[i] = Math.max(0, peakPositionsRef.current[i] - 1);
|
||||
peakDropsRef.current[i] = 0;
|
||||
}
|
||||
}
|
||||
const peakTile = peakPositionsRef.current[i];
|
||||
|
||||
for (let j = 0; j < maxTilesColumn; j++) {
|
||||
const y = dimensions.height - j * TOTAL_ROW_HEIGHT - TILE_SIZE;
|
||||
|
||||
if (j < activeTiles) {
|
||||
// Main Bar
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(x, y, TILE_SIZE, TILE_SIZE, 1 * SCALE);
|
||||
} else {
|
||||
ctx.rect(x, y, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1.0;
|
||||
} else if (j === peakTile && peakTile > 0 && isPlaying) {
|
||||
// Floating Peak
|
||||
ctx.fillStyle = "#FFAB40";
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(x, y, TILE_SIZE, TILE_SIZE, 1 * SCALE);
|
||||
} else {
|
||||
ctx.rect(x, y, TILE_SIZE, TILE_SIZE);
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlaying || peakPositionsRef.current.some((p) => p > 0)) {
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
}
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [isPlaying, isHovering, hoverPercent, dimensions]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
className="block"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
web/frontend/src/components/audio/EmberPlayer.tsx
Normal file
275
web/frontend/src/components/audio/EmberPlayer.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useRef, useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import { Play, Pause, AlertCircle } from "lucide-react";
|
||||
import { AudioVisualizer } from "./AudioVisualizer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EmberPlayerRef {
|
||||
seekTo: (time: number) => void;
|
||||
playPause: () => void;
|
||||
isPlaying: () => boolean;
|
||||
}
|
||||
|
||||
export interface EmberPlayerProps {
|
||||
src?: string;
|
||||
audioId?: string;
|
||||
className?: string;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onPlayStateChange?: (isPlaying: boolean) => void;
|
||||
}
|
||||
|
||||
export const EmberPlayer = forwardRef<EmberPlayerRef, EmberPlayerProps>(
|
||||
({ src, audioId, className, onTimeUpdate, onPlayStateChange }, ref) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Visualizer Interaction State
|
||||
const [hoverTime, setHoverTime] = useState(0);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// --- 1. Parent Control (ForwardRef) ---
|
||||
useImperativeHandle(ref, () => ({
|
||||
seekTo: (time: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
},
|
||||
playPause: () => togglePlay(),
|
||||
isPlaying: () => isPlaying
|
||||
}));
|
||||
|
||||
// --- 2. URL Logic ---
|
||||
let streamUrl = src;
|
||||
if (!streamUrl && audioId) {
|
||||
streamUrl = `/api/v1/transcription/${audioId}/audio`;
|
||||
}
|
||||
|
||||
// --- 3. Audio Handlers ---
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play().catch(e => {
|
||||
console.error("Playback failed:", e);
|
||||
setError("Playback failed.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current && !isDragging) {
|
||||
const time = audioRef.current.currentTime;
|
||||
setCurrentTime(time);
|
||||
onTimeUpdate?.(time);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.current) {
|
||||
setDuration(audioRef.current.duration);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 4. Advanced Scrubber Logic ---
|
||||
const calculateTimeFromEvent = (e: React.MouseEvent | MouseEvent) => {
|
||||
if (!progressRef.current || !duration) return 0;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
let x = e.clientX - rect.left;
|
||||
x = Math.max(0, Math.min(x, rect.width));
|
||||
return (x / rect.width) * duration;
|
||||
};
|
||||
|
||||
const handleScrubberMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true);
|
||||
const time = calculateTimeFromEvent(e);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
// Global mouse listeners for dragging outside the component
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging && audioRef.current && progressRef.current) {
|
||||
const time = calculateTimeFromEvent(e);
|
||||
audioRef.current.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleGlobalMouseMove);
|
||||
window.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleGlobalMouseMove);
|
||||
window.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
};
|
||||
}, [isDragging, duration]);
|
||||
|
||||
const handleHoverMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || !duration) return;
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = Math.min(Math.max(0, x / rect.width), 1);
|
||||
setHoverTime(percent * duration);
|
||||
};
|
||||
|
||||
// Sync State
|
||||
useEffect(() => {
|
||||
onPlayStateChange?.(isPlaying);
|
||||
}, [isPlaying, onPlayStateChange]);
|
||||
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return "00:00";
|
||||
const min = Math.floor(time / 60);
|
||||
const sec = Math.floor(time % 60);
|
||||
return `${min.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const hoverPercent = duration > 0 ? hoverTime / duration : 0;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full h-32 flex items-center justify-center text-[var(--error)] bg-[var(--error)]/10 rounded-[var(--radius-card)] border border-[var(--error)]/20">
|
||||
<AlertCircle className="w-5 h-5 mr-2" />
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Base Container Styles - designed to fill the parent card
|
||||
"relative w-full overflow-hidden rounded-[var(--radius-card)]",
|
||||
// Use standard background matching the design system
|
||||
"bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Visualizer Layer (Background) */}
|
||||
{/* Removed mix-blend-screen which makes viz invisible on white. Added minimal opacity for subtlety */}
|
||||
<div className="absolute inset-0 z-0 h-full w-full pointer-events-none opacity-40">
|
||||
<AudioVisualizer
|
||||
audioRef={audioRef}
|
||||
isPlaying={isPlaying}
|
||||
isHovering={isHovering}
|
||||
hoverPercent={hoverPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls Layer */}
|
||||
<div className="relative z-10 flex flex-col px-1 py-1 gap-3">
|
||||
{/* Top Row: Button & Time */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[image:var(--brand-gradient)] text-white shadow-lg shadow-orange-500/20 hover:scale-105 active:scale-95 transition-all focus:outline-none cursor-pointer"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={20} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={20} fill="currentColor" className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-mono text-xs font-medium text-[var(--text-secondary)] tabular-nums tracking-wide">
|
||||
{formatTime(currentTime)}{" "}
|
||||
<span className="text-[var(--text-tertiary)] mx-0.5">/</span>{" "}
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)] font-bold uppercase tracking-widest mt-0.5 opacity-80">
|
||||
{isPlaying ? "Playing" : "Ready"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Interactive Scrubber */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative w-full h-5 flex items-center group cursor-pointer mt-1"
|
||||
onMouseMove={handleHoverMove}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onMouseDown={handleScrubberMouseDown}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full mb-3 px-2 py-1 rounded bg-[var(--text-primary)] text-[10px] font-mono text-[var(--bg-main)] shadow-sm pointer-events-none transition-opacity duration-200 z-30",
|
||||
isHovering ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
style={{
|
||||
left: `${duration > 0 ? (hoverTime / duration) * 100 : 0}%`,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{formatTime(hoverTime)}
|
||||
</div>
|
||||
|
||||
{/* Track Background - Darker for visibility on white */}
|
||||
<div className="absolute w-full h-[4px] bg-[var(--border-focus)] rounded-full overflow-hidden group-hover:h-[6px] transition-all">
|
||||
{/* Progress Fill */}
|
||||
<div
|
||||
className="h-full bg-[image:var(--brand-gradient)] shadow-sm transition-all duration-100 ease-linear"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumb Indicator - White with Shadow, visible on track */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-3.5 w-3.5 bg-white border border-[var(--border-subtle)] rounded-full shadow-md ml-[-7px] pointer-events-none transition-all duration-100 ease-linear",
|
||||
isHovering || isDragging
|
||||
? "scale-100 opacity-100"
|
||||
: "scale-0 opacity-0"
|
||||
)}
|
||||
style={{ left: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden Audio Element */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={streamUrl}
|
||||
preload="metadata"
|
||||
crossOrigin="use-credentials" // Sends cookies AND allows Web Audio API access (with backend support)
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onError={(e) => {
|
||||
console.error("Audio Load Error", e);
|
||||
setError("Unable to load audio stream.");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EmberPlayer.displayName = "EmberPlayer";
|
||||
|
||||
EmberPlayer.displayName = "EmberPlayer";
|
||||
@@ -6,8 +6,7 @@ import { Header } from "@/components/Header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AudioPlayer } from "@/components/audio/AudioPlayer";
|
||||
import type { AudioPlayerRef } from "@/components/audio/AudioPlayer";
|
||||
import { EmberPlayer, type EmberPlayerRef } from "@/components/audio/EmberPlayer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Custom Hooks
|
||||
@@ -32,7 +31,7 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Refs
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
const audioPlayerRef = useRef<EmberPlayerRef>(null);
|
||||
|
||||
// State
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -286,9 +285,10 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
|
||||
3. Audio Player:
|
||||
- Floating Card style
|
||||
- 1px hairline border
|
||||
- Replaced with EmberPlayer (Streaming + Viz)
|
||||
*/}
|
||||
<div className="sticky top-6 z-40 glass-card rounded-[var(--radius-card)] border-[var(--border-subtle)] shadow-[var(--shadow-card)] p-4 md:p-6 mb-8 transition-all duration-300 hover:shadow-[var(--shadow-float)]">
|
||||
<AudioPlayer
|
||||
<EmberPlayer
|
||||
ref={audioPlayerRef}
|
||||
audioId={audioId}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
|
||||
Reference in New Issue
Block a user