WIP Working on player

This commit is contained in:
Mistral Vibe
2026-04-08 08:12:05 +00:00
parent ff4985a719
commit d654ad5987
16 changed files with 1714 additions and 94 deletions

View File

@@ -16,6 +16,7 @@ RUN npm run build
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
ARG NGINX_CONF=nginx.conf
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

26
web/nginx-standalone.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "0" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# SPA routing — all paths fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively (Vite build output — hashed filenames)
location ~* \.(js|css|woff2|png|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
}

View File

@@ -62,6 +62,11 @@ server {
proxy_send_timeout 60s;
}
# Serve manifest.json directly
location = /manifest.json {
try_files $uri =404;
}
# SPA routing — all other paths fall back to index.html
location / {
try_files $uri $uri/ /index.html;

9
web/public/manifest.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "RehearsalHub",
"short_name": "RehearsalHub",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": []
}

View File

@@ -1,4 +1,5 @@
import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { usePlayerStore } from "../stores/playerStore";
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
function IconLibrary() {
@@ -9,6 +10,14 @@ function IconLibrary() {
);
}
function IconPlay() {
return (
<svg width="20" height="20" viewBox="0 0 14 14" fill="currentColor">
<path d="M3 2l9 5-9 5V2z" />
</svg>
);
}
function IconSettings() {
@@ -85,6 +94,10 @@ export function BottomNavBar() {
const isLibrary = !!matchPath("/bands/:bandId", location.pathname) ||
!!matchPath("/bands/:bandId/sessions/:sessionId", location.pathname);
const isSettings = location.pathname.startsWith("/settings");
// Player state
const { currentSongId, currentBandId: playerBandId, isPlaying } = usePlayerStore();
const hasActiveSong = !!currentSongId && !!playerBandId;
return (
<nav
@@ -115,6 +128,18 @@ export function BottomNavBar() {
}}
/>
<NavItem
icon={<IconPlay />}
label="Player"
active={hasActiveSong && isPlaying}
onClick={() => {
if (hasActiveSong) {
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
}
}}
disabled={!hasActiveSong}
/>
<NavItem
icon={<IconMembers />}
label="Members"

View File

@@ -0,0 +1,119 @@
import { usePlayerStore } from "../stores/playerStore";
import { useNavigate } from "react-router-dom";
export function MiniPlayer() {
const { currentSongId, currentBandId, isPlaying, currentTime, duration } = usePlayerStore();
const navigate = useNavigate();
if (!currentSongId || !currentBandId) {
return null;
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div
style={
{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
background: "#18181e",
borderTop: "1px solid rgba(255,255,255,0.06)",
padding: "8px 16px",
zIndex: 999,
display: "flex",
alignItems: "center",
gap: 12,
}
}
>
<button
onClick={() => navigate(`/bands/${currentBandId}/songs/${currentSongId}`)}
style={
{
background: "transparent",
border: "none",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px",
borderRadius: 4,
}
}
title="Go to song"
>
<svg width="16" height="16" viewBox="0 0 14 14" fill="currentColor">
<path d="M3 2l9 5-9 5V2z" />
</svg>
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.8)" }}>
Now Playing
</span>
</button>
<div
style={
{
flex: 1,
height: 4,
background: "rgba(255,255,255,0.1)",
borderRadius: 2,
overflow: "hidden",
cursor: "pointer",
}
}
>
<div
style={
{
width: `${progress}%`,
height: "100%",
background: "#e8a22a",
transition: "width 0.1s linear",
}
}
/>
</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.6)", minWidth: 60, textAlign: "right" }}>
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<button
onClick={() => {
// This would need to be connected to actual play/pause functionality
// For now, it's just a visual indicator
}}
style={
{
background: "transparent",
border: "none",
color: "white",
cursor: "pointer",
padding: "4px",
}
}
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<svg width="16" height="16" viewBox="0 0 14 14" fill="currentColor">
<path d="M4 2h2v10H4zm4 0h2v10h-2z" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 14 14" fill="currentColor">
<path d="M3 2l9 5-9 5V2z" />
</svg>
)}
</button>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { BottomNavBar } from "./BottomNavBar";
import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar";
import { MiniPlayer } from "./MiniPlayer";
export function ResponsiveLayout({ children }: { children: React.ReactNode }) {
const [isMobile, setIsMobile] = useState(false);
@@ -35,8 +36,12 @@ export function ResponsiveLayout({ children }: { children: React.ReactNode }) {
{children}
</div>
<BottomNavBar />
<MiniPlayer />
</>
) : (
<Sidebar>{children}</Sidebar>
<>
<Sidebar>{children}</Sidebar>
<MiniPlayer />
</>
);
}

