diff --git a/apps/web/src/features/player/components/AudioVisualizer.test.tsx b/apps/web/src/features/player/components/AudioVisualizer.test.tsx new file mode 100644 index 000000000..6e60fe639 --- /dev/null +++ b/apps/web/src/features/player/components/AudioVisualizer.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AudioVisualizer } from './AudioVisualizer'; + +// Mock canvas getContext since JSDOM doesn't implement Canvas 2D +beforeAll(() => { + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(null); +}); + +describe('AudioVisualizer', () => { + const defaultProps = { + bands: Array(64).fill(0.5), + frequencyData: new Uint8Array(128), + waveformData: new Uint8Array(512), + isPlaying: true, + }; + + it('should render canvas element', () => { + render(); + const canvas = screen.getByRole('img', { name: /audio visualizer/i }); + expect(canvas).toBeDefined(); + expect(canvas.tagName).toBe('CANVAS'); + }); + + it('should render mode selector buttons', () => { + render(); + expect(screen.getByLabelText('Equalizer')).toBeDefined(); + expect(screen.getByLabelText('Waveform')).toBeDefined(); + expect(screen.getByLabelText('Spectrogram')).toBeDefined(); + }); + + it('should switch modes on button click', () => { + render(); + const waveBtn = screen.getByLabelText('Waveform'); + fireEvent.click(waveBtn); + const canvas = screen.getByRole('img', { name: /wave mode/i }); + expect(canvas).toBeDefined(); + }); + + it('should start in bars mode', () => { + render(); + const canvas = screen.getByRole('img', { name: /bars mode/i }); + expect(canvas).toBeDefined(); + }); + + it('should render with empty data when not playing', () => { + render(); + const canvas = screen.getByRole('img'); + expect(canvas).toBeDefined(); + }); + + it('should accept custom className', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.classList.contains('custom-class')).toBe(true); + }); +}); diff --git a/apps/web/src/features/player/components/AudioVisualizer.tsx b/apps/web/src/features/player/components/AudioVisualizer.tsx new file mode 100644 index 000000000..2713d95b5 --- /dev/null +++ b/apps/web/src/features/player/components/AudioVisualizer.tsx @@ -0,0 +1,296 @@ +/** + * AudioVisualizer — Canvas-based audio visualization + * v0.13.4 TASK-APLSH-004: Spectrogram/Equalizer visualisers + * + * Three modes: + * - bars: Frequency equalizer bars with SUMI gradient + * - spectrogram: Scrolling time-frequency waterfall + * - wave: Oscilloscope waveform + */ + +import { useRef, useEffect, useCallback, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { BarChart3, Activity, Radio } from 'lucide-react'; +import type { VisualizerMode } from '../hooks/useSpectrumAnalyser'; + +interface AudioVisualizerProps { + /** Normalized frequency bands [0-1] */ + bands: number[]; + /** Raw frequency data (Uint8Array) for spectrogram */ + frequencyData: Uint8Array; + /** Time-domain waveform data for oscilloscope */ + waveformData: Uint8Array; + isPlaying: boolean; + className?: string; +} + +const MODES: { mode: VisualizerMode; icon: typeof BarChart3; label: string }[] = [ + { mode: 'bars', icon: BarChart3, label: 'Equalizer' }, + { mode: 'wave', icon: Activity, label: 'Waveform' }, + { mode: 'spectrogram', icon: Radio, label: 'Spectrogram' }, +]; + +// SUMI colors +const ACCENT_COLOR = '#7c9dd6'; // --sumi-accent +const SAGE = '#7a9e6c'; // --sumi-sage +const GOLD = '#c9a84c'; // --sumi-gold +const BG_VOID = '#0c0c0f'; // --sumi-bg-void + +export function AudioVisualizer({ + bands, + frequencyData, + waveformData, + isPlaying, + className, +}: AudioVisualizerProps) { + const canvasRef = useRef(null); + const spectrogramRef = useRef(null); + const [mode, setMode] = useState('bars'); + + const drawBars = useCallback( + (ctx: CanvasRenderingContext2D, W: number, H: number) => { + ctx.fillStyle = BG_VOID; + ctx.fillRect(0, 0, W, H); + + if (!isPlaying || bands.length === 0) { + // Idle state: flat line + ctx.strokeStyle = ACCENT_COLOR + '40'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, H / 2); + ctx.lineTo(W, H / 2); + ctx.stroke(); + return; + } + + const barCount = bands.length; + const gap = 2; + const barWidth = (W - gap * (barCount - 1)) / barCount; + const cornerRadius = Math.min(barWidth / 2, 3); + + for (let i = 0; i < barCount; i++) { + const level = bands[i] ?? 0; + const barH = Math.max(2, level * (H - 8)); + const x = i * (barWidth + gap); + const y = H - barH; + + // Gradient from accent to vermillion based on frequency + const t = i / barCount; + const r = lerp(0x7c, 0xd4, t); + const g = lerp(0x9d, 0x63, t); + const b = lerp(0xd6, 0x4a, t); + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + + // Rounded top rect + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') { + ctx.roundRect(x, y, barWidth, barH, [cornerRadius, cornerRadius, 0, 0]); + } else { + ctx.rect(x, y, barWidth, barH); + } + ctx.fill(); + + // Glow at peaks + if (level > 0.7) { + ctx.shadowColor = `rgba(${r}, ${g}, ${b}, 0.6)`; + ctx.shadowBlur = 8; + ctx.fillRect(x, y, barWidth, 2); + ctx.shadowBlur = 0; + } + } + }, + [bands, isPlaying], + ); + + const drawWave = useCallback( + (ctx: CanvasRenderingContext2D, W: number, H: number) => { + ctx.fillStyle = BG_VOID; + ctx.fillRect(0, 0, W, H); + + if (!isPlaying || waveformData.length === 0) { + ctx.strokeStyle = ACCENT_COLOR + '40'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, H / 2); + ctx.lineTo(W, H / 2); + ctx.stroke(); + return; + } + + const sliceWidth = W / waveformData.length; + + // Fill gradient under the wave + const gradient = ctx.createLinearGradient(0, 0, W, 0); + gradient.addColorStop(0, SAGE + '20'); + gradient.addColorStop(0.5, ACCENT_COLOR + '20'); + gradient.addColorStop(1, GOLD + '20'); + + ctx.beginPath(); + ctx.moveTo(0, H / 2); + for (let i = 0; i < waveformData.length; i++) { + const v = (waveformData[i] ?? 128) / 128.0; + const y = (v * H) / 2; + const x = i * sliceWidth; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.lineTo(W, H / 2); + ctx.fillStyle = gradient; + ctx.fill(); + + // Stroke the wave line + ctx.beginPath(); + for (let i = 0; i < waveformData.length; i++) { + const v = (waveformData[i] ?? 128) / 128.0; + const y = (v * H) / 2; + const x = i * sliceWidth; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + + const lineGradient = ctx.createLinearGradient(0, 0, W, 0); + lineGradient.addColorStop(0, SAGE); + lineGradient.addColorStop(0.5, ACCENT_COLOR); + lineGradient.addColorStop(1, GOLD); + ctx.strokeStyle = lineGradient; + ctx.lineWidth = 2; + ctx.stroke(); + }, + [waveformData, isPlaying], + ); + + const drawSpectrogram = useCallback( + (ctx: CanvasRenderingContext2D, W: number, H: number) => { + if (!isPlaying || frequencyData.length === 0) { + ctx.fillStyle = BG_VOID; + ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = ACCENT_COLOR + '40'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, H / 2); + ctx.lineTo(W, H / 2); + ctx.stroke(); + spectrogramRef.current = null; + return; + } + + // Shift existing image left by 1 column + if (spectrogramRef.current) { + ctx.putImageData(spectrogramRef.current, -1, 0); + } else { + ctx.fillStyle = BG_VOID; + ctx.fillRect(0, 0, W, H); + } + + // Draw new column on the right edge + const colX = W - 1; + const binCount = frequencyData.length; + const binHeight = H / binCount; + + for (let i = 0; i < binCount; i++) { + const value = frequencyData[i] ?? 0; + const intensity = value / 255; + // Bottom = low freq, top = high freq + const y = H - (i + 1) * binHeight; + + // Color mapping: dark → accent → vermillion → gold (heat) + const [r, g, b] = spectrogramColor(intensity); + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + ctx.fillRect(colX, y, 1, Math.ceil(binHeight)); + } + + // Save for next frame shift + spectrogramRef.current = ctx.getImageData(0, 0, W, H); + }, + [frequencyData, isPlaying], + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx || typeof ctx.fillRect !== 'function') return; + + // Set canvas size to match display size + const rect = canvas.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const W = rect.width || 300; + const H = rect.height || 150; + canvas.width = W * dpr; + canvas.height = H * dpr; + if (typeof ctx.scale === 'function') ctx.scale(dpr, dpr); + + switch (mode) { + case 'bars': + drawBars(ctx, W, H); + break; + case 'wave': + drawWave(ctx, W, H); + break; + case 'spectrogram': + drawSpectrogram(ctx, W, H); + break; + } + }, [mode, drawBars, drawWave, drawSpectrogram]); + + // Reset spectrogram data when switching modes + useEffect(() => { + spectrogramRef.current = null; + }, [mode]); + + return ( +
+ + {/* Mode selector */} +
+ {MODES.map(({ mode: m, icon: Icon, label }) => ( + + ))} +
+
+ ); +} + +function lerp(a: number, b: number, t: number): number { + return Math.round(a + (b - a) * t); +} + +function spectrogramColor(intensity: number): [number, number, number] { + // 0..0.25: black → deep blue + // 0.25..0.5: deep blue → accent + // 0.5..0.75: accent → vermillion + // 0.75..1: vermillion → gold + if (intensity < 0.25) { + const t = intensity / 0.25; + return [lerp(12, 40, t), lerp(12, 50, t), lerp(15, 100, t)]; + } else if (intensity < 0.5) { + const t = (intensity - 0.25) / 0.25; + return [lerp(40, 0x7c, t), lerp(50, 0x9d, t), lerp(100, 0xd6, t)]; + } else if (intensity < 0.75) { + const t = (intensity - 0.5) / 0.25; + return [lerp(0x7c, 0xd4, t), lerp(0x9d, 0x63, t), lerp(0xd6, 0x4a, t)]; + } else { + const t = (intensity - 0.75) / 0.25; + return [lerp(0xd4, 0xc9, t), lerp(0x63, 0xa8, t), lerp(0x4a, 0x4c, t)]; + } +} diff --git a/apps/web/src/features/player/components/GlobalPlayer.tsx b/apps/web/src/features/player/components/GlobalPlayer.tsx index f825a5b0c..41abad31c 100644 --- a/apps/web/src/features/player/components/GlobalPlayer.tsx +++ b/apps/web/src/features/player/components/GlobalPlayer.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { usePlayer } from '@/features/player/hooks/usePlayer'; import { usePictureInPicture } from '@/features/player/hooks/usePictureInPicture'; @@ -7,6 +7,8 @@ import { useAudioAnalyser } from '@/features/player/hooks/useAudioAnalyser'; import { useAudioNormalization } from '@/features/player/hooks/useAudioNormalization'; import { useMediaSession } from '@/features/player/hooks/useMediaSession'; import { useWakeLock } from '@/features/player/hooks/useWakeLock'; +import { useCastSupport } from '@/features/player/hooks/useCastSupport'; +import { useAirPlaySupport } from '@/features/player/hooks/useAirPlaySupport'; import { useUIStore } from '@/stores/ui'; import { formatTime } from '@/features/player/services/playerService'; import { PlayerControls } from './PlayerControls'; @@ -53,10 +55,25 @@ export function GlobalPlayer() { const displayTrack = currentTrack || IDLE_TRACK; const isIdle = !currentTrack; - const { setVideoRef: setPiPVideoRef, togglePiP, isPiPActive, isSupported: isPiPSupported } = usePictureInPicture( + const { setVideoRef: setPiPVideoRef, togglePiP, isPiPActive, isSupported: isPiPSupported, updateTrackInfo: updatePiPTrackInfo } = usePictureInPicture( currentTrack?.cover ?? null, ); + // v0.13.4 TASK-APLSH-002/003: Cast & AirPlay detection + const cast = useCastSupport(); + const airplay = useAirPlaySupport(audioEl); + + // v0.13.4 TASK-APLSH-001: Update PiP canvas when track changes + useEffect(() => { + if (currentTrack && isPiPActive) { + updatePiPTrackInfo({ + title: currentTrack.title, + artist: currentTrack.artist || 'Unknown Artist', + cover: currentTrack.cover, + }); + } + }, [currentTrack, isPiPActive, updatePiPTrackInfo]); + useWakeLock(player.isPlaying); const SEEK_STEP_SEC = 10; @@ -94,6 +111,7 @@ export function GlobalPlayer() { duration={player.duration} onSeek={player.seek} player={player} + audioElement={audioEl} /> diff --git a/apps/web/src/features/player/components/PlayerExpanded.tsx b/apps/web/src/features/player/components/PlayerExpanded.tsx index 115fec912..d192cffca 100644 --- a/apps/web/src/features/player/components/PlayerExpanded.tsx +++ b/apps/web/src/features/player/components/PlayerExpanded.tsx @@ -6,12 +6,14 @@ import { Slider } from '@/components/ui/slider'; import { Tooltip } from '@/components/ui/tooltip'; import { ChevronDown, Heart, MoreHorizontal, Share2, - Mic2, AlignLeft, Settings2 + Mic2, AlignLeft, Settings2, BarChart3 } from 'lucide-react'; import { PlayPauseButton } from './PlayPauseButton'; import { NextPreviousButtons } from './NextPreviousButtons'; import { RepeatShuffleButtons } from './RepeatShuffleButtons'; import { AudioSettingsPanel } from './AudioSettingsPanel'; +import { AudioVisualizer } from './AudioVisualizer'; +import { useSpectrumAnalyser } from '../hooks/useSpectrumAnalyser'; interface PlayerExpandedProps { isOpen: boolean; @@ -20,16 +22,21 @@ interface PlayerExpandedProps { duration: number; onSeek: (time: number) => void; player: any; // Using the player hook object + audioElement?: HTMLAudioElement | null; } -export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, player }: PlayerExpandedProps) { +export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, player, audioElement }: PlayerExpandedProps) { const { currentTrack } = usePlayerStore(); const [showLyrics, setShowLyrics] = useState(false); const [showAudioSettings, setShowAudioSettings] = useState(false); + const [showVisualizer, setShowVisualizer] = useState(false); const [autoScrollLyrics, setAutoScrollLyrics] = useState(true); const lyricsScrollRef = useRef(null); const lyrics = currentTrack?.lyrics; + // v0.13.4 TASK-APLSH-004: Spectrum analyser for visualizer + const spectrum = useSpectrumAnalyser(audioElement ?? null, player.isPlaying, showVisualizer && isOpen); + // Auto-scroll lyrics to active line (must be before early return - hooks rules) useEffect(() => { if (!isOpen || !currentTrack || !autoScrollLyrics || !lyrics?.length || !lyricsScrollRef.current) return; @@ -180,6 +187,17 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek, + + + )} + {castAvailable && onCast && ( + + )} + {airplayAvailable && onAirPlay && ( + + )}