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:
+63
-2
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user