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:
Kevin O'Neill
2025-12-26 13:06:17 -06:00
parent d8c608cf70
commit f8b464feec
28 changed files with 5328 additions and 1615 deletions
+63 -2
View File
@@ -1,6 +1,6 @@
const AUTH_TOKEN_KEY = "auth_token";
// Mood Mix Types
// Mood Mix Types (Legacy - for old presets endpoint)
export interface MoodPreset {
id: string;
name: string;
@@ -29,6 +29,43 @@ export interface MoodMixParams {
limit?: number;
}
// New Mood Bucket Types (simplified mood system)
export type MoodType =
| "happy"
| "sad"
| "chill"
| "energetic"
| "party"
| "focus"
| "melancholy"
| "aggressive"
| "acoustic";
export interface MoodBucketPreset {
id: MoodType;
name: string;
color: string;
icon: string;
trackCount: number;
}
export interface MoodBucketMix {
id: string;
mood: MoodType;
name: string;
description: string;
trackIds: string[];
coverUrls: string[];
trackCount: number;
color: string;
tracks?: any[];
}
export interface SavedMoodMixResponse {
success: boolean;
mix: MoodBucketMix & { generatedAt: string };
}
// Dynamically determine API URL based on configuration
const getApiBaseUrl = () => {
// Server-side rendering
@@ -1225,7 +1262,7 @@ class ApiClient {
);
}
// Mood on Demand
// Mood on Demand (Legacy)
async getMoodPresets() {
return this.request<MoodPreset[]>("/mixes/mood/presets");
}
@@ -1237,6 +1274,30 @@ class ApiClient {
});
}
// New Mood Bucket System (simplified, pre-computed)
async getMoodBucketPresets() {
return this.request<MoodBucketPreset[]>("/mixes/mood/buckets/presets");
}
async getMoodBucketMix(mood: MoodType) {
return this.request<MoodBucketMix>(`/mixes/mood/buckets/${mood}`);
}
async saveMoodBucketMix(mood: MoodType) {
return this.request<SavedMoodMixResponse>(
`/mixes/mood/buckets/${mood}/save`,
{ method: "POST" }
);
}
async backfillMoodBuckets() {
return this.request<{
success: boolean;
processed: number;
assigned: number;
}>("/mixes/mood/buckets/backfill", { method: "POST" });
}
// Enrichment
async getEnrichmentSettings() {
return this.request<any>("/enrichment/settings");
+4
View File
@@ -663,6 +663,10 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
? Math.min(Math.max(time, 0), maxDuration)
: Math.max(time, 0);
// Lock seek to prevent stale timeupdate events from overwriting optimistic update
// This is especially important for podcasts where seeking may require audio reload
playback.lockSeek(clampedTime);
// Optimistically update local playback time for instant UI feedback
playback.setCurrentTime(clampedTime);
+94 -3
View File
@@ -6,6 +6,7 @@ import {
useState,
useEffect,
useRef,
useCallback,
ReactNode,
useMemo,
} from "react";
@@ -18,13 +19,17 @@ interface AudioPlaybackContextType {
targetSeekPosition: number | null;
canSeek: boolean;
downloadProgress: number | null; // 0-100 for downloading, null for not downloading
isSeekLocked: boolean; // True when a seek operation is in progress
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setCurrentTimeFromEngine: (time: number) => void; // For timeupdate events - respects seek lock
setDuration: (duration: number) => void;
setIsBuffering: (buffering: boolean) => void;
setTargetSeekPosition: (position: number | null) => void;
setCanSeek: (canSeek: boolean) => void;
setDownloadProgress: (progress: number | null) => void;
lockSeek: (targetTime: number) => void; // Lock updates during seek
unlockSeek: () => void; // Unlock after seek completes
}
const AudioPlaybackContext = createContext<
@@ -42,12 +47,73 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isBuffering, setIsBuffering] = useState(false);
const [targetSeekPosition, setTargetSeekPosition] = useState<number | null>(null);
const [targetSeekPosition, setTargetSeekPosition] = useState<number | null>(
null
);
const [canSeek, setCanSeek] = useState(true); // Default true for music, false for uncached podcasts
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadProgress, setDownloadProgress] = useState<number | null>(
null
);
const [isHydrated, setIsHydrated] = useState(false);
const lastSaveTimeRef = useRef<number>(0);
// Seek lock state - prevents stale timeupdate events from overwriting optimistic UI updates
const [isSeekLocked, setIsSeekLocked] = useState(false);
const seekTargetRef = useRef<number | null>(null);
const seekLockTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Lock the seek state - ignores timeupdate events until audio catches up or timeout
const lockSeek = useCallback((targetTime: number) => {
setIsSeekLocked(true);
seekTargetRef.current = targetTime;
// Clear any existing timeout
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
}
// Auto-unlock after 500ms as a safety measure
seekLockTimeoutRef.current = setTimeout(() => {
setIsSeekLocked(false);
seekTargetRef.current = null;
seekLockTimeoutRef.current = null;
}, 500);
}, []);
// Unlock the seek state
const unlockSeek = useCallback(() => {
setIsSeekLocked(false);
seekTargetRef.current = null;
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
seekLockTimeoutRef.current = null;
}
}, []);
// setCurrentTimeFromEngine - for timeupdate events from Howler
// Respects seek lock to prevent stale updates causing flicker
const setCurrentTimeFromEngine = useCallback(
(time: number) => {
if (isSeekLocked && seekTargetRef.current !== null) {
// During seek, only accept updates that are close to our target
// This prevents old positions from briefly showing during seek
const isNearTarget = Math.abs(time - seekTargetRef.current) < 2;
if (!isNearTarget) {
return; // Ignore stale position update
}
// Position is near target - seek completed, unlock
setIsSeekLocked(false);
seekTargetRef.current = null;
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
seekLockTimeoutRef.current = null;
}
}
setCurrentTime(time);
},
[isSeekLocked]
);
// Restore currentTime from localStorage on mount
// NOTE: Do NOT touch isPlaying here - let user actions control it
useEffect(() => {
@@ -63,6 +129,15 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
setIsHydrated(true);
}, []);
// Cleanup seek lock timeout on unmount
useEffect(() => {
return () => {
if (seekLockTimeoutRef.current) {
clearTimeout(seekLockTimeoutRef.current);
}
};
}, []);
// Save currentTime to localStorage (throttled to avoid excessive writes)
useEffect(() => {
if (!isHydrated || typeof window === "undefined") return;
@@ -92,15 +167,31 @@ export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
targetSeekPosition,
canSeek,
downloadProgress,
isSeekLocked,
setIsPlaying,
setCurrentTime,
setCurrentTimeFromEngine,
setDuration,
setIsBuffering,
setTargetSeekPosition,
setCanSeek,
setDownloadProgress,
lockSeek,
unlockSeek,
}),
[isPlaying, currentTime, duration, isBuffering, targetSeekPosition, canSeek, downloadProgress]
[
isPlaying,
currentTime,
duration,
isBuffering,
targetSeekPosition,
canSeek,
downloadProgress,
isSeekLocked,
setCurrentTimeFromEngine,
lockSeek,
unlockSeek,
]
);
return (
+86 -8
View File
@@ -52,6 +52,11 @@ class HowlerEngine {
private readonly popFadeMs: number = 10; // ms - micro-fade to reduce click/pop on track changes
private shouldRetryLoads: boolean = false; // Only retry transient load errors where it helps (Android WebView)
private cleanupTimeoutId: NodeJS.Timeout | null = null; // Track cleanup timeout to prevent race conditions
// Seek state management - prevents stale timeupdate events during seeks
private isSeeking: boolean = false;
private seekTargetTime: number | null = null;
private seekTimeoutId: NodeJS.Timeout | null = null;
constructor() {
// Initialize event listener maps
@@ -105,13 +110,15 @@ class HowlerEngine {
this.state.currentSrc = src;
// Detect if running in Android WebView (for graceful degradation)
const isAndroidWebView = typeof navigator !== "undefined" &&
/wv/.test(navigator.userAgent.toLowerCase()) &&
const isAndroidWebView =
typeof navigator !== "undefined" &&
/wv/.test(navigator.userAgent.toLowerCase()) &&
/android/.test(navigator.userAgent.toLowerCase());
this.shouldRetryLoads = isAndroidWebView;
// Check if this is a podcast/audiobook stream (they need HTML5 Audio for Range request support)
const isPodcastOrAudiobook = src.includes("/api/podcasts/") || src.includes("/api/audiobooks/");
const isPodcastOrAudiobook =
src.includes("/api/podcasts/") || src.includes("/api/audiobooks/");
// Build Howl config
// Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes
@@ -164,13 +171,24 @@ class HowlerEngine {
}
},
onloaderror: (id, error) => {
console.error("[HowlerEngine] Load error:", error, "Attempt:", this.retryCount + 1);
console.error(
"[HowlerEngine] Load error:",
error,
"Attempt:",
this.retryCount + 1
);
this.isLoading = false;
// Retry logic for transient errors (common on Android WebView)
if (this.shouldRetryLoads && this.retryCount < this.maxRetries && this.state.currentSrc) {
if (
this.shouldRetryLoads &&
this.retryCount < this.maxRetries &&
this.state.currentSrc
) {
this.retryCount++;
console.log(`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`);
console.log(
`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`
);
// Save src before cleanup
const srcToRetry = this.state.currentSrc;
@@ -183,7 +201,12 @@ class HowlerEngine {
// Wait a bit before retrying
setTimeout(() => {
this.load(srcToRetry, autoplayToRetry, formatToRetry, true);
this.load(
srcToRetry,
autoplayToRetry,
formatToRetry,
true
);
}, 500 * this.retryCount); // Exponential backoff
return;
}
@@ -276,14 +299,45 @@ class HowlerEngine {
/**
* Seek to a specific time
* Simple seek - UI handles buffering state if needed
* Includes seek locking to prevent stale timeupdate events from causing UI flicker
*/
seek(time: number): void {
if (!this.howl) return;
// Set seek lock - this prevents timeupdate from emitting stale values
this.isSeeking = true;
this.seekTargetTime = time;
// Clear any existing seek timeout
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
}
this.state.currentTime = time;
this.howl.seek(time);
this.emit("seek", { time });
// Release seek lock after audio has time to sync
// This timeout ensures timeupdate doesn't emit stale values during the seek operation
this.seekTimeoutId = setTimeout(() => {
this.isSeeking = false;
this.seekTargetTime = null;
this.seekTimeoutId = null;
}, 300);
}
/**
* Check if currently in a seek operation
*/
isCurrentlySeeking(): boolean {
return this.isSeeking;
}
/**
* Get the target seek position (if seeking)
*/
getSeekTarget(): number | null {
return this.seekTargetTime;
}
/**
@@ -416,6 +470,23 @@ class HowlerEngine {
if (this.howl && this.state.isPlaying) {
const seek = this.howl.seek();
if (typeof seek === "number") {
// During a seek operation, ignore timeupdate events that report stale positions
// This prevents the UI flicker where old position briefly shows during seek
if (this.isSeeking && this.seekTargetTime !== null) {
const isNearTarget = Math.abs(seek - this.seekTargetTime) < 2;
if (!isNearTarget) {
// Stale position - don't emit, use target instead
return;
}
// Position is near target, seek completed - clear seek state
this.isSeeking = false;
this.seekTargetTime = null;
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
this.seekTimeoutId = null;
}
}
this.state.currentTime = seek;
this.emit("timeupdate", { time: seek });
}
@@ -497,6 +568,13 @@ class HowlerEngine {
clearTimeout(this.cleanupTimeoutId);
this.cleanupTimeoutId = null;
}
// Clear seek state
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
this.seekTimeoutId = null;
}
this.isSeeking = false;
this.seekTargetTime = null;
}
}