"use client"; import { useAudioState } from "@/lib/audio-state-context"; import { useAudioPlayback } from "@/lib/audio-playback-context"; import { useAudioControls } from "@/lib/audio-controls-context"; import { api } from "@/lib/api"; import { howlerEngine } from "@/lib/howler-engine"; import { audioSeekEmitter } from "@/lib/audio-seek-emitter"; import { useEffect, useLayoutEffect, useRef, memo, useCallback, useMemo } from "react"; function podcastDebugEnabled(): boolean { try { return ( typeof window !== "undefined" && window.localStorage?.getItem("lidifyPodcastDebug") === "1" ); } catch { return false; } } function podcastDebugLog(message: string, data?: Record) { if (!podcastDebugEnabled()) return; console.log(`[PodcastDebug] ${message}`, data || {}); } /** * HowlerAudioElement - Unified audio playback using Howler.js * * Handles: web playback, progress saving for audiobooks/podcasts * Browser media controls are handled separately by useMediaSession hook */ export const HowlerAudioElement = memo(function HowlerAudioElement() { // State context const { currentTrack, currentAudiobook, currentPodcast, playbackType, volume, isMuted, repeatMode, setCurrentAudiobook, setCurrentTrack, setCurrentPodcast, setPlaybackType, queue, } = useAudioState(); // Playback context const { isPlaying, setCurrentTime, setDuration, setIsPlaying, isBuffering, setIsBuffering, setTargetSeekPosition, canSeek, setCanSeek, setDownloadProgress, } = useAudioPlayback(); // Controls context const { pause, next } = useAudioControls(); // Refs const lastTrackIdRef = useRef(null); const lastPlayingStateRef = useRef(isPlaying); const progressSaveIntervalRef = useRef(null); const lastProgressSaveRef = useRef(0); const isUserInitiatedRef = useRef(false); const isLoadingRef = useRef(false); const loadIdRef = useRef(0); const cachePollingRef = useRef(null); const seekCheckTimeoutRef = useRef(null); const cacheStatusPollingRef = useRef(null); const seekReloadListenerRef = useRef<(() => void) | null>(null); const seekReloadInProgressRef = useRef(false); // Track when a seek operation is in progress to prevent load effect from interfering const isSeekingRef = useRef(false); // Track load listeners for cleanup to prevent memory leaks const loadListenerRef = useRef<(() => void) | null>(null); const loadErrorListenerRef = useRef<(() => void) | null>(null); const cachePollingLoadListenerRef = useRef<(() => void) | null>(null); // Reset duration when nothing is playing useEffect(() => { if (!currentTrack && !currentAudiobook && !currentPodcast) { setDuration(0); } }, [currentTrack, currentAudiobook, currentPodcast, setDuration]); // Subscribe to Howler events useEffect(() => { const handleTimeUpdate = (data: { time: number }) => { setCurrentTime(data.time); }; const handleLoad = (data: { duration: number }) => { const fallbackDuration = currentTrack?.duration || currentAudiobook?.duration || currentPodcast?.duration || 0; setDuration(data.duration || fallbackDuration); }; const handleEnd = () => { // Save final progress for audiobooks/podcasts if (playbackType === "audiobook" && currentAudiobook) { saveAudiobookProgress(true); } else if (playbackType === "podcast" && currentPodcast) { savePodcastProgress(true); } // Handle track advancement based on repeat mode if (playbackType === "track") { if (repeatMode === "one") { howlerEngine.seek(0); howlerEngine.play(); } else { next(); } } else { pause(); } }; const handleError = (data: { error: any }) => { console.error("[HowlerAudioElement] Playback error:", data.error); setIsPlaying(false); isUserInitiatedRef.current = false; if (playbackType === "track") { if (queue.length > 1) { console.log("[HowlerAudioElement] Track failed, trying next in queue"); lastTrackIdRef.current = null; isLoadingRef.current = false; next(); } else { console.log("[HowlerAudioElement] Track failed, no more in queue - clearing"); lastTrackIdRef.current = null; isLoadingRef.current = false; setCurrentTrack(null); setPlaybackType(null); } } else if (playbackType === "audiobook") { setCurrentAudiobook(null); setPlaybackType(null); } else if (playbackType === "podcast") { setCurrentPodcast(null); setPlaybackType(null); } }; const handlePlay = () => { if (!isUserInitiatedRef.current) { setIsPlaying(true); } isUserInitiatedRef.current = false; }; const handlePause = () => { if (isLoadingRef.current) return; if (seekReloadInProgressRef.current) return; if (!isUserInitiatedRef.current) { setIsPlaying(false); } isUserInitiatedRef.current = false; }; howlerEngine.on("timeupdate", handleTimeUpdate); howlerEngine.on("load", handleLoad); howlerEngine.on("end", handleEnd); howlerEngine.on("loaderror", handleError); howlerEngine.on("playerror", handleError); howlerEngine.on("play", handlePlay); howlerEngine.on("pause", handlePause); return () => { howlerEngine.off("timeupdate", handleTimeUpdate); howlerEngine.off("load", handleLoad); howlerEngine.off("end", handleEnd); howlerEngine.off("loaderror", handleError); howlerEngine.off("playerror", handleError); howlerEngine.off("play", handlePlay); howlerEngine.off("pause", handlePause); }; }, [playbackType, currentTrack, currentAudiobook, currentPodcast, repeatMode, next, pause, setCurrentTime, setDuration, setIsPlaying, queue, setCurrentTrack, setCurrentAudiobook, setCurrentPodcast, setPlaybackType]); // Save audiobook progress const saveAudiobookProgress = useCallback( async (isFinished: boolean = false) => { if (!currentAudiobook) return; const currentTime = howlerEngine.getCurrentTime(); const duration = howlerEngine.getDuration() || currentAudiobook.duration; if (currentTime === lastProgressSaveRef.current && !isFinished) return; lastProgressSaveRef.current = currentTime; try { await api.updateAudiobookProgress( currentAudiobook.id, isFinished ? duration : currentTime, duration, isFinished ); setCurrentAudiobook({ ...currentAudiobook, progress: { currentTime: isFinished ? duration : currentTime, progress: duration > 0 ? ((isFinished ? duration : currentTime) / duration) * 100 : 0, isFinished, lastPlayedAt: new Date(), }, }); } catch (err) { console.error( "[HowlerAudioElement] Failed to save audiobook progress:", err ); } }, [currentAudiobook, setCurrentAudiobook] ); // Save podcast progress const savePodcastProgress = useCallback( async (isFinished: boolean = false) => { if (!currentPodcast) return; if (isBuffering && !isFinished) return; const currentTime = howlerEngine.getCurrentTime(); const duration = howlerEngine.getDuration() || currentPodcast.duration; if (currentTime <= 0 && !isFinished) return; try { const [podcastId, episodeId] = currentPodcast.id.split(":"); await api.updatePodcastProgress( podcastId, episodeId, isFinished ? duration : currentTime, duration, isFinished ); } catch (err) { console.error( "[HowlerAudioElement] Failed to save podcast progress:", err ); } }, [currentPodcast, isBuffering] ); // Load and play audio when track changes useEffect(() => { const currentMediaId = currentTrack?.id || currentAudiobook?.id || currentPodcast?.id || null; if (!currentMediaId) { howlerEngine.stop(); lastTrackIdRef.current = null; isLoadingRef.current = false; return; } if (currentMediaId === lastTrackIdRef.current) { // Skip if a seek operation is in progress - the seek handler will manage playback if (isSeekingRef.current) { return; } const shouldPlay = lastPlayingStateRef.current || isPlaying; const isCurrentlyPlaying = howlerEngine.isPlaying(); if (shouldPlay && !isCurrentlyPlaying) { howlerEngine.seek(0); howlerEngine.play(); } return; } if (isLoadingRef.current) return; isLoadingRef.current = true; lastTrackIdRef.current = currentMediaId; loadIdRef.current += 1; const thisLoadId = loadIdRef.current; let streamUrl: string | null = null; let startTime = 0; if (playbackType === "track" && currentTrack) { streamUrl = api.getStreamUrl(currentTrack.id); } else if (playbackType === "audiobook" && currentAudiobook) { streamUrl = api.getAudiobookStreamUrl(currentAudiobook.id); startTime = currentAudiobook.progress?.currentTime || 0; } else if (playbackType === "podcast" && currentPodcast) { const [podcastId, episodeId] = currentPodcast.id.split(":"); streamUrl = api.getPodcastEpisodeStreamUrl(podcastId, episodeId); startTime = currentPodcast.progress?.currentTime || 0; podcastDebugLog("load podcast", { currentPodcastId: currentPodcast.id, podcastId, episodeId, title: currentPodcast.title, podcastTitle: currentPodcast.podcastTitle, startTime, loadId: thisLoadId, }); } if (streamUrl) { const wasHowlerPlayingBeforeLoad = howlerEngine.isPlaying(); const fallbackDuration = currentTrack?.duration || currentAudiobook?.duration || currentPodcast?.duration || 0; setDuration(fallbackDuration); let format = "mp3"; const filePath = currentTrack?.filePath || ""; if (filePath) { const ext = filePath.split(".").pop()?.toLowerCase(); if (ext === "flac") format = "flac"; else if (ext === "m4a" || ext === "aac") format = "mp4"; else if (ext === "ogg" || ext === "opus") format = "webm"; else if (ext === "wav") format = "wav"; } howlerEngine.load(streamUrl, false, format); if (playbackType === "podcast" && currentPodcast) { podcastDebugLog("howlerEngine.load()", { url: streamUrl, format, loadId: thisLoadId, }); } // Clean up any previous load listeners before adding new ones if (loadListenerRef.current) { howlerEngine.off("load", loadListenerRef.current); loadListenerRef.current = null; } if (loadErrorListenerRef.current) { howlerEngine.off("loaderror", loadErrorListenerRef.current); loadErrorListenerRef.current = null; } const handleLoaded = () => { if (loadIdRef.current !== thisLoadId) return; isLoadingRef.current = false; if (startTime > 0) { howlerEngine.seek(startTime); } if (playbackType === "podcast" && currentPodcast) { podcastDebugLog("loaded", { loadId: thisLoadId, durationHowler: howlerEngine.getDuration(), howlerTime: howlerEngine.getCurrentTime(), actualTime: howlerEngine.getActualCurrentTime(), startTime, canSeek, }); } const shouldAutoPlay = lastPlayingStateRef.current || wasHowlerPlayingBeforeLoad; if (shouldAutoPlay) { howlerEngine.play(); if (!lastPlayingStateRef.current) { setIsPlaying(true); } } // Clean up both listeners howlerEngine.off("load", handleLoaded); howlerEngine.off("loaderror", handleLoadError); loadListenerRef.current = null; loadErrorListenerRef.current = null; }; const handleLoadError = () => { isLoadingRef.current = false; howlerEngine.off("load", handleLoaded); howlerEngine.off("loaderror", handleLoadError); loadListenerRef.current = null; loadErrorListenerRef.current = null; }; // Store refs for cleanup on unmount loadListenerRef.current = handleLoaded; loadErrorListenerRef.current = handleLoadError; howlerEngine.on("load", handleLoaded); howlerEngine.on("loaderror", handleLoadError); } else { isLoadingRef.current = false; } }, [currentTrack, currentAudiobook, currentPodcast, playbackType, setDuration]); // Check podcast cache status and control canSeek useEffect(() => { if (playbackType !== "podcast") { setCanSeek(true); setDownloadProgress(null); if (cacheStatusPollingRef.current) { clearInterval(cacheStatusPollingRef.current); cacheStatusPollingRef.current = null; } return; } if (!currentPodcast) { setCanSeek(true); return; } const [podcastId, episodeId] = currentPodcast.id.split(":"); const checkCacheStatus = async () => { try { const status = await api.getPodcastEpisodeCacheStatus( podcastId, episodeId ); if (status.cached) { setCanSeek(true); setDownloadProgress(null); if (cacheStatusPollingRef.current) { clearInterval(cacheStatusPollingRef.current); cacheStatusPollingRef.current = null; } } else { setCanSeek(false); setDownloadProgress( status.downloadProgress ?? (status.downloading ? 0 : null) ); } return status.cached; } catch (err) { console.error( "[HowlerAudioElement] Failed to check cache status:", err ); setCanSeek(true); return true; } }; checkCacheStatus(); cacheStatusPollingRef.current = setInterval(async () => { const isCached = await checkCacheStatus(); if (isCached && cacheStatusPollingRef.current) { clearInterval(cacheStatusPollingRef.current); cacheStatusPollingRef.current = null; } }, 5000); return () => { if (cacheStatusPollingRef.current) { clearInterval(cacheStatusPollingRef.current); cacheStatusPollingRef.current = null; } }; }, [currentPodcast, playbackType, setCanSeek, setDownloadProgress]); // Keep lastPlayingStateRef always in sync useLayoutEffect(() => { lastPlayingStateRef.current = isPlaying; }, [isPlaying]); // Handle play/pause changes from UI useEffect(() => { if (isLoadingRef.current) return; isUserInitiatedRef.current = true; if (isPlaying) { howlerEngine.play(); } else { howlerEngine.pause(); } }, [isPlaying]); // Handle volume changes useEffect(() => { howlerEngine.setVolume(volume); }, [volume]); // Handle mute changes useEffect(() => { howlerEngine.setMuted(isMuted); }, [isMuted]); // Poll for podcast cache and reload when ready const startCachePolling = useCallback( (podcastId: string, episodeId: string, targetTime: number) => { if (cachePollingRef.current) { clearInterval(cachePollingRef.current); } let pollCount = 0; const maxPolls = 60; cachePollingRef.current = setInterval(async () => { pollCount++; try { const status = await api.getPodcastEpisodeCacheStatus( podcastId, episodeId ); podcastDebugLog("cache poll", { podcastId, episodeId, pollCount, cached: status.cached, downloading: status.downloading, downloadProgress: status.downloadProgress, targetTime, }); if (status.cached) { if (cachePollingRef.current) { clearInterval(cachePollingRef.current); cachePollingRef.current = null; } podcastDebugLog("cache ready -> howlerEngine.reload()", { podcastId, episodeId, targetTime, }); // Clean up any previous cache polling load listener if (cachePollingLoadListenerRef.current) { howlerEngine.off("load", cachePollingLoadListenerRef.current); cachePollingLoadListenerRef.current = null; } howlerEngine.reload(); const onLoad = () => { howlerEngine.off("load", onLoad); cachePollingLoadListenerRef.current = null; howlerEngine.seek(targetTime); setCurrentTime(targetTime); howlerEngine.play(); podcastDebugLog("post-reload seek+play", { podcastId, episodeId, targetTime, howlerTime: howlerEngine.getCurrentTime(), actualTime: howlerEngine.getActualCurrentTime(), }); setIsBuffering(false); setTargetSeekPosition(null); setIsPlaying(true); }; cachePollingLoadListenerRef.current = onLoad; howlerEngine.on("load", onLoad); } else if (pollCount >= maxPolls) { if (cachePollingRef.current) { clearInterval(cachePollingRef.current); cachePollingRef.current = null; } console.warn("[HowlerAudioElement] Cache polling timeout"); setIsBuffering(false); setTargetSeekPosition(null); } } catch (error) { console.error("[HowlerAudioElement] Cache polling error:", error); } }, 2000); }, [setCurrentTime, setIsBuffering, setTargetSeekPosition, setIsPlaying] ); // Handle seeking via event emitter useEffect(() => { const handleSeek = async (time: number) => { const wasPlayingAtSeekStart = howlerEngine.isPlaying(); setCurrentTime(time); if (playbackType === "podcast" && currentPodcast) { if (seekCheckTimeoutRef.current) { clearTimeout(seekCheckTimeoutRef.current); } const [podcastId, episodeId] = currentPodcast.id.split(":"); try { const status = await api.getPodcastEpisodeCacheStatus( podcastId, episodeId ); if (status.cached) { podcastDebugLog("seek: cached=true, using reload+seek pattern", { time, podcastId, episodeId, }); if (seekReloadListenerRef.current) { howlerEngine.off("load", seekReloadListenerRef.current); seekReloadListenerRef.current = null; } seekReloadInProgressRef.current = true; howlerEngine.reload(); const onLoad = () => { howlerEngine.off("load", onLoad); seekReloadListenerRef.current = null; seekReloadInProgressRef.current = false; howlerEngine.seek(time); setCurrentTime(time); if (wasPlayingAtSeekStart) { howlerEngine.play(); setIsPlaying(true); } }; seekReloadListenerRef.current = onLoad; howlerEngine.on("load", onLoad); return; } } catch (e) { console.warn("[HowlerAudioElement] Could not check cache status:", e); } howlerEngine.seek(time); seekCheckTimeoutRef.current = setTimeout(() => { try { const actualPos = howlerEngine.getActualCurrentTime(); const seekFailed = time > 30 && actualPos < 30; podcastDebugLog("seek check", { time, actualPos, seekFailed, podcastId, episodeId, }); if (seekFailed) { howlerEngine.pause(); setIsBuffering(true); setTargetSeekPosition(time); setIsPlaying(false); startCachePolling(podcastId, episodeId, time); } } catch (e) { console.error("[HowlerAudioElement] Seek check error:", e); } }, 1000); return; } // For audiobooks and tracks, set seeking flag to prevent load effect interference isSeekingRef.current = true; howlerEngine.seek(time); // Reset seeking flag after a short delay to allow seek to complete setTimeout(() => { isSeekingRef.current = false; }, 100); }; const unsubscribe = audioSeekEmitter.subscribe(handleSeek); return unsubscribe; }, [setCurrentTime, playbackType, currentPodcast, setIsBuffering, setTargetSeekPosition, setIsPlaying, startCachePolling]); // Cleanup cache polling, seek timeout, and seek-reload listener on unmount useEffect(() => { return () => { if (cachePollingRef.current) { clearInterval(cachePollingRef.current); } if (seekCheckTimeoutRef.current) { clearTimeout(seekCheckTimeoutRef.current); } if (seekReloadListenerRef.current) { howlerEngine.off("load", seekReloadListenerRef.current); seekReloadListenerRef.current = null; } }; }, []); // Periodic progress saving for audiobooks and podcasts useEffect(() => { if (playbackType !== "audiobook" && playbackType !== "podcast") { if (progressSaveIntervalRef.current) { clearInterval(progressSaveIntervalRef.current); progressSaveIntervalRef.current = null; } return; } if (!isPlaying) { if (playbackType === "audiobook") { saveAudiobookProgress(); } else if (playbackType === "podcast") { savePodcastProgress(); } } if (isPlaying) { // Clear any existing interval before creating a new one if (progressSaveIntervalRef.current) { clearInterval(progressSaveIntervalRef.current); } progressSaveIntervalRef.current = setInterval(() => { if (playbackType === "audiobook") { saveAudiobookProgress(); } else if (playbackType === "podcast") { savePodcastProgress(); } }, 30000); } return () => { if (progressSaveIntervalRef.current) { clearInterval(progressSaveIntervalRef.current); progressSaveIntervalRef.current = null; } }; }, [playbackType, isPlaying, saveAudiobookProgress, savePodcastProgress]); // Cleanup on unmount useEffect(() => { return () => { howlerEngine.stop(); if (progressSaveIntervalRef.current) { clearInterval(progressSaveIntervalRef.current); } // Clean up all listener refs to prevent memory leaks if (loadListenerRef.current) { howlerEngine.off("load", loadListenerRef.current); loadListenerRef.current = null; } if (loadErrorListenerRef.current) { howlerEngine.off("loaderror", loadErrorListenerRef.current); loadErrorListenerRef.current = null; } if (cachePollingLoadListenerRef.current) { howlerEngine.off("load", cachePollingLoadListenerRef.current); cachePollingLoadListenerRef.current = null; } }; }, []); // This component doesn't render anything visible return null; });