Initial release v1.0.0
This commit is contained in:
790
frontend/components/player/HowlerAudioElement.tsx
Normal file
790
frontend/components/player/HowlerAudioElement.tsx
Normal file
@@ -0,0 +1,790 @@
|
||||
"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;
|
||||
});
|
||||
Reference in New Issue
Block a user