Files
lidify/frontend/components/player/HowlerAudioElement.tsx
2025-12-25 18:58:06 -06:00

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;
});