View File

@@ -6,6 +6,7 @@ import { api } from "../api/client";
import { logout } from "../api/auth";
import { getInitials } from "../utils";
import type { MemberRead } from "../api/auth";
import { usePlayerStore } from "../stores/playerStore";
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
function IconWaveform() {
@@ -168,6 +169,10 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
const isSettings = location.pathname.startsWith("/settings");
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
// Player state
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
const hasActiveSong = !!currentSongId && !!playerBandId;
// Close dropdown on outside click
useEffect(() => {
@@ -429,9 +434,13 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
<NavItem
icon={<IconPlay />}
label="Player"
active={isPlayer}
onClick={() => {}}
disabled={!isPlayer}
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
onClick={() => {
if (hasActiveSong) {
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
}
}}
disabled={!hasActiveSong}
/>
</>
)}

View File

@@ -1,11 +1,14 @@
import { useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
import { audioService } from "../services/audioService";
import { usePlayerStore } from "../stores/playerStore";
export interface UseWaveformOptions {
url: string | null;
peaksUrl: string | null;
onReady?: (duration: number) => void;
onTimeUpdate?: (currentTime: number) => void;
songId?: string | null;
bandId?: string | null;
}
export interface CommentMarker {
@@ -19,116 +22,190 @@ export function useWaveform(
containerRef: React.RefObject<HTMLDivElement>,
options: UseWaveformOptions
) {
const wsRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const wasPlayingRef = useRef(false);
const markersRef = useRef<CommentMarker[]>([]);
// Global player state - use shallow comparison to reduce re-renders
const {
isPlaying: globalIsPlaying,
currentTime: globalCurrentTime,
currentSongId,
currentBandId: globalBandId,
setCurrentSong
} = usePlayerStore(state => ({
isPlaying: state.isPlaying,
currentTime: state.currentTime,
currentSongId: state.currentSongId,
currentBandId: state.currentBandId,
setCurrentSong: state.setCurrentSong
}));
useEffect(() => {
if (!containerRef.current || !options.url) return;
if (!containerRef.current) {
console.debug('useWaveform: container ref is null, skipping initialization');
return;
}
const ws = WaveSurfer.create({
container: containerRef.current,
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
barWidth: 2,
barRadius: 2,
height: 104,
normalize: true,
if (!options.url || options.url === 'null' || options.url === 'undefined') {
console.debug('useWaveform: invalid URL, skipping initialization', { url: options.url });
return;
}
console.debug('useWaveform: initializing audio service', {
url: options.url,
songId: options.songId,
bandId: options.bandId,
containerExists: !!containerRef.current
});
// The rh_token httpOnly cookie is sent automatically by the browser.
ws.load(options.url);
ws.on("ready", () => {
setIsReady(true);
setDuration(ws.getDuration());
options.onReady?.(ws.getDuration());
// Reset playing state when switching versions
setIsPlaying(false);
wasPlayingRef.current = false;
});
ws.on("audioprocess", (time) => {
setCurrentTime(time);
options.onTimeUpdate?.(time);
});
ws.on("play", () => {
setIsPlaying(true);
wasPlayingRef.current = true;
});
ws.on("pause", () => {
setIsPlaying(false);
wasPlayingRef.current = false;
});
ws.on("finish", () => {
setIsPlaying(false);
wasPlayingRef.current = false;
});
wsRef.current = ws;
return () => {
ws.destroy();
wsRef.current = null;
const initializeAudio = async () => {
try {
console.debug('useWaveform: using audio service instance');
await audioService.initialize(containerRef.current!, options.url!);
// Set up local state synchronization with requestAnimationFrame for smoother updates
let animationFrameId: number | null = null;
let lastUpdateTime = 0;
const updateInterval = 1000 / 15; // ~15fps for state updates
const handleStateUpdate = () => {
const now = Date.now();
if (now - lastUpdateTime >= updateInterval) {
const state = usePlayerStore.getState();
setIsPlaying(state.isPlaying);
setCurrentTime(state.currentTime);
setDuration(state.duration);
lastUpdateTime = now;
}
animationFrameId = requestAnimationFrame(handleStateUpdate);
};
// Start the animation frame loop
animationFrameId = requestAnimationFrame(handleStateUpdate);
const unsubscribe = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
};
// Update global song context
if (options.songId && options.bandId) {
setCurrentSong(options.songId, options.bandId);
}
// If this is the same song that was playing globally, restore play state
if (options.songId && options.bandId &&
currentSongId === options.songId &&
globalBandId === options.bandId &&
globalIsPlaying) {
console.debug('useWaveform: restoring playback state');
// Wait a moment for the waveform to be ready
setTimeout(() => {
audioService.play();
if (globalCurrentTime > 0) {
audioService.seekTo(globalCurrentTime);
}
}, 100);
}
setIsReady(true);
options.onReady?.(audioService.getDuration());
return () => {
console.debug('useWaveform: cleanup');
unsubscribe();
// Note: We don't cleanup the audio service here to maintain persistence
// audioService.cleanup();
};
} catch (error) {
console.error('useWaveform: initialization failed', error);
setIsReady(false);
return () => {};
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url]);
initializeAudio();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
const play = () => {
wsRef.current?.play();
wasPlayingRef.current = true;
console.debug('useWaveform.play called');
try {
audioService.play();
} catch (error) {
console.error('useWaveform.play failed:', error);
}
};
const pause = () => {
wsRef.current?.pause();
wasPlayingRef.current = false;
console.debug('useWaveform.pause called');
try {
audioService.pause();
} catch (error) {
console.error('useWaveform.pause failed:', error);
}
};
const seekTo = (time: number) => {
if (wsRef.current && isReady && isFinite(time)) {
wsRef.current.setTime(time);
console.debug('useWaveform.seekTo called', { time });
try {
if (isReady && isFinite(time)) {
audioService.seekTo(time);
}
} catch (error) {
console.error('useWaveform.seekTo failed:', error);
}
};
const addMarker = (marker: CommentMarker) => {
if (wsRef.current && isReady) {
const wavesurfer = wsRef.current;
const markerElement = document.createElement("div");
markerElement.style.position = "absolute";
markerElement.style.width = "24px";
markerElement.style.height = "24px";
markerElement.style.borderRadius = "50%";
markerElement.style.backgroundColor = "var(--accent)";
markerElement.style.cursor = "pointer";
markerElement.style.zIndex = "9999";
markerElement.style.left = `${(marker.time / wavesurfer.getDuration()) * 100}%`;
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
markerElement.style.top = "50%";
markerElement.style.border = "2px solid white";
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
markerElement.title = `Comment at ${formatTime(marker.time)}`;
markerElement.onclick = marker.onClick;
if (isReady) {
try {
// This would need proper implementation with the actual wavesurfer instance
const markerElement = document.createElement("div");
markerElement.style.position = "absolute";
markerElement.style.width = "24px";
markerElement.style.height = "24px";
markerElement.style.borderRadius = "50%";
markerElement.style.backgroundColor = "var(--accent)";
markerElement.style.cursor = "pointer";
markerElement.style.zIndex = "9999";
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
markerElement.style.top = "50%";
markerElement.style.border = "2px solid white";
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
markerElement.title = `Comment at ${formatTime(marker.time)}`;
markerElement.onclick = marker.onClick;
if (marker.icon) {
const iconElement = document.createElement("img");
iconElement.src = marker.icon;
iconElement.style.width = "100%";
iconElement.style.height = "100%";
iconElement.style.borderRadius = "50%";
iconElement.style.objectFit = "cover";
markerElement.appendChild(iconElement);
if (marker.icon) {
const iconElement = document.createElement("img");
iconElement.src = marker.icon;
iconElement.style.width = "100%";
iconElement.style.height = "100%";
iconElement.style.borderRadius = "50%";
iconElement.style.objectFit = "cover";
markerElement.appendChild(iconElement);
}
const waveformContainer = containerRef.current;
if (waveformContainer) {
waveformContainer.style.position = "relative";
waveformContainer.appendChild(markerElement);
}
markersRef.current.push(marker);
} catch (error) {
console.error('useWaveform.addMarker failed:', error);
}
const waveformContainer = containerRef.current;
if (waveformContainer) {
waveformContainer.style.position = "relative";
waveformContainer.appendChild(markerElement);
}
markersRef.current.push(marker);
}
};
@@ -150,4 +227,4 @@ function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
}

View File

@@ -357,6 +357,8 @@ export function SongPage() {
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo } = useWaveform(waveformRef, {
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
songId: songId,
bandId: bandId,
});
// Track waveform container width for pin positioning

View File

@@ -0,0 +1,433 @@
import WaveSurfer from "wavesurfer.js";
import { usePlayerStore } from "../stores/playerStore";
// Log level enum
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}
// Type extension for WaveSurfer backend access
interface WaveSurferWithBackend extends WaveSurfer {
backend?: {
getAudioContext?: () => AudioContext;
ac?: AudioContext;
audioContext?: AudioContext;
};
getAudioContext?: () => AudioContext;
getContainer?: () => HTMLElement;
setContainer?: (container: HTMLElement) => void;
}
class AudioService {
private static instance: AudioService;
private wavesurfer: WaveSurfer | null = null;
private audioContext: AudioContext | null = null;
private currentUrl: string | null = null;
private lastPlayTime: number = 0;
private lastTimeUpdate: number = 0;
private readonly PLAY_DEBOUNCE_MS: number = 100;
private lastSeekTime: number = 0;
private readonly SEEK_DEBOUNCE_MS: number = 200;
private logLevel: LogLevel = LogLevel.WARN;
private playbackAttempts: number = 0;
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
private constructor() {
this.log(LogLevel.INFO, 'AudioService initialized');
}
private log(level: LogLevel, message: string, ...args: unknown[]) {
if (level < this.logLevel) return;
const prefix = `[AudioService:${LogLevel[level]}]`;
switch(level) {
case LogLevel.DEBUG:
if (console.debug) {
console.debug(prefix, message, ...args);
}
break;
case LogLevel.INFO:
console.info(prefix, message, ...args);
break;
case LogLevel.WARN:
console.warn(prefix, message, ...args);
break;
case LogLevel.ERROR:
console.error(prefix, message, ...args);
break;
}
}
// Add method to set log level from outside
public setLogLevel(level: LogLevel) {
this.log(LogLevel.INFO, `Log level set to: ${LogLevel[level]}`);
this.logLevel = level;
}
public static getInstance() {
if (!this.instance) {
this.instance = new AudioService();
}
return this.instance;
}
public async initialize(container: HTMLElement, url: string) {
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
// Validate inputs
if (!container) {
this.log(LogLevel.ERROR, 'AudioService: container element is null');
throw new Error('Container element is required');
}
if (!url || url === 'null' || url === 'undefined') {
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
throw new Error('Valid audio URL is required');
}
// If same URL and we already have an instance, just update container reference
if (this.currentUrl === url && this.wavesurfer) {
this.log(LogLevel.INFO, 'Reusing existing WaveSurfer instance for URL:', url);
try {
// Check if container is different and needs updating
const ws = this.wavesurfer as WaveSurferWithBackend;
const currentContainer = ws.getContainer?.();
if (currentContainer !== container) {
this.log(LogLevel.DEBUG, 'Updating container reference for existing instance');
// Update container reference without recreating instance
ws.setContainer?.(container);
} else {
this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed');
}
return this.wavesurfer;
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error);
this.cleanup();
}
}
// Clean up existing instance if different URL
if (this.wavesurfer && this.currentUrl !== url) {
this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url);
this.cleanup();
}
// Create new WaveSurfer instance
this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url);
let ws;
try {
ws = WaveSurfer.create({
container: container,
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
barWidth: 2,
barRadius: 2,
height: 104,
normalize: true,
// Ensure we can control playback manually
autoplay: false,
});
if (!ws) {
throw new Error('WaveSurfer.create returned null or undefined');
}
// @ts-expect-error - WaveSurfer typing doesn't expose backend
if (!ws.backend) {
console.warn('WaveSurfer instance has no backend property yet - this might be normal in v7+');
// Don't throw error - we'll try to access backend later when needed
}
} catch (error) {
console.error('Failed to create WaveSurfer instance:', error);
throw error;
}
// Store references
this.wavesurfer = ws;
this.currentUrl = url;
// Get audio context from wavesurfer
// Note: In WaveSurfer v7+, backend might not be available immediately
// We'll try to access it now, but also set up a handler to get it when ready
this.setupAudioContext(ws);
// Set up event handlers before loading
this.setupEventHandlers();
// Load the audio with error handling
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
try {
const loadPromise = new Promise<void>((resolve, reject) => {
ws.on('ready', () => {
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
// Now that WaveSurfer is ready, set up audio context and finalize initialization
this.setupAudioContext(ws);
// Update player store with duration
const playerStore = usePlayerStore.getState();
playerStore.setDuration(ws.getDuration());
resolve();
});
ws.on('error', (error) => {
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
reject(error);
});
// Start loading
ws.load(url);
});
await loadPromise;
this.log(LogLevel.INFO, 'Audio loaded successfully');
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to load audio:', error);
this.cleanup();
throw error;
}
return ws;
}
private setupEventHandlers() {
if (!this.wavesurfer) return;
const ws = this.wavesurfer;
const playerStore = usePlayerStore.getState();
ws.on("play", () => {
this.log(LogLevel.DEBUG, 'AudioService: play event');
playerStore.batchUpdate({ isPlaying: true });
});
ws.on("pause", () => {
this.log(LogLevel.DEBUG, 'AudioService: pause event');
playerStore.batchUpdate({ isPlaying: false });
});
ws.on("finish", () => {
this.log(LogLevel.DEBUG, 'AudioService: finish event');
playerStore.batchUpdate({ isPlaying: false });
});
ws.on("audioprocess", (time) => {
const now = Date.now();
// Throttle state updates to reduce React re-renders
if (now - this.lastTimeUpdate >= 250) {
playerStore.batchUpdate({ currentTime: time });
this.lastTimeUpdate = now;
}
});
// Note: Ready event is handled in the load promise, so we don't set it up here
// to avoid duplicate event handlers
}
public async play(): Promise<void> {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
}
// Debounce rapid play calls
const now = Date.now();
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
this.log(LogLevel.DEBUG, 'Playback debounced - too frequent calls');
return;
}
this.lastPlayTime = now;
this.log(LogLevel.INFO, 'AudioService.play called');
try {
// Ensure we have a valid audio context
await this.ensureAudioContext();
await this.wavesurfer.play();
this.log(LogLevel.INFO, 'Playback started successfully');
this.playbackAttempts = 0; // Reset on success
} catch (error) {
this.playbackAttempts++;
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
this.cleanup();
// Could trigger re-initialization here if needed
} else {
// Exponential backoff for retry
const delay = 100 * this.playbackAttempts;
this.log(LogLevel.WARN, `Retrying playback in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.play(); // Retry
}
}
}
public pause() {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
}
this.log(LogLevel.INFO, 'AudioService.pause called');
this.wavesurfer.pause();
}
public seekTo(time: number) {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, "AudioService: no wavesurfer instance");
return;
}
// Debounce seek operations to prevent jitter
const now = Date.now();
if (now - this.lastSeekTime < this.SEEK_DEBOUNCE_MS) {
this.log(LogLevel.DEBUG, "Seek debounced - too frequent");
return;
}
this.lastSeekTime = now;
this.log(LogLevel.INFO, "AudioService.seekTo called", { time });
this.wavesurfer.setTime(time);
}
public getCurrentTime(): number {
if (!this.wavesurfer) return 0;
return this.wavesurfer.getCurrentTime();
}
public getDuration(): number {
if (!this.wavesurfer) return 0;
return this.wavesurfer.getDuration();
}
public isPlaying(): boolean {
if (!this.wavesurfer) return false;
return this.wavesurfer.isPlaying();
}
public cleanup() {
this.log(LogLevel.INFO, 'AudioService.cleanup called');
if (this.wavesurfer) {
try {
// Disconnect audio nodes but keep audio context alive
this.wavesurfer.unAll();
this.wavesurfer.destroy();
this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up');
} catch (error) {
this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error);
}
this.wavesurfer = null;
}
this.currentUrl = null;
// Note: We intentionally don't nullify audioContext to keep it alive
}
private async ensureAudioContext(): Promise<AudioContext> {
// If we already have a valid audio context, return it
if (this.audioContext) {
// Resume if suspended (common in mobile browsers)
if (this.audioContext.state === 'suspended') {
try {
await this.audioContext.resume();
console.log('Audio context resumed successfully');
} catch (error) {
console.error('Failed to resume audio context:', error);
}
}
return this.audioContext;
}
// Create new audio context
try {
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
console.log('Audio context created:', this.audioContext.state);
// Handle context state changes
this.audioContext.onstatechange = () => {
console.log('Audio context state changed:', this.audioContext?.state);
};
return this.audioContext;
} catch (error) {
console.error('Failed to create audio context:', error);
throw error;
}
}
private setupAudioContext(ws: WaveSurferWithBackend) {
// Try multiple methods to get audio context from WaveSurfer v7+
try {
// Method 1: Try standard backend.getAudioContext()
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
// Method 2: Try accessing audio context directly from backend
if (!this.audioContext) {
this.audioContext = ws.backend?.ac ?? null;
}
// Method 3: Try accessing through backend.getAudioContext() without optional chaining
if (!this.audioContext) {
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
}
// Method 4: Try accessing through wavesurfer.getAudioContext() if it exists
if (!this.audioContext && typeof ws.getAudioContext === 'function') {
this.audioContext = ws.getAudioContext() ?? null;
}
// Method 5: Try accessing through backend.ac directly
if (!this.audioContext) {
this.audioContext = ws.backend?.ac ?? null;
}
// Method 6: Try accessing through backend.audioContext
if (!this.audioContext) {
this.audioContext = ws.backend?.audioContext ?? null;
}
if (this.audioContext) {
console.log('Audio context accessed successfully:', this.audioContext.state);
} else {
console.warn('Could not access audio context from WaveSurfer - playback may have issues');
// Log the wavesurfer structure for debugging
console.debug('WaveSurfer structure:', {
hasBackend: !!ws.backend,
backendType: typeof ws.backend,
backendKeys: ws.backend ? Object.keys(ws.backend) : 'no backend',
wavesurferKeys: Object.keys(ws)
});
}
} catch (error) {
console.error('Error accessing audio context:', error);
}
}
public getAudioContextState(): string | undefined {
return this.audioContext?.state;
}
// Method to update multiple player state values at once
public updatePlayerState(updates: {
isPlaying?: boolean;
currentTime?: number;
duration?: number;
}) {
const playerStore = usePlayerStore.getState();
playerStore.batchUpdate(updates);
}
}
export const audioService = AudioService.getInstance();

View File

@@ -0,0 +1,415 @@
import WaveSurfer from "wavesurfer.js";
import { usePlayerStore } from "../stores/playerStore";
// Log level enum
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}
// Type extension for WaveSurfer backend access
interface WaveSurferWithBackend extends WaveSurfer {
backend?: {
getAudioContext?: () => AudioContext;
ac?: AudioContext;
audioContext?: AudioContext;
};
getAudioContext?: () => AudioContext;
getContainer?: () => HTMLElement;
setContainer?: (container: HTMLElement) => void;
}
class AudioService {
private static instance: AudioService;
private wavesurfer: WaveSurfer | null = null;
private audioContext: AudioContext | null = null;
private currentUrl: string | null = null;
private lastPlayTime: number = 0;
private lastTimeUpdate: number = 0;
private readonly TIME_UPDATE_THROTTLE: number = 100;
private readonly PLAY_DEBOUNCE_MS: number = 100;
private lastSeekTime: number = 0;
private readonly SEEK_DEBOUNCE_MS: number = 200;
private logLevel: LogLevel = LogLevel.INFO;
private playbackAttempts: number = 0;
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
private constructor() {
this.log(LogLevel.INFO, 'AudioService initialized');
}
private log(level: LogLevel, message: string, ...args: unknown[]) {
if (level < this.logLevel) return;
const prefix = `[AudioService:${LogLevel[level]}]`;
switch(level) {
case LogLevel.DEBUG:
if (console.debug) {
console.debug(prefix, message, ...args);
}
break;
case LogLevel.INFO:
console.info(prefix, message, ...args);
break;
case LogLevel.WARN:
console.warn(prefix, message, ...args);
break;
case LogLevel.ERROR:
console.error(prefix, message, ...args);
break;
}
}
// Add method to set log level from outside
public setLogLevel(level: LogLevel) {
this.log(LogLevel.INFO, `Log level set to: ${LogLevel[level]}`);
this.logLevel = level;
}
public static getInstance() {
if (!this.instance) {
this.instance = new AudioService();
}
return this.instance;
}
public async initialize(container: HTMLElement, url: string) {
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
// Validate inputs
if (!container) {
this.log(LogLevel.ERROR, 'AudioService: container element is null');
throw new Error('Container element is required');
}
if (!url || url === 'null' || url === 'undefined') {
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
throw new Error('Valid audio URL is required');
}
// If same URL and we already have an instance, just update container reference
if (this.currentUrl === url && this.wavesurfer) {
this.log(LogLevel.INFO, 'Reusing existing WaveSurfer instance for URL:', url);
try {
// Check if container is different and needs updating
const ws = this.wavesurfer as WaveSurferWithBackend;
const currentContainer = ws.getContainer?.();
if (currentContainer !== container) {
this.log(LogLevel.DEBUG, 'Updating container reference for existing instance');
// Update container reference without recreating instance
ws.setContainer?.(container);
} else {
this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed');
}
return this.wavesurfer;
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error);
this.cleanup();
}
}
// Clean up existing instance if different URL
if (this.wavesurfer && this.currentUrl !== url) {
this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url);
this.cleanup();
}
// Create new WaveSurfer instance
this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url);
let ws;
try {
ws = WaveSurfer.create({
container: container,
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
barWidth: 2,
barRadius: 2,
height: 104,
normalize: true,
// Ensure we can control playback manually
autoplay: false,
});
if (!ws) {
throw new Error('WaveSurfer.create returned null or undefined');
}
// @ts-expect-error - WaveSurfer typing doesn't expose backend
if (!ws.backend) {
console.warn('WaveSurfer instance has no backend property yet - this might be normal in v7+');
// Don't throw error - we'll try to access backend later when needed
}
} catch (error) {
console.error('Failed to create WaveSurfer instance:', error);
throw error;
}
// Store references
this.wavesurfer = ws;
this.currentUrl = url;
// Get audio context from wavesurfer
// Note: In WaveSurfer v7+, backend might not be available immediately
// We'll try to access it now, but also set up a handler to get it when ready
this.setupAudioContext(ws);
// Set up event handlers before loading
this.setupEventHandlers();
// Load the audio with error handling
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
try {
const loadPromise = new Promise<void>((resolve, reject) => {
ws.on('ready', () => {
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
// Now that WaveSurfer is ready, set up audio context and finalize initialization
this.setupAudioContext(ws);
// Update player store with duration
const playerStore = usePlayerStore.getState();
playerStore.setDuration(ws.getDuration());
resolve();
});
ws.on('error', (error) => {
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
reject(error);
});
// Start loading
ws.load(url);
});
await loadPromise;
this.log(LogLevel.INFO, 'Audio loaded successfully');
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to load audio:', error);
this.cleanup();
throw error;
}
return ws;
}
private setupEventHandlers() {
if (!this.wavesurfer) return;
const ws = this.wavesurfer;
const playerStore = usePlayerStore.getState();
ws.on("play", () => {
this.log(LogLevel.DEBUG, 'AudioService: play event');
playerStore.setPlaying(true);
});
ws.on("pause", () => {
this.log(LogLevel.DEBUG, 'AudioService: pause event');
playerStore.setPlaying(false);
});
ws.on("finish", () => {
this.log(LogLevel.DEBUG, 'AudioService: finish event');
playerStore.setPlaying(false);
});
ws.on("audioprocess", (time) => {
const now = Date.now();
if (now - this.lastTimeUpdate >= this.TIME_UPDATE_THROTTLE) {
playerStore.setCurrentTime(time);
this.lastTimeUpdate = now;
}
});
// Note: Ready event is handled in the load promise, so we don't set it up here
// to avoid duplicate event handlers
}
public async play(): Promise<void> {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
}
// Debounce rapid play calls
const now = Date.now();
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
this.log(LogLevel.DEBUG, 'Playback debounced - too frequent calls');
return;
}
this.lastPlayTime = now;
this.log(LogLevel.INFO, 'AudioService.play called');
try {
// Ensure we have a valid audio context
await this.ensureAudioContext();
await this.wavesurfer.play();
this.log(LogLevel.INFO, 'Playback started successfully');
this.playbackAttempts = 0; // Reset on success
} catch (error) {
this.playbackAttempts++;
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
this.cleanup();
// Could trigger re-initialization here if needed
} else {
// Exponential backoff for retry
const delay = 100 * this.playbackAttempts;
this.log(LogLevel.WARN, `Retrying playback in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.play(); // Retry
}
}
}
public pause() {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
}
this.log(LogLevel.INFO, 'AudioService.pause called');
this.wavesurfer.pause();
}
public seekTo(time: number) {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
}
this.log(LogLevel.INFO, 'AudioService.seekTo called', { time });
this.wavesurfer.setTime(time);
}
public getCurrentTime(): number {
if (!this.wavesurfer) return 0;
return this.wavesurfer.getCurrentTime();
}
public getDuration(): number {
if (!this.wavesurfer) return 0;
return this.wavesurfer.getDuration();
}
public isPlaying(): boolean {
if (!this.wavesurfer) return false;
return this.wavesurfer.isPlaying();
}
public cleanup() {
this.log(LogLevel.INFO, 'AudioService.cleanup called');
if (this.wavesurfer) {
try {
// Disconnect audio nodes but keep audio context alive
this.wavesurfer.unAll();
this.wavesurfer.destroy();
this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up');
} catch (error) {
this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error);
}
this.wavesurfer = null;
}
this.currentUrl = null;
// Note: We intentionally don't nullify audioContext to keep it alive
}
private async ensureAudioContext(): Promise<AudioContext> {
// If we already have a valid audio context, return it
if (this.audioContext) {
// Resume if suspended (common in mobile browsers)
if (this.audioContext.state === 'suspended') {
try {
await this.audioContext.resume();
console.log('Audio context resumed successfully');
} catch (error) {
console.error('Failed to resume audio context:', error);
}
}
return this.audioContext;
}
// Create new audio context
try {
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
console.log('Audio context created:', this.audioContext.state);
// Handle context state changes
this.audioContext.onstatechange = () => {
console.log('Audio context state changed:', this.audioContext?.state);
};
return this.audioContext;
} catch (error) {
console.error('Failed to create audio context:', error);
throw error;
}
}
private setupAudioContext(ws: WaveSurferWithBackend) {
// Try multiple methods to get audio context from WaveSurfer v7+
try {
// Method 1: Try standard backend.getAudioContext()
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
// Method 2: Try accessing audio context directly from backend
if (!this.audioContext) {
this.audioContext = ws.backend?.ac ?? null;
}
// Method 3: Try accessing through backend.getAudioContext() without optional chaining
if (!this.audioContext) {
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
}
// Method 4: Try accessing through wavesurfer.getAudioContext() if it exists
if (!this.audioContext && typeof ws.getAudioContext === 'function') {
this.audioContext = ws.getAudioContext() ?? null;
}
// Method 5: Try accessing through backend.ac directly
if (!this.audioContext) {
this.audioContext = ws.backend?.ac ?? null;
}
// Method 6: Try accessing through backend.audioContext
if (!this.audioContext) {
this.audioContext = ws.backend?.audioContext ?? null;
}
if (this.audioContext) {
console.log('Audio context accessed successfully:', this.audioContext.state);
} else {
console.warn('Could not access audio context from WaveSurfer - playback may have issues');
// Log the wavesurfer structure for debugging
console.debug('WaveSurfer structure:', {
hasBackend: !!ws.backend,
backendType: typeof ws.backend,
backendKeys: ws.backend ? Object.keys(ws.backend) : 'no backend',
wavesurferKeys: Object.keys(ws)
});
}
} catch (error) {
console.error('Error accessing audio context:', error);
}
}
public getAudioContextState(): string | undefined {
return this.audioContext?.state;
}
}
export const audioService = AudioService.getInstance();

View File

@@ -0,0 +1,35 @@
import { create } from "zustand";
interface PlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
currentSongId: string | null;
currentBandId: string | null;
setPlaying: (isPlaying: boolean) => void;
setCurrentTime: (currentTime: number) => void;
setDuration: (duration: number) => void;
setCurrentSong: (songId: string | null, bandId: string | null) => void;
reset: () => void;
batchUpdate: (updates: Partial<Omit<PlayerState, 'setPlaying' | 'setCurrentTime' | 'setDuration' | 'setCurrentSong' | 'reset' | 'batchUpdate'>>) => void;
}
export const usePlayerStore = create<PlayerState>()((set) => ({
isPlaying: false,
currentTime: 0,
duration: 0,
currentSongId: null,
currentBandId: null,
setPlaying: (isPlaying) => set({ isPlaying }),
setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }),
setCurrentSong: (songId, bandId) => set({ currentSongId: songId, currentBandId: bandId }),
batchUpdate: (updates) => set(updates),
reset: () => set({
isPlaying: false,
currentTime: 0,
duration: 0,
currentSongId: null,
currentBandId: null
})
}));