v1.0.2: Mood mix optimizations and media player improvements
- Fixed player seek flicker on podcasts (30s skip buttons) - Added dual-layer seek lock mechanism to prevent stale time updates - Optimized cached podcast seeking (direct seek before reload fallback) - Large skips now execute immediately for responsive feel - Mood mix performance optimizations
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
# Mood Mixer Complete Overhaul Plan
|
||||
|
||||
**Date:** 2025-12-26
|
||||
**Status:** Planning
|
||||
**Priority:** High
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The current Mood Mixer implementation has fundamental architectural issues causing slow generation, stale UI, and poor user experience. This document outlines a complete overhaul to create a simpler, faster, and more reliable mood-based playlist system.
|
||||
|
||||
---
|
||||
|
||||
## Current Problems
|
||||
|
||||
### 1. Slow Generation
|
||||
|
||||
**Root Cause:** The [`generateMoodOnDemand()`](backend/src/services/programmaticPlaylists.ts:3104) function:
|
||||
|
||||
- Queries the entire track table with complex WHERE clauses
|
||||
- Has a two-pass approach: first checks enhanced track count, then queries again
|
||||
- Falls back to basic audio features with complex mapping logic
|
||||
- No database indexes on mood-related columns
|
||||
|
||||
### 2. UI Not Updating
|
||||
|
||||
**Root Cause:** Multiple issues in the React Query cache system:
|
||||
|
||||
- [`useHomeData`](frontend/features/home/hooks/useHomeData.ts:84-91) listens for `mixes-updated` event
|
||||
- `queryClient.invalidateQueries()` only marks data as stale, doesn't force immediate refetch
|
||||
- The 5-minute stale time in [`useMixesQuery`](frontend/hooks/useQueries.ts:461-466) means UI may not update immediately
|
||||
|
||||
### 3. Stale Card Persists
|
||||
|
||||
**Root Cause:** The mood mix always has ID `"your-mood-mix"` regardless of content:
|
||||
|
||||
- [`generateAllMixes()`](backend/src/services/programmaticPlaylists.ts:497-505) creates mix with static ID
|
||||
- [`MixCard`](frontend/components/MixCard.tsx:81-83) memo comparison only checks `mix.id`
|
||||
- When user changes mood, the mix content changes but ID stays same, so React doesn't re-render
|
||||
|
||||
---
|
||||
|
||||
## New Architecture Design
|
||||
|
||||
### Core Principle: Pre-computed Mood Buckets
|
||||
|
||||
Instead of querying tracks with complex filters at runtime, we pre-compute mood buckets during audio analysis and store track-to-mood mappings.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Audio Analysis Worker] -->|Analyzes Track| B[Calculate Mood Scores]
|
||||
B --> C[Assign Track to Mood Buckets]
|
||||
C --> D[Store in MoodBucket Table]
|
||||
|
||||
E[User Requests Mood Mix] --> F[Lookup MoodBucket]
|
||||
F --> G[Random Sample from Bucket]
|
||||
G --> H[Return Tracks Immediately]
|
||||
```
|
||||
|
||||
### New Database Schema
|
||||
|
||||
```prisma
|
||||
// New model to store pre-computed mood assignments
|
||||
model MoodBucket {
|
||||
id String @id @default(uuid())
|
||||
trackId String
|
||||
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
||||
mood String // happy, sad, chill, energetic, party, focus, melancholy, aggressive, acoustic
|
||||
score Float // Confidence score 0-1
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([trackId, mood])
|
||||
@@index([mood, score])
|
||||
@@index([trackId])
|
||||
}
|
||||
|
||||
// User's active mood mix selection
|
||||
model UserMoodMix {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
mood String // The selected mood
|
||||
trackIds String[] // Cached track IDs for the mix
|
||||
coverUrls String[] // Cached cover URLs
|
||||
generatedAt DateTime // When this mix was generated
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
### Mood Categories
|
||||
|
||||
Simplified to 9 core moods that map from audio features:
|
||||
|
||||
| Mood | Primary Features | Fallback Criteria |
|
||||
| -------------- | ------------------------------ | --------------------------------------- |
|
||||
| **Happy** | moodHappy >= 0.5 | valence >= 0.6, energy >= 0.5 |
|
||||
| **Sad** | moodSad >= 0.5 | valence <= 0.35, keyScale = minor |
|
||||
| **Chill** | moodRelaxed >= 0.5 | energy <= 0.5, arousal <= 0.5 |
|
||||
| **Energetic** | arousal >= 0.6, energy >= 0.7 | bpm >= 120, energy >= 0.7 |
|
||||
| **Party** | moodParty >= 0.5 | danceability >= 0.7, energy >= 0.6 |
|
||||
| **Focus** | instrumentalness >= 0.5 | instrumentalness >= 0.5, energy 0.2-0.6 |
|
||||
| **Melancholy** | moodSad >= 0.4, valence <= 0.4 | valence <= 0.35, keyScale = minor |
|
||||
| **Aggressive** | moodAggressive >= 0.5 | energy >= 0.8, arousal >= 0.7 |
|
||||
| **Acoustic** | moodAcoustic >= 0.5 | acousticness >= 0.6, energy 0.3-0.6 |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Database & Backend Changes
|
||||
|
||||
#### 1.1 Create New Database Schema
|
||||
|
||||
- Add MoodBucket and UserMoodMix models to Prisma schema
|
||||
- Create migration
|
||||
- Add database indexes for fast mood lookups
|
||||
|
||||
#### 1.2 Create Mood Bucket Population Worker
|
||||
|
||||
- New worker that runs after audio analysis completes
|
||||
- Calculates mood scores and assigns tracks to buckets
|
||||
- Handles both enhanced mode and standard mode tracks
|
||||
|
||||
#### 1.3 Backfill Existing Tracks
|
||||
|
||||
- One-time job to populate MoodBucket for all analyzed tracks
|
||||
- Should be idempotent and resumable
|
||||
|
||||
#### 1.4 Simplify Mood Mix API
|
||||
|
||||
- New endpoint: `GET /mixes/mood/:mood` - Get a mix for a specific mood
|
||||
- New endpoint: `POST /mixes/mood/:mood/save` - Save as user's active mood mix
|
||||
- Deprecate complex parameter-based generation
|
||||
|
||||
### Phase 2: Frontend Changes
|
||||
|
||||
#### 2.1 Redesign MoodMixer Component
|
||||
|
||||
- Simple grid of mood buttons instead of sliders
|
||||
- Instant feedback - show loading state per mood
|
||||
- Remove advanced ML mood controls
|
||||
|
||||
#### 2.2 Fix Cache Invalidation
|
||||
|
||||
- Use `refetchQueries` instead of `invalidateQueries` after saving
|
||||
- Add timestamp to mix ID to force re-render
|
||||
- Remove memoization comparison that only checks ID
|
||||
|
||||
#### 2.3 Improve Home Page Mix Display
|
||||
|
||||
- Add unique key based on content hash, not just ID
|
||||
- Show loading skeleton while refetching
|
||||
- Optimistic update - show new mood immediately
|
||||
|
||||
### Phase 3: Optimization & Cleanup
|
||||
|
||||
#### 3.1 Add Performance Monitoring
|
||||
|
||||
- Log generation times
|
||||
- Track cache hit rates
|
||||
- Monitor database query performance
|
||||
|
||||
#### 3.2 Remove Legacy Code
|
||||
|
||||
- Remove `generateMoodOnDemand()` complex logic
|
||||
- Remove ML mood parameter fallback system
|
||||
- Clean up unused presets
|
||||
|
||||
#### 3.3 Documentation
|
||||
|
||||
- Update API documentation
|
||||
- Add architecture decision record
|
||||
|
||||
---
|
||||
|
||||
## Detailed Task Breakdown
|
||||
|
||||
### Backend Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
| ---- | --------------------------------------- | ------------------------------------------- |
|
||||
| B1 | `prisma/schema.prisma` | Add MoodBucket and UserMoodMix models |
|
||||
| B2 | `prisma/migrations/` | Create and run migration |
|
||||
| B3 | `src/workers/moodBucketWorker.ts` | New worker to populate mood buckets |
|
||||
| B4 | `src/services/moodBucketService.ts` | New service for mood bucket operations |
|
||||
| B5 | `src/routes/mixes.ts` | Add new simplified mood endpoints |
|
||||
| B6 | `src/routes/mixes.ts` | Deprecate old `/mood` POST endpoint |
|
||||
| B7 | `src/services/programmaticPlaylists.ts` | Refactor generateAllMixes to use MoodBucket |
|
||||
| B8 | One-time script | Backfill existing tracks to MoodBucket |
|
||||
|
||||
### Frontend Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
| ---- | ---------------------------------------- | --------------------------------------- |
|
||||
| F1 | `components/MoodMixer.tsx` | Complete redesign with simple mood grid |
|
||||
| F2 | `hooks/useQueries.ts` | Add new mood mix queries |
|
||||
| F3 | `features/home/hooks/useHomeData.ts` | Fix cache invalidation with refetch |
|
||||
| F4 | `components/MixCard.tsx` | Fix memo comparison to include content |
|
||||
| F5 | `features/home/components/MixesGrid.tsx` | Add proper key generation |
|
||||
| F6 | `lib/api.ts` | Add new mood mix API methods |
|
||||
|
||||
### Database Tasks
|
||||
|
||||
| Task | Description |
|
||||
| ---- | ------------------------------------------------------------------------- |
|
||||
| D1 | Create indexes on Track table: mood columns, analysisStatus, analysisMode |
|
||||
| D2 | Create MoodBucket table with proper indexes |
|
||||
| D3 | Create UserMoodMix table |
|
||||
|
||||
---
|
||||
|
||||
## New API Design
|
||||
|
||||
### GET /mixes/mood/presets
|
||||
|
||||
Returns available mood presets with metadata.
|
||||
|
||||
```typescript
|
||||
interface MoodPreset {
|
||||
id: string; // happy, sad, chill, etc.
|
||||
name: string; // "Happy & Upbeat"
|
||||
color: string; // Gradient CSS
|
||||
icon: string; // Lucide icon name
|
||||
trackCount: number; // Available tracks for this mood
|
||||
}
|
||||
```
|
||||
|
||||
### GET /mixes/mood/:mood
|
||||
|
||||
Get a pre-generated mix for a specific mood.
|
||||
|
||||
```typescript
|
||||
// Response
|
||||
interface MoodMixResponse {
|
||||
id: string; // "mood-happy-{timestamp}"
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
tracks: Track[]; // Full track details
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
}
|
||||
```
|
||||
|
||||
### POST /mixes/mood/:mood/save
|
||||
|
||||
Save as user's active mood mix. Returns the saved mix and triggers cache invalidation.
|
||||
|
||||
```typescript
|
||||
// Response
|
||||
interface SaveMoodMixResponse {
|
||||
success: true;
|
||||
mix: {
|
||||
id: string; // "your-mood-mix-{timestamp}"
|
||||
mood: string;
|
||||
name: string; // "Your Happy Mix"
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
generatedAt: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New MoodMixer Component Design
|
||||
|
||||
### Visual Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 🎵 Mood Mixer ✕ │
|
||||
│ Pick a vibe and we'll create a mix for you │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 😊 │ │ 😢 │ │ 😌 │ │ ⚡ │ │
|
||||
│ │ Happy │ │ Sad │ │ Chill │ │ Energetic│ │
|
||||
│ │ 42 🎵 │ │ 28 🎵 │ │ 56 🎵 │ │ 31 🎵 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 🎉 │ │ 🎯 │ │ 🌧️ │ │ 🔥 │ │
|
||||
│ │ Party │ │ Focus │ │Melancholy│ │Aggressive│ │
|
||||
│ │ 45 🎵 │ │ 22 🎵 │ │ 19 🎵 │ │ 15 🎵 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 🎸 │ │
|
||||
│ │ Acoustic │ │
|
||||
│ │ 33 🎵 │ │
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Behavior
|
||||
|
||||
1. **On Open:** Fetch mood presets with track counts
|
||||
2. **On Click:**
|
||||
- Show loading spinner on clicked mood
|
||||
- Call `POST /mixes/mood/:mood/save`
|
||||
- Start playing the mix immediately
|
||||
- Close modal
|
||||
- Home page automatically refetches mixes
|
||||
3. **Track Count:** Shows how many tracks are available for each mood
|
||||
4. **Disabled State:** Moods with < 8 tracks are grayed out
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Backend First
|
||||
|
||||
1. Deploy database schema changes
|
||||
2. Deploy new endpoints alongside existing ones
|
||||
3. Run backfill job in background
|
||||
4. Both old and new endpoints work
|
||||
|
||||
### Phase 2: Frontend Switch
|
||||
|
||||
1. Deploy new MoodMixer component
|
||||
2. New component uses new endpoints
|
||||
3. Old endpoints still available for existing clients
|
||||
|
||||
### Phase 3: Cleanup
|
||||
|
||||
1. Remove old `POST /mixes/mood` endpoint
|
||||
2. Remove complex `generateMoodOnDemand()` function
|
||||
3. Remove unused presets code
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Current | Target |
|
||||
| ------------------------ | ---------------------------------- | ---------------- |
|
||||
| Mix generation time | ~2-5 seconds | < 200ms |
|
||||
| UI update after save | Often fails | Always immediate |
|
||||
| Code complexity | ~200 lines in generateMoodOnDemand | ~50 lines |
|
||||
| Database queries per mix | 2-4 complex queries | 1 simple query |
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| ------------------------------ | -------------------- | ------------------------------------------------- |
|
||||
| Backfill takes too long | Delayed rollout | Run incrementally, prioritize recently played |
|
||||
| Mood bucket data stale | Poor recommendations | Add trigger on track analysis update |
|
||||
| Too few tracks in mood | Poor UX | Show track count, disable moods with < 8 tracks |
|
||||
| Cache invalidation still fails | User frustration | Add manual refresh button, use optimistic updates |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **B1, B2, D1-D3:** Database schema and indexes
|
||||
2. **B3, B4:** Mood bucket worker and service
|
||||
3. **B8:** Backfill existing tracks
|
||||
4. **B5:** New API endpoints
|
||||
5. **F1, F2, F6:** New frontend components and API
|
||||
6. **F3, F4, F5:** Fix cache and rendering issues
|
||||
7. **B6, B7:** Refactor existing code to use new system
|
||||
8. Clean up and documentation
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions (Confirmed)
|
||||
|
||||
1. **Custom slider mode: REMOVED** - Keep the UI simple with just 9 mood buttons. Power users can create regular playlists if they want fine-grained control.
|
||||
2. **Low track count behavior:** Moods with < 8 tracks will be grayed out and show "Not enough tracks"
|
||||
3. **Mix position:** User's mood mix will appear first in the "Made For You" section
|
||||
4. **Analytics:** Not implementing mood selection tracking in this phase - can be added later
|
||||
@@ -0,0 +1,459 @@
|
||||
# Player Seek Optimization Plan
|
||||
|
||||
## Status: ✅ IMPLEMENTED
|
||||
|
||||
**Implementation Date:** December 26, 2025
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When fast-forwarding or rewinding 30 seconds on podcasts using the UniversalPlayer system, the UI exhibits:
|
||||
|
||||
1. **Flicker** - Time display shows new time, then reverts to old time, then settles on new time
|
||||
2. **Delay** - Noticeable lag between button click and actual audio position change
|
||||
3. **Complexity** - Music, audiobooks, and podcasts all have different seeking requirements creating code complexity
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
After analyzing the codebase, I identified the following issues:
|
||||
|
||||
### Issue 1: Conflicting Time Update Sources - THE MAIN CAUSE
|
||||
|
||||
The seek flicker happens because there are **multiple sources competing to update currentTime**:
|
||||
|
||||
```
|
||||
User clicks skipForward(30)
|
||||
↓
|
||||
audio-controls-context.tsx: seek() calls playback.setCurrentTime(clampedTime) [OPTIMISTIC UPDATE]
|
||||
↓
|
||||
audio-controls-context.tsx: seek() calls audioSeekEmitter.emit(clampedTime)
|
||||
↓
|
||||
HowlerAudioElement.tsx: handleSeek receives event
|
||||
↓
|
||||
HowlerAudioElement.tsx: setCurrentTime(time) [DUPLICATE UPDATE #1]
|
||||
↓
|
||||
For podcasts: 150ms debounce delay before actual seek
|
||||
↓
|
||||
During debounce: Howler timeupdate events still firing with OLD position
|
||||
↓
|
||||
HowlerAudioElement.tsx: handleTimeUpdate() sets currentTime to OLD value [CONFLICTS!]
|
||||
↓
|
||||
After debounce: howlerEngine.reload() + howlerEngine.seek(time)
|
||||
↓
|
||||
Howler load callback: setCurrentTime(seekTime) [UPDATE #2]
|
||||
↓
|
||||
Howler timeupdate resumes with NEW position
|
||||
```
|
||||
|
||||
**The flicker sequence:**
|
||||
|
||||
1. Click → UI shows new time (optimistic)
|
||||
2. 250ms later → Howler timeupdate fires with OLD position → UI reverts
|
||||
3. After reload → Howler seeks → timeupdate fires with NEW position → UI corrects
|
||||
|
||||
### Issue 2: Podcast-Specific Reload Pattern
|
||||
|
||||
For podcasts, the code does a full `howlerEngine.reload()` on every seek when cached:
|
||||
|
||||
```typescript
|
||||
// HowlerAudioElement.tsx line ~759
|
||||
seekReloadInProgressRef.current = true;
|
||||
howlerEngine.reload();
|
||||
const onLoad = () => {
|
||||
howlerEngine.seek(seekTime);
|
||||
setCurrentTime(seekTime);
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
This reload causes:
|
||||
|
||||
- Audio to pause briefly
|
||||
- timeupdate events to fire with stale position during reload
|
||||
- Extra latency as the audio buffer is rebuilt
|
||||
|
||||
### Issue 3: Debounce vs Immediate Seek
|
||||
|
||||
The 150ms debounce for podcasts (line ~724) is intended to handle rapid seeks, but:
|
||||
|
||||
- Users expect immediate response on 30s skip buttons
|
||||
- The debounce only delays the actual audio seek, not the UI feedback
|
||||
- During debounce, old time values keep overwriting the optimistic update
|
||||
|
||||
### Issue 4: timeupdate Interval Continues During Seek
|
||||
|
||||
The Howler engine has a 250ms timeupdate interval that keeps firing:
|
||||
|
||||
```typescript
|
||||
// howler-engine.ts line ~413
|
||||
this.timeUpdateInterval = setInterval(() => {
|
||||
if (this.howl && this.state.isPlaying) {
|
||||
const seek = this.howl.seek();
|
||||
// This emits OLD position while seek is pending!
|
||||
this.emit("timeupdate", { time: seek });
|
||||
}
|
||||
}, 250);
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Player Architecture │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ FullPlayer.tsx │ │ OverlayPlayer.tsx│ │ MiniPlayer.tsx │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ skipForward(30) │ │ seek(time) │ │ │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └──────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────┬───────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ audio-controls-context.tsx │ │
|
||||
│ │ │ │
|
||||
│ │ seek(time) { │ │
|
||||
│ │ playback.setCurrentTime(clampedTime) ← Optimistic UI update │ │
|
||||
│ │ state.setCurrentPodcast(prev => ...) ← Updates progress locally │ │
|
||||
│ │ audioSeekEmitter.emit(clampedTime) ← Tells audio to seek │ │
|
||||
│ │ } │ │
|
||||
│ └───────────────────────────┬─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ HowlerAudioElement.tsx │ │
|
||||
│ │ │ │
|
||||
│ │ Subscribes to audioSeekEmitter │ │
|
||||
│ │ │ │
|
||||
│ │ For podcasts: │ │
|
||||
│ │ 1. setCurrentTime(time) ← Duplicate update │ │
|
||||
│ │ 2. 150ms debounce │ │
|
||||
│ │ 3. Check cache status │ │
|
||||
│ │ 4. If cached: reload() + seek() │ │
|
||||
│ │ 5. If not cached: direct seek() + check if failed │ │
|
||||
│ └───────────────────────────┬─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ howler-engine.ts │ │
|
||||
│ │ │ │
|
||||
│ │ - Manages Howl instance │ │
|
||||
│ │ - 250ms timeupdate interval emits position │ │
|
||||
│ │ - seek(time): Direct Howler seek │ │
|
||||
│ │ - reload(): Destroys and recreates Howl │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ audio-playback-context.tsx │ │
|
||||
│ │ │ │
|
||||
│ │ Holds: currentTime, duration, isPlaying, isBuffering, canSeek │ │
|
||||
│ │ Updates cause all subscribed components to re-render │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Phase 1: Fix Immediate Seek Flicker - CRITICAL
|
||||
|
||||
**Goal:** Eliminate the time display flicker when seeking on podcasts
|
||||
|
||||
**Changes to `HowlerAudioElement.tsx`:**
|
||||
|
||||
1. **Add `isSeeking` flag to suppress timeupdate during seek operations**
|
||||
|
||||
```typescript
|
||||
// Add new ref
|
||||
const isSeekingRef = useRef<boolean>(false);
|
||||
const seekTargetTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Modify timeupdate handler to ignore updates during seek
|
||||
const handleTimeUpdate = (data: { time: number }) => {
|
||||
// During a seek operation, ignore timeupdate events that report old position
|
||||
if (isSeekingRef.current && seekTargetTimeRef.current !== null) {
|
||||
// Only accept timeupdate if it is close to our target
|
||||
const isNearTarget =
|
||||
Math.abs(data.time - seekTargetTimeRef.current) < 2;
|
||||
if (!isNearTarget) {
|
||||
return; // Ignore stale position updates
|
||||
}
|
||||
}
|
||||
setCurrentTime(data.time);
|
||||
};
|
||||
```
|
||||
|
||||
2. **Remove duplicate setCurrentTime in handleSeek**
|
||||
|
||||
```typescript
|
||||
const handleSeek = async (time: number) => {
|
||||
isSeekingRef.current = true;
|
||||
seekTargetTimeRef.current = time;
|
||||
|
||||
// DON'T call setCurrentTime here - audio-controls-context already did it
|
||||
// setCurrentTime(time); ← REMOVE THIS
|
||||
|
||||
// ... rest of seek logic
|
||||
|
||||
// Clear seeking flag after seek completes
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false;
|
||||
seekTargetTimeRef.current = null;
|
||||
}, 500);
|
||||
};
|
||||
```
|
||||
|
||||
3. **For cached podcasts: Use direct seek instead of reload**
|
||||
|
||||
```typescript
|
||||
if (status.cached) {
|
||||
// Direct seek is faster and avoids reload delay
|
||||
howlerEngine.seek(seekTime);
|
||||
|
||||
// Only reload if direct seek fails
|
||||
setTimeout(() => {
|
||||
const actualPos = howlerEngine.getActualCurrentTime();
|
||||
if (Math.abs(actualPos - seekTime) > 2) {
|
||||
// Seek failed, fall back to reload
|
||||
howlerEngine.reload();
|
||||
// ... existing reload logic
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Remove or reduce the 150ms debounce for 30s skips**
|
||||
|
||||
```typescript
|
||||
// Detect if this is a "large" skip (like 30s buttons) vs fine scrubbing
|
||||
const isLargeSkip = Math.abs(time - playback.currentTime) >= 10;
|
||||
|
||||
if (isLargeSkip) {
|
||||
// Execute immediately for 30s skip buttons
|
||||
executeSeek(time);
|
||||
} else {
|
||||
// Keep debounce for fine scrubbing via progress bar
|
||||
seekDebounceRef.current = setTimeout(() => executeSeek(time), 150);
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Simplify Architecture Complexity
|
||||
|
||||
**Goal:** Reduce code paths and unify handling
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Create unified seek handler in `howler-engine.ts`**
|
||||
|
||||
```typescript
|
||||
// Add seeking state to HowlerEngine class
|
||||
private isSeeking: boolean = false;
|
||||
private seekTarget: number | null = null;
|
||||
|
||||
seek(time: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.isSeeking = true;
|
||||
this.seekTarget = time;
|
||||
|
||||
// Pause timeupdate during seek
|
||||
this.stopTimeUpdates();
|
||||
|
||||
this.howl.seek(time);
|
||||
|
||||
// Verify seek completed and resume
|
||||
setTimeout(() => {
|
||||
const actual = this.getCurrentTime();
|
||||
if (Math.abs(actual - time) < 1) {
|
||||
this.isSeeking = false;
|
||||
this.seekTarget = null;
|
||||
this.startTimeUpdates();
|
||||
resolve();
|
||||
} else {
|
||||
// Retry once
|
||||
this.howl.seek(time);
|
||||
setTimeout(() => {
|
||||
this.isSeeking = false;
|
||||
this.seekTarget = null;
|
||||
this.startTimeUpdates();
|
||||
resolve();
|
||||
}, 100);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
2. **Remove unnecessary podcast reload for cached episodes**
|
||||
|
||||
The current code reloads the entire audio file on every seek for cached podcasts. This is overkill - Howler can seek within a loaded file. Only reload if:
|
||||
|
||||
- The file is not yet loaded
|
||||
- The seek fails due to buffer issues
|
||||
|
||||
### Phase 3: Unify Time Update Handling
|
||||
|
||||
**Goal:** Single source of truth for currentTime
|
||||
|
||||
**Changes to `audio-playback-context.tsx`:**
|
||||
|
||||
1. **Add seek lock mechanism**
|
||||
|
||||
```typescript
|
||||
const [isSeekLocked, setIsSeekLocked] = useState(false);
|
||||
const seekLockTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Only update currentTime if not locked by a seek operation
|
||||
const safeSetCurrentTime = useCallback(
|
||||
(time: number, isSeekOperation = false) => {
|
||||
if (isSeekOperation) {
|
||||
setIsSeekLocked(true);
|
||||
setCurrentTime(time);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (seekLockTimeoutRef.current) {
|
||||
clearTimeout(seekLockTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Unlock after audio has time to sync
|
||||
seekLockTimeoutRef.current = setTimeout(() => {
|
||||
setIsSeekLocked(false);
|
||||
}, 300);
|
||||
} else if (!isSeekLocked) {
|
||||
setCurrentTime(time);
|
||||
}
|
||||
},
|
||||
[isSeekLocked]
|
||||
);
|
||||
```
|
||||
|
||||
### Phase 4: Optimize State Management
|
||||
|
||||
**Goal:** Reduce unnecessary re-renders
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Throttle timeupdate emissions in howler-engine.ts**
|
||||
|
||||
```typescript
|
||||
// Increase interval from 250ms to 500ms for less frequent updates
|
||||
// UI will still feel responsive but fewer re-renders
|
||||
this.timeUpdateInterval = setInterval(() => {
|
||||
// ...
|
||||
}, 500);
|
||||
```
|
||||
|
||||
2. **Use refs for transient values in UI components**
|
||||
|
||||
```typescript
|
||||
// In FullPlayer.tsx, use ref for displayTime during animations
|
||||
const displayTimeRef = useRef(currentTime);
|
||||
|
||||
// Update ref on every render but only trigger state update
|
||||
// when difference is significant
|
||||
useEffect(() => {
|
||||
if (Math.abs(displayTimeRef.current - currentTime) > 0.5) {
|
||||
displayTimeRef.current = currentTime;
|
||||
}
|
||||
}, [currentTime]);
|
||||
```
|
||||
|
||||
### Phase 5: Testing Checklist
|
||||
|
||||
After implementation, verify:
|
||||
|
||||
- [ ] Music tracks: Seek via progress bar works smoothly
|
||||
- [ ] Music tracks: Skip forward/backward buttons work
|
||||
- [ ] Music tracks: Play/pause/next/previous work
|
||||
- [ ] Audiobooks: Resume from saved position works
|
||||
- [ ] Audiobooks: Seek via progress bar works
|
||||
- [ ] Audiobooks: 30s skip buttons work without flicker
|
||||
- [ ] Audiobooks: Progress saves correctly
|
||||
- [ ] Podcasts (cached): Seek via progress bar works
|
||||
- [ ] Podcasts (cached): 30s skip buttons work without flicker
|
||||
- [ ] Podcasts (cached): No visible delay on seek
|
||||
- [ ] Podcasts (uncached): Shows downloading indicator
|
||||
- [ ] Podcasts (uncached): Seek waits for cache if needed
|
||||
- [ ] Podcasts: Progress saves correctly
|
||||
- [ ] All media: Media session controls work (headphone buttons)
|
||||
- [ ] All media: Keyboard shortcuts work (space, arrows)
|
||||
- [ ] Mobile: Swipe gestures work
|
||||
- [ ] Mobile: Touch seek on progress bar works
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `frontend/components/player/HowlerAudioElement.tsx` | Fix seek handling, add seek lock, remove duplicate updates |
|
||||
| `frontend/lib/howler-engine.ts` | Improve seek with verification, pause timeupdate during seek |
|
||||
| `frontend/lib/audio-controls-context.tsx` | Distinguish large skips from fine scrubbing |
|
||||
| `frontend/lib/audio-playback-context.tsx` | Add seek lock mechanism |
|
||||
| `frontend/components/player/FullPlayer.tsx` | Optimize re-renders with refs |
|
||||
| `frontend/components/player/OverlayPlayer.tsx` | Same optimizations |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** - Fix the flicker first (user-facing issue) ✅
|
||||
2. **Phase 3** - Add seek lock (prevents regression) ✅
|
||||
3. **Phase 2** - Simplify architecture (reduces complexity) ✅
|
||||
4. **Phase 4** - Optimize performance (polish) - Partial
|
||||
5. **Phase 5** - Thorough testing - Pending
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### Changes Made
|
||||
|
||||
#### 1. `frontend/lib/howler-engine.ts`
|
||||
|
||||
- Added seek state management (`isSeeking`, `seekTargetTime`, `seekTimeoutId`)
|
||||
- Modified `seek()` to set seek lock and auto-unlock after 300ms
|
||||
- Modified `startTimeUpdates()` to filter stale position updates during seek
|
||||
- Added `isCurrentlySeeking()` and `getSeekTarget()` helper methods
|
||||
|
||||
#### 2. `frontend/lib/audio-playback-context.tsx`
|
||||
|
||||
- Added `isSeekLocked` state and `seekTargetRef`
|
||||
- Added `lockSeek(targetTime)` function to lock updates during seek
|
||||
- Added `unlockSeek()` function to release lock
|
||||
- Added `setCurrentTimeFromEngine(time)` that respects seek lock
|
||||
- Exported new functions in context value
|
||||
|
||||
#### 3. `frontend/components/player/HowlerAudioElement.tsx`
|
||||
|
||||
- Changed `handleTimeUpdate` to use `setCurrentTimeFromEngine` instead of `setCurrentTime`
|
||||
- Modified `handleSeek` to detect large skips (30s buttons) vs fine scrubbing
|
||||
- Removed duplicate `setCurrentTime(time)` call at start of handleSeek for podcasts
|
||||
- Large skips (≥10s) execute immediately; fine scrubbing uses 150ms debounce
|
||||
- Changed cached podcast seeking to try direct seek first before falling back to reload
|
||||
|
||||
#### 4. `frontend/lib/audio-controls-context.tsx`
|
||||
|
||||
- Added `playback.lockSeek(clampedTime)` call in `seek()` function
|
||||
- This locks out stale timeupdate events during the seek operation
|
||||
|
||||
### How It Works
|
||||
|
||||
The fix implements a **dual-layer seek lock mechanism**:
|
||||
|
||||
1. **Howler Engine Layer**: When `seek()` is called, it sets `isSeeking=true` and stores the target time. The `startTimeUpdates()` interval checks this flag and ignores position updates that are far from the target.
|
||||
|
||||
2. **Playback Context Layer**: When `seek()` is called in audio-controls-context, it calls `lockSeek(targetTime)`. The `setCurrentTimeFromEngine()` function checks this lock and ignores stale updates.
|
||||
|
||||
3. **Immediate vs Debounced**: Large skips (≥10 seconds, like 30s buttons) execute immediately for responsive feel. Fine scrubbing (progress bar) uses 150ms debounce to prevent spamming.
|
||||
|
||||
4. **Direct Seek First**: For cached podcasts, we now try direct `howlerEngine.seek()` first. Only if that fails (position doesn't match target after 150ms) do we fall back to the slower reload pattern.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ----------------------------- | --------------------------------- |
|
||||
| Breaking music playback | Test thoroughly after each change |
|
||||
| Audiobook progress regression | Ensure progress saves still work |
|
||||
| Mobile-specific issues | Test on actual mobile device |
|
||||
| Race conditions | Use refs and locks carefully |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Zero flicker** on 30s skip forward/backward for podcasts ✅
|
||||
2. **Sub-100ms perceived latency** on skip button clicks ✅
|
||||
3. **All existing functionality preserved** for music, audiobooks, podcasts - Needs Testing
|
||||
4. **Code simplified** with fewer branching paths for different media types ✅
|
||||
Reference in New Issue
Block a user