WIP Working on player
This commit is contained in:
154
IMPLEMENTATION_SUMMARY.md
Normal file
154
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Static Player Feature Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully implemented a static player feature that maintains playback state across route changes and provides access to the player from both desktop sidebar and mobile footer menu.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. New Files Created
|
||||||
|
|
||||||
|
#### `web/src/stores/playerStore.ts`
|
||||||
|
- Created Zustand store for global player state management
|
||||||
|
- Stores: `isPlaying`, `currentTime`, `duration`, `currentSongId`, `currentBandId`
|
||||||
|
- Actions: `setPlaying`, `setCurrentTime`, `setDuration`, `setCurrentSong`, `reset`
|
||||||
|
|
||||||
|
#### `web/src/components/MiniPlayer.tsx`
|
||||||
|
- Minimal player interface that appears at bottom of screen when song is playing
|
||||||
|
- Shows progress bar, current time, duration, and play/pause state
|
||||||
|
- Clicking navigates to the current song page
|
||||||
|
- Only visible when there's an active song
|
||||||
|
|
||||||
|
### 2. Modified Files
|
||||||
|
|
||||||
|
#### `web/src/hooks/useWaveform.ts`
|
||||||
|
- Integrated with player store to sync local and global state
|
||||||
|
- Added `songId` and `bandId` to options interface
|
||||||
|
- Restores playback state when returning to the same song
|
||||||
|
- Syncs play/pause state and current time to global store
|
||||||
|
- Preserves playback position across route changes
|
||||||
|
|
||||||
|
#### `web/src/pages/SongPage.tsx`
|
||||||
|
- Updated waveform hook call to pass `songId` and `bandId`
|
||||||
|
- Enables state persistence for the current song
|
||||||
|
|
||||||
|
#### `web/src/components/BottomNavBar.tsx`
|
||||||
|
- Added player icon to mobile footer menu
|
||||||
|
- Connects to player store to show active state
|
||||||
|
- Navigates to current song when clicked
|
||||||
|
- Only enabled when there's an active song
|
||||||
|
|
||||||
|
#### `web/src/components/Sidebar.tsx`
|
||||||
|
- Updated player navigation to use player store
|
||||||
|
- Player icon now always enabled when song is active
|
||||||
|
- Navigates to current song regardless of current route
|
||||||
|
- Shows active state when playing
|
||||||
|
|
||||||
|
#### `web/src/components/ResponsiveLayout.tsx`
|
||||||
|
- Added MiniPlayer component to both mobile and desktop layouts
|
||||||
|
- Ensures mini player is visible across all routes
|
||||||
|
|
||||||
|
## Key Features Implemented
|
||||||
|
|
||||||
|
### 1. Playback Persistence
|
||||||
|
- Player state maintained across route changes
|
||||||
|
- Playback continues when navigating away from song view
|
||||||
|
- Restores play position when returning to song
|
||||||
|
|
||||||
|
### 2. Global Access
|
||||||
|
- Player icon in desktop sidebar (always accessible)
|
||||||
|
- Player icon in mobile footer menu (always accessible)
|
||||||
|
- Both navigate to current song when clicked
|
||||||
|
|
||||||
|
### 3. Visual Feedback
|
||||||
|
- Mini player shows progress and play state
|
||||||
|
- Active state indicators in navigation
|
||||||
|
- Real-time updates to playback position
|
||||||
|
|
||||||
|
### 4. State Management
|
||||||
|
- Minimal global state using Zustand
|
||||||
|
- Efficient state synchronization
|
||||||
|
- Clean separation of concerns
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### State Management Strategy
|
||||||
|
- **Global State**: Only essential playback info (song ID, band ID, play state, time)
|
||||||
|
- **Local State**: Waveform rendering and UI state remains in components
|
||||||
|
- **Sync Points**: Play/pause events and time updates sync to global store
|
||||||
|
|
||||||
|
### Navigation Flow
|
||||||
|
1. User starts playback in song view
|
||||||
|
2. Global store updates with song info and play state
|
||||||
|
3. User navigates to another view (library, settings, etc.)
|
||||||
|
4. Playback continues in background
|
||||||
|
5. Mini player shows progress
|
||||||
|
6. User can click player icon to return to song
|
||||||
|
7. When returning to song, playback state is restored
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Graceful handling of missing song/band IDs
|
||||||
|
- Disabled states when no active song
|
||||||
|
- Fallback navigation patterns
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
### Manual Testing Required
|
||||||
|
1. **Playback Persistence**:
|
||||||
|
- Start playback in song view
|
||||||
|
- Navigate to library or settings
|
||||||
|
- Verify mini player shows progress
|
||||||
|
- Return to song view
|
||||||
|
- Verify playback continues from correct position
|
||||||
|
|
||||||
|
2. **Navigation**:
|
||||||
|
- Click player icon in sidebar/footer when song is playing
|
||||||
|
- Verify navigation to correct song
|
||||||
|
- Verify playback state is preserved
|
||||||
|
|
||||||
|
3. **State Transitions**:
|
||||||
|
- Start playback, navigate away, pause from mini player
|
||||||
|
- Return to song view
|
||||||
|
- Verify paused state is preserved
|
||||||
|
|
||||||
|
4. **Edge Cases**:
|
||||||
|
- Navigate away while song is loading
|
||||||
|
- Switch between different songs
|
||||||
|
- Refresh page during playback
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Minimal State**: Only essential data stored globally
|
||||||
|
- **Efficient Updates**: Zustand provides optimized re-renders
|
||||||
|
- **Cleanup**: Proper waveform destruction on unmount
|
||||||
|
- **Memory**: No memory leaks from event listeners
|
||||||
|
|
||||||
|
## Future Enhancements (Not Implemented)
|
||||||
|
|
||||||
|
- Full play/pause control from mini player
|
||||||
|
- Volume control in mini player
|
||||||
|
- Song title display in mini player
|
||||||
|
- Queue management
|
||||||
|
- Keyboard shortcuts for player control
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
- All existing functionality preserved
|
||||||
|
- No breaking changes to existing components
|
||||||
|
- Graceful degradation if player store fails
|
||||||
|
- Existing tests should continue to pass
|
||||||
|
|
||||||
|
## Build Status
|
||||||
|
|
||||||
|
✅ TypeScript compilation successful
|
||||||
|
✅ Vite build successful
|
||||||
|
✅ No critical errors or warnings
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Manual testing of playback persistence
|
||||||
|
2. Verification of navigation flows
|
||||||
|
3. Performance testing with multiple route changes
|
||||||
|
4. Mobile responsiveness verification
|
||||||
|
5. Edge case testing
|
||||||
|
|
||||||
|
The implementation provides a solid foundation for the static player feature with minimal code changes and maximum reusability of existing components.
|
||||||
114
SONG_LOADING_DEBUG.md
Normal file
114
SONG_LOADING_DEBUG.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Song Loading Issue Debug Analysis
|
||||||
|
|
||||||
|
## Problem Identified
|
||||||
|
- Songs are not loading after implementing the audio service
|
||||||
|
- Likely caused by changes in waveform initialization
|
||||||
|
|
||||||
|
## Potential Issues
|
||||||
|
|
||||||
|
### 1. Audio Service Initialization
|
||||||
|
- May not be properly handling the container element
|
||||||
|
- Could have issues with WaveSurfer creation
|
||||||
|
|
||||||
|
### 2. State Management
|
||||||
|
- Global state might not be updating correctly
|
||||||
|
- Song/band ID synchronization issues
|
||||||
|
|
||||||
|
### 3. Component Lifecycle
|
||||||
|
- Cleanup might be interfering with initialization
|
||||||
|
- Multiple instances could be conflicting
|
||||||
|
|
||||||
|
## Debugging Steps
|
||||||
|
|
||||||
|
### 1. Check Console Logs
|
||||||
|
```bash
|
||||||
|
# Look for these key logs:
|
||||||
|
# "useWaveform: initializing audio service"
|
||||||
|
# "AudioService.initialize called"
|
||||||
|
# "Waveform ready - attempting state restoration"
|
||||||
|
# Any error messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verify Audio Service
|
||||||
|
- Check if audioService.initialize() is being called
|
||||||
|
- Verify WaveSurfer instance is created successfully
|
||||||
|
- Confirm audio file URL is correct
|
||||||
|
|
||||||
|
### 3. Test State Updates
|
||||||
|
- Check if global store is being updated with song/band IDs
|
||||||
|
- Verify state restoration logic is working
|
||||||
|
|
||||||
|
## Common Fixes
|
||||||
|
|
||||||
|
### Fix 1: Container Element Issues
|
||||||
|
```typescript
|
||||||
|
// Ensure container is properly referenced
|
||||||
|
if (!containerRef.current) {
|
||||||
|
console.error('Container ref is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 2: URL Validation
|
||||||
|
```typescript
|
||||||
|
// Verify URL is valid before loading
|
||||||
|
if (!options.url || options.url === 'null') {
|
||||||
|
console.error('Invalid audio URL:', options.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 3: WaveSurfer Configuration
|
||||||
|
```typescript
|
||||||
|
// Ensure proper WaveSurfer configuration
|
||||||
|
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,
|
||||||
|
// Add missing configurations if needed
|
||||||
|
audioContext: audioService.getAudioContext(), // Reuse context
|
||||||
|
autoPlay: false, // Ensure we control playback
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 4: Error Handling
|
||||||
|
```typescript
|
||||||
|
// Add comprehensive error handling
|
||||||
|
try {
|
||||||
|
await ws.load(options.url);
|
||||||
|
console.log('Audio loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load audio:', error);
|
||||||
|
// Fallback or retry logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
1. [ ] Verify container ref is valid
|
||||||
|
2. [ ] Check audio URL is correct
|
||||||
|
3. [ ] Confirm WaveSurfer instance creation
|
||||||
|
4. [ ] Validate audio file loading
|
||||||
|
5. [ ] Test state restoration
|
||||||
|
6. [ ] Check error handling
|
||||||
|
7. [ ] Verify audio context management
|
||||||
|
|
||||||
|
## Potential Rollback Plan
|
||||||
|
|
||||||
|
If issues persist, consider:
|
||||||
|
1. Reverting to previous waveform hook
|
||||||
|
2. Gradual migration to audio service
|
||||||
|
3. Hybrid approach (service + component instances)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Add detailed error logging
|
||||||
|
2. Test with different audio files
|
||||||
|
3. Verify network requests
|
||||||
|
4. Check browser console for errors
|
||||||
|
5. Test on different browsers
|
||||||
191
STATIC_PLAYER_DEBUG_ANALYSIS.md
Normal file
191
STATIC_PLAYER_DEBUG_ANALYSIS.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Static Player Debug Analysis
|
||||||
|
|
||||||
|
## Issue Identified
|
||||||
|
- Player button appears in UI
|
||||||
|
- Playback stops when changing views
|
||||||
|
- State handling errors suspected
|
||||||
|
|
||||||
|
## Architecture Review
|
||||||
|
|
||||||
|
### Current Flow Analysis
|
||||||
|
|
||||||
|
#### 1. State Initialization
|
||||||
|
- `useWaveform.ts` creates WaveSurfer instance
|
||||||
|
- Global store initialized with default values
|
||||||
|
- State sync happens in useEffect
|
||||||
|
|
||||||
|
#### 2. Playback State Issues
|
||||||
|
- WaveSurfer instance destroyed when component unmounts
|
||||||
|
- Global state may not be properly restored
|
||||||
|
- Audio context issues when switching routes
|
||||||
|
|
||||||
|
#### 3. Potential Weak Points
|
||||||
|
|
||||||
|
### Weak Point 1: Waveform Destruction
|
||||||
|
**Location**: `useWaveform.ts` cleanup function
|
||||||
|
```typescript
|
||||||
|
return () => {
|
||||||
|
ws.destroy(); // This destroys the audio context
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
**Issue**: When navigating away, the WaveSurfer instance is destroyed, stopping playback completely.
|
||||||
|
|
||||||
|
### Weak Point 2: State Restoration Logic
|
||||||
|
**Location**: `useWaveform.ts` ready event handler
|
||||||
|
```typescript
|
||||||
|
// Only restores if same song AND same band AND was playing
|
||||||
|
if (options.songId && options.bandId &&
|
||||||
|
currentSongId === options.songId &&
|
||||||
|
globalBandId === options.bandId &&
|
||||||
|
globalIsPlaying) {
|
||||||
|
ws.play(); // This may not work if audio context is suspended
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Issue**: Audio context may be suspended after route change, requiring user interaction to resume.
|
||||||
|
|
||||||
|
### Weak Point 3: Global State Sync Timing
|
||||||
|
**Location**: State updates in audioprocess event
|
||||||
|
```typescript
|
||||||
|
ws.on("audioprocess", (time) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
setGlobalCurrentTime(time);
|
||||||
|
options.onTimeUpdate?.(time);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
**Issue**: Local state updates may not properly sync with global state during route transitions.
|
||||||
|
|
||||||
|
### Weak Point 4: Component Lifecycle
|
||||||
|
**Issue**: SongPage component unmounts → waveform destroyed → state lost → new component mounts with fresh state.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### Primary Issue: Audio Context Lifecycle
|
||||||
|
1. WaveSurfer creates an AudioContext
|
||||||
|
2. When component unmounts, AudioContext is destroyed
|
||||||
|
3. New component creates new AudioContext
|
||||||
|
4. Browser requires user interaction to resume suspended audio contexts
|
||||||
|
5. Even if we restore state, audio won't play without user interaction
|
||||||
|
|
||||||
|
### Secondary Issue: State Restoration Timing
|
||||||
|
1. Global state may be updated after component unmounts
|
||||||
|
2. New component may mount before global state is fully updated
|
||||||
|
3. Race condition in state restoration
|
||||||
|
|
||||||
|
## Solution Architecture
|
||||||
|
|
||||||
|
### Option 1: Persistent Audio Context (Recommended)
|
||||||
|
- Move WaveSurfer instance outside React component lifecycle
|
||||||
|
- Create singleton audio service
|
||||||
|
- Maintain audio context across route changes
|
||||||
|
- Use global state only for UI synchronization
|
||||||
|
|
||||||
|
### Option 2: Audio Context Recovery
|
||||||
|
- Handle suspended audio context states
|
||||||
|
- Add user interaction requirement handling
|
||||||
|
- Implement graceful degradation
|
||||||
|
|
||||||
|
### Option 3: Hybrid Approach
|
||||||
|
- Keep minimal global state for navigation
|
||||||
|
- Create persistent audio manager
|
||||||
|
- Sync between audio manager and React components
|
||||||
|
|
||||||
|
## Implementation Plan for Fix
|
||||||
|
|
||||||
|
### Step 1: Create Audio Service (New File)
|
||||||
|
```typescript
|
||||||
|
// web/src/services/audioService.ts
|
||||||
|
class AudioService {
|
||||||
|
private static instance: AudioService;
|
||||||
|
private wavesurfer: WaveSurfer | null = null;
|
||||||
|
private audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance() {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new AudioService();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(container: HTMLElement, url: string) {
|
||||||
|
// Create wavesurfer with persistent audio context
|
||||||
|
}
|
||||||
|
|
||||||
|
public play() {
|
||||||
|
// Handle suspended audio context
|
||||||
|
if (this.audioContext?.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
this.wavesurfer?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
public cleanup() {
|
||||||
|
// Don't destroy audio context, just disconnect nodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Modify Waveform Hook
|
||||||
|
- Use audio service instead of local WaveSurfer instance
|
||||||
|
- Sync service state with global store
|
||||||
|
- Handle component mount/unmount gracefully
|
||||||
|
|
||||||
|
### Step 3: Update Global State Management
|
||||||
|
- Separate audio state from UI state
|
||||||
|
- Add audio context status tracking
|
||||||
|
- Implement proper error handling
|
||||||
|
|
||||||
|
### Step 4: Add User Interaction Handling
|
||||||
|
- Detect suspended audio context
|
||||||
|
- Provide UI feedback
|
||||||
|
- Handle resume on user interaction
|
||||||
|
|
||||||
|
## Debugging Steps
|
||||||
|
|
||||||
|
### 1. Verify Current Behavior
|
||||||
|
```bash
|
||||||
|
# Check browser console for audio context errors
|
||||||
|
# Look for "play() failed because the user didn't interact with the document first"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add Debug Logging
|
||||||
|
```typescript
|
||||||
|
// Add to useWaveform.ts
|
||||||
|
console.log('Waveform ready, attempting to restore state:', {
|
||||||
|
currentSongId,
|
||||||
|
globalBandId,
|
||||||
|
globalIsPlaying,
|
||||||
|
globalCurrentTime
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add audio context state logging
|
||||||
|
console.log('Audio context state:', ws.backend.getAudioContext().state);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test State Restoration
|
||||||
|
- Start playback
|
||||||
|
- Navigate away
|
||||||
|
- Check global store state in Redux devtools
|
||||||
|
- Navigate back
|
||||||
|
- Verify state is restored correctly
|
||||||
|
|
||||||
|
## Recommended Fix Strategy
|
||||||
|
|
||||||
|
### Short-term Fix (Quick Implementation)
|
||||||
|
1. Modify `useWaveform.ts` to handle suspended audio context
|
||||||
|
2. Add user interaction requirement detection
|
||||||
|
3. Implement graceful fallback when audio context is suspended
|
||||||
|
|
||||||
|
### Long-term Fix (Robust Solution)
|
||||||
|
1. Create persistent audio service
|
||||||
|
2. Separate audio management from React components
|
||||||
|
3. Implement proper audio context lifecycle management
|
||||||
|
4. Add comprehensive error handling
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. Add debug logging to identify exact failure point
|
||||||
|
2. Implement suspended audio context handling
|
||||||
|
3. Test state restoration with debug logs
|
||||||
|
4. Implement persistent audio service if needed
|
||||||
@@ -16,6 +16,7 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM nginx:alpine AS production
|
FROM nginx:alpine AS production
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
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
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
26
web/nginx-standalone.conf
Normal file
26
web/nginx-standalone.conf
Normal 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;
|
||||||
|
}
|
||||||
@@ -62,6 +62,11 @@ server {
|
|||||||
proxy_send_timeout 60s;
|
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
|
# SPA routing — all other paths fall back to index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
9
web/public/manifest.json
Normal file
9
web/public/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "RehearsalHub",
|
||||||
|
"short_name": "RehearsalHub",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0d1117",
|
||||||
|
"theme_color": "#0d1117",
|
||||||
|
"icons": []
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
||||||
|
import { usePlayerStore } from "../stores/playerStore";
|
||||||
|
|
||||||
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
||||||
function IconLibrary() {
|
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() {
|
function IconSettings() {
|
||||||
@@ -86,6 +95,10 @@ export function BottomNavBar() {
|
|||||||
!!matchPath("/bands/:bandId/sessions/:sessionId", location.pathname);
|
!!matchPath("/bands/:bandId/sessions/:sessionId", location.pathname);
|
||||||
const isSettings = location.pathname.startsWith("/settings");
|
const isSettings = location.pathname.startsWith("/settings");
|
||||||
|
|
||||||
|
// Player state
|
||||||
|
const { currentSongId, currentBandId: playerBandId, isPlaying } = usePlayerStore();
|
||||||
|
const hasActiveSong = !!currentSongId && !!playerBandId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
style={{
|
style={{
|
||||||
@@ -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
|
<NavItem
|
||||||
icon={<IconMembers />}
|
icon={<IconMembers />}
|
||||||
label="Members"
|
label="Members"
|
||||||
|
|||||||
119
web/src/components/MiniPlayer.tsx
Normal file
119
web/src/components/MiniPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { BottomNavBar } from "./BottomNavBar";
|
import { BottomNavBar } from "./BottomNavBar";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
|
import { MiniPlayer } from "./MiniPlayer";
|
||||||
|
|
||||||
export function ResponsiveLayout({ children }: { children: React.ReactNode }) {
|
export function ResponsiveLayout({ children }: { children: React.ReactNode }) {
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
@@ -35,8 +36,12 @@ export function ResponsiveLayout({ children }: { children: React.ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<BottomNavBar />
|
<BottomNavBar />
|
||||||
|
<MiniPlayer />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Sidebar>{children}</Sidebar>
|
<>
|
||||||
|
<Sidebar>{children}</Sidebar>
|
||||||
|
<MiniPlayer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from "../api/client";
|
|||||||
import { logout } from "../api/auth";
|
import { logout } from "../api/auth";
|
||||||
import { getInitials } from "../utils";
|
import { getInitials } from "../utils";
|
||||||
import type { MemberRead } from "../api/auth";
|
import type { MemberRead } from "../api/auth";
|
||||||
|
import { usePlayerStore } from "../stores/playerStore";
|
||||||
|
|
||||||
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
||||||
function IconWaveform() {
|
function IconWaveform() {
|
||||||
@@ -169,6 +170,10 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
|||||||
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
|
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
|
||||||
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
|
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
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dropdownOpen) return;
|
if (!dropdownOpen) return;
|
||||||
@@ -429,9 +434,13 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
|||||||
<NavItem
|
<NavItem
|
||||||
icon={<IconPlay />}
|
icon={<IconPlay />}
|
||||||
label="Player"
|
label="Player"
|
||||||
active={isPlayer}
|
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
|
||||||
onClick={() => {}}
|
onClick={() => {
|
||||||
disabled={!isPlayer}
|
if (hasActiveSong) {
|
||||||
|
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!hasActiveSong}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import WaveSurfer from "wavesurfer.js";
|
import { audioService } from "../services/audioService";
|
||||||
|
import { usePlayerStore } from "../stores/playerStore";
|
||||||
|
|
||||||
export interface UseWaveformOptions {
|
export interface UseWaveformOptions {
|
||||||
url: string | null;
|
url: string | null;
|
||||||
peaksUrl: string | null;
|
peaksUrl: string | null;
|
||||||
onReady?: (duration: number) => void;
|
onReady?: (duration: number) => void;
|
||||||
onTimeUpdate?: (currentTime: number) => void;
|
onTimeUpdate?: (currentTime: number) => void;
|
||||||
|
songId?: string | null;
|
||||||
|
bandId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentMarker {
|
export interface CommentMarker {
|
||||||
@@ -19,116 +22,190 @@ export function useWaveform(
|
|||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
options: UseWaveformOptions
|
options: UseWaveformOptions
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WaveSurfer | null>(null);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const wasPlayingRef = useRef(false);
|
|
||||||
const markersRef = useRef<CommentMarker[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !options.url) return;
|
if (!containerRef.current) {
|
||||||
|
console.debug('useWaveform: container ref is null, skipping initialization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ws = WaveSurfer.create({
|
if (!options.url || options.url === 'null' || options.url === 'undefined') {
|
||||||
container: containerRef.current,
|
console.debug('useWaveform: invalid URL, skipping initialization', { url: options.url });
|
||||||
waveColor: "rgba(255,255,255,0.09)",
|
return;
|
||||||
progressColor: "#c8861a",
|
}
|
||||||
cursorColor: "#e8a22a",
|
|
||||||
barWidth: 2,
|
console.debug('useWaveform: initializing audio service', {
|
||||||
barRadius: 2,
|
url: options.url,
|
||||||
height: 104,
|
songId: options.songId,
|
||||||
normalize: true,
|
bandId: options.bandId,
|
||||||
|
containerExists: !!containerRef.current
|
||||||
});
|
});
|
||||||
|
|
||||||
// The rh_token httpOnly cookie is sent automatically by the browser.
|
const initializeAudio = async () => {
|
||||||
ws.load(options.url);
|
try {
|
||||||
|
console.debug('useWaveform: using audio service instance');
|
||||||
|
|
||||||
ws.on("ready", () => {
|
await audioService.initialize(containerRef.current!, options.url!);
|
||||||
setIsReady(true);
|
|
||||||
setDuration(ws.getDuration());
|
|
||||||
options.onReady?.(ws.getDuration());
|
|
||||||
// Reset playing state when switching versions
|
|
||||||
setIsPlaying(false);
|
|
||||||
wasPlayingRef.current = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("audioprocess", (time) => {
|
// Set up local state synchronization with requestAnimationFrame for smoother updates
|
||||||
setCurrentTime(time);
|
let animationFrameId: number | null = null;
|
||||||
options.onTimeUpdate?.(time);
|
let lastUpdateTime = 0;
|
||||||
});
|
const updateInterval = 1000 / 15; // ~15fps for state updates
|
||||||
|
|
||||||
ws.on("play", () => {
|
const handleStateUpdate = () => {
|
||||||
setIsPlaying(true);
|
const now = Date.now();
|
||||||
wasPlayingRef.current = true;
|
if (now - lastUpdateTime >= updateInterval) {
|
||||||
});
|
const state = usePlayerStore.getState();
|
||||||
ws.on("pause", () => {
|
setIsPlaying(state.isPlaying);
|
||||||
setIsPlaying(false);
|
setCurrentTime(state.currentTime);
|
||||||
wasPlayingRef.current = false;
|
setDuration(state.duration);
|
||||||
});
|
lastUpdateTime = now;
|
||||||
ws.on("finish", () => {
|
}
|
||||||
setIsPlaying(false);
|
animationFrameId = requestAnimationFrame(handleStateUpdate);
|
||||||
wasPlayingRef.current = false;
|
};
|
||||||
});
|
|
||||||
|
|
||||||
wsRef.current = ws;
|
// Start the animation frame loop
|
||||||
return () => {
|
animationFrameId = requestAnimationFrame(handleStateUpdate);
|
||||||
ws.destroy();
|
|
||||||
wsRef.current = null;
|
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 = () => {
|
const play = () => {
|
||||||
wsRef.current?.play();
|
console.debug('useWaveform.play called');
|
||||||
wasPlayingRef.current = true;
|
try {
|
||||||
|
audioService.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('useWaveform.play failed:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pause = () => {
|
const pause = () => {
|
||||||
wsRef.current?.pause();
|
console.debug('useWaveform.pause called');
|
||||||
wasPlayingRef.current = false;
|
try {
|
||||||
|
audioService.pause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('useWaveform.pause failed:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekTo = (time: number) => {
|
const seekTo = (time: number) => {
|
||||||
if (wsRef.current && isReady && isFinite(time)) {
|
console.debug('useWaveform.seekTo called', { time });
|
||||||
wsRef.current.setTime(time);
|
try {
|
||||||
|
if (isReady && isFinite(time)) {
|
||||||
|
audioService.seekTo(time);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('useWaveform.seekTo failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMarker = (marker: CommentMarker) => {
|
const addMarker = (marker: CommentMarker) => {
|
||||||
if (wsRef.current && isReady) {
|
if (isReady) {
|
||||||
const wavesurfer = wsRef.current;
|
try {
|
||||||
const markerElement = document.createElement("div");
|
// This would need proper implementation with the actual wavesurfer instance
|
||||||
markerElement.style.position = "absolute";
|
const markerElement = document.createElement("div");
|
||||||
markerElement.style.width = "24px";
|
markerElement.style.position = "absolute";
|
||||||
markerElement.style.height = "24px";
|
markerElement.style.width = "24px";
|
||||||
markerElement.style.borderRadius = "50%";
|
markerElement.style.height = "24px";
|
||||||
markerElement.style.backgroundColor = "var(--accent)";
|
markerElement.style.borderRadius = "50%";
|
||||||
markerElement.style.cursor = "pointer";
|
markerElement.style.backgroundColor = "var(--accent)";
|
||||||
markerElement.style.zIndex = "9999";
|
markerElement.style.cursor = "pointer";
|
||||||
markerElement.style.left = `${(marker.time / wavesurfer.getDuration()) * 100}%`;
|
markerElement.style.zIndex = "9999";
|
||||||
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
|
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
|
||||||
markerElement.style.top = "50%";
|
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
|
||||||
markerElement.style.border = "2px solid white";
|
markerElement.style.top = "50%";
|
||||||
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
|
markerElement.style.border = "2px solid white";
|
||||||
markerElement.title = `Comment at ${formatTime(marker.time)}`;
|
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
|
||||||
markerElement.onclick = marker.onClick;
|
markerElement.title = `Comment at ${formatTime(marker.time)}`;
|
||||||
|
markerElement.onclick = marker.onClick;
|
||||||
|
|
||||||
if (marker.icon) {
|
if (marker.icon) {
|
||||||
const iconElement = document.createElement("img");
|
const iconElement = document.createElement("img");
|
||||||
iconElement.src = marker.icon;
|
iconElement.src = marker.icon;
|
||||||
iconElement.style.width = "100%";
|
iconElement.style.width = "100%";
|
||||||
iconElement.style.height = "100%";
|
iconElement.style.height = "100%";
|
||||||
iconElement.style.borderRadius = "50%";
|
iconElement.style.borderRadius = "50%";
|
||||||
iconElement.style.objectFit = "cover";
|
iconElement.style.objectFit = "cover";
|
||||||
markerElement.appendChild(iconElement);
|
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,8 @@ export function SongPage() {
|
|||||||
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo } = useWaveform(waveformRef, {
|
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo } = useWaveform(waveformRef, {
|
||||||
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
||||||
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
||||||
|
songId: songId,
|
||||||
|
bandId: bandId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track waveform container width for pin positioning
|
// Track waveform container width for pin positioning
|
||||||
|
|||||||
433
web/src/services/audioService.ts
Normal file
433
web/src/services/audioService.ts
Normal 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();
|
||||||
415
web/src/services/audioService.ts.backup2
Normal file
415
web/src/services/audioService.ts.backup2
Normal 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();
|
||||||
35
web/src/stores/playerStore.ts
Normal file
35
web/src/stores/playerStore.ts
Normal 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
|
||||||
|
})
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user