791 lines
29 KiB
TypeScript
791 lines
29 KiB
TypeScript
"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<string, unknown>) {
|
|
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<string | null>(null);
|
|
const lastPlayingStateRef = useRef<boolean>(isPlaying);
|
|
const progressSaveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const lastProgressSaveRef = useRef<number>(0);
|
|
const isUserInitiatedRef = useRef<boolean>(false);
|
|
const isLoadingRef = useRef<boolean>(false);
|
|
const loadIdRef = useRef<number>(0);
|
|
const cachePollingRef = useRef<NodeJS.Timeout | null>(null);
|
|
const seekCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const cacheStatusPollingRef = useRef<NodeJS.Timeout | null>(null);
|
|
const seekReloadListenerRef = useRef<(() => void) | null>(null);
|
|
const seekReloadInProgressRef = useRef<boolean>(false);
|
|
// Track when a seek operation is in progress to prevent load effect from interfering
|
|
const isSeekingRef = useRef<boolean>(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;
|
|
});
|