300 lines
9.2 KiB
TypeScript
300 lines
9.2 KiB
TypeScript
import { createContext, useEffect, useRef, useState } from "react";
|
||
|
||
export interface PlayerContextValue {
|
||
playAudio: (
|
||
artist: string,
|
||
title: string,
|
||
audio: string | Blob | File,
|
||
messageId?: string | null
|
||
) => void;
|
||
playing: boolean;
|
||
pause: () => void;
|
||
resume: () => void;
|
||
stop: () => void;
|
||
setDuration: (duration: number) => void;
|
||
duration: number;
|
||
totalDuration: number;
|
||
currentMessageId: string | null;
|
||
lastMessageId: string | null;
|
||
lastError: string | null;
|
||
}
|
||
|
||
export const PlayerContext = createContext<PlayerContextValue | null>(null);
|
||
|
||
interface PlayerProviderProps {
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
export function PlayerProvider(props: PlayerProviderProps) {
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
|
||
const objectUrlRef = useRef<string | null>(null);
|
||
const rafTimeUpdateRef = useRef<number | null>(null);
|
||
|
||
const isLoadingRef = useRef(false);
|
||
const isSeekingRef = useRef(false);
|
||
|
||
const durationRef = useRef(0);
|
||
const totalDurationRef = useRef(0);
|
||
|
||
const isPlayingRef = useRef(false);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [duration, setDurationState] = useState(0);
|
||
const [totalDuration, setTotalDuration] = useState(0);
|
||
const [lastError, setLastError] = useState<string | null>(null);
|
||
|
||
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
|
||
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
|
||
|
||
const commitPlaying = (next: boolean) => {
|
||
if (isPlayingRef.current === next) return;
|
||
isPlayingRef.current = next;
|
||
setIsPlaying(next);
|
||
};
|
||
|
||
const commitDuration = (next: number) => {
|
||
const safe = Number.isFinite(next) && next >= 0 ? next : 0;
|
||
if (Math.abs(safe - durationRef.current) < 0.033) return;
|
||
durationRef.current = safe;
|
||
setDurationState(safe);
|
||
};
|
||
|
||
const commitTotalDuration = (next: number) => {
|
||
const safe = Number.isFinite(next) && next > 0 ? next : 0;
|
||
if (Math.abs(safe - totalDurationRef.current) < 0.05) return;
|
||
totalDurationRef.current = safe;
|
||
setTotalDuration(safe);
|
||
};
|
||
|
||
const decodeMediaError = (err: MediaError | null) => {
|
||
if (!err) return "Unknown media error";
|
||
switch (err.code) {
|
||
case MediaError.MEDIA_ERR_ABORTED:
|
||
return "Playback aborted";
|
||
case MediaError.MEDIA_ERR_NETWORK:
|
||
return "Network error while loading audio";
|
||
case MediaError.MEDIA_ERR_DECODE:
|
||
return "Audio decode error";
|
||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||
return "Audio source is not supported";
|
||
default:
|
||
return `Unknown media error (${err.code})`;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
const onPlay = () => {
|
||
if (isLoadingRef.current) return;
|
||
commitPlaying(true);
|
||
};
|
||
|
||
const onPause = () => {
|
||
if (isLoadingRef.current) return;
|
||
commitPlaying(false);
|
||
};
|
||
|
||
const onEnded = () => {
|
||
commitPlaying(false);
|
||
durationRef.current = 0;
|
||
setDurationState(0);
|
||
setCurrentMessageId(null);
|
||
};
|
||
|
||
const onTimeUpdate = () => {
|
||
if (isLoadingRef.current) return;
|
||
if (isSeekingRef.current) return;
|
||
if (rafTimeUpdateRef.current != null) return;
|
||
|
||
rafTimeUpdateRef.current = requestAnimationFrame(() => {
|
||
rafTimeUpdateRef.current = null;
|
||
if (!isLoadingRef.current && !isSeekingRef.current) {
|
||
commitDuration(audio.currentTime || 0);
|
||
}
|
||
});
|
||
};
|
||
|
||
const onLoadedMetadata = () => commitTotalDuration(audio.duration);
|
||
const onDurationChange = () => commitTotalDuration(audio.duration);
|
||
|
||
const onSeeked = () => {
|
||
if (isSeekingRef.current) {
|
||
isSeekingRef.current = false;
|
||
if (!isLoadingRef.current) commitDuration(audio.currentTime || 0);
|
||
return;
|
||
}
|
||
if (isLoadingRef.current) return;
|
||
commitDuration(audio.currentTime || 0);
|
||
};
|
||
|
||
const onCanPlay = () => {
|
||
if (isLoadingRef.current) isLoadingRef.current = false;
|
||
};
|
||
|
||
const onError = (_e: Event) => {
|
||
const message = decodeMediaError(audio.error);
|
||
setLastError(message);
|
||
|
||
console.error("Audio playback error", {
|
||
message,
|
||
mediaError: audio.error,
|
||
currentSrc: audio.currentSrc,
|
||
readyState: audio.readyState,
|
||
networkState: audio.networkState,
|
||
});
|
||
};
|
||
|
||
audio.addEventListener("play", onPlay);
|
||
audio.addEventListener("pause", onPause);
|
||
audio.addEventListener("ended", onEnded);
|
||
audio.addEventListener("timeupdate", onTimeUpdate);
|
||
audio.addEventListener("loadedmetadata", onLoadedMetadata);
|
||
audio.addEventListener("durationchange", onDurationChange);
|
||
audio.addEventListener("seeked", onSeeked);
|
||
audio.addEventListener("canplay", onCanPlay);
|
||
audio.addEventListener("error", onError);
|
||
|
||
return () => {
|
||
audio.removeEventListener("play", onPlay);
|
||
audio.removeEventListener("pause", onPause);
|
||
audio.removeEventListener("ended", onEnded);
|
||
audio.removeEventListener("timeupdate", onTimeUpdate);
|
||
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||
audio.removeEventListener("durationchange", onDurationChange);
|
||
audio.removeEventListener("seeked", onSeeked);
|
||
audio.removeEventListener("canplay", onCanPlay);
|
||
audio.removeEventListener("error", onError);
|
||
|
||
if (rafTimeUpdateRef.current != null) {
|
||
cancelAnimationFrame(rafTimeUpdateRef.current);
|
||
rafTimeUpdateRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (objectUrlRef.current) {
|
||
URL.revokeObjectURL(objectUrlRef.current);
|
||
objectUrlRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const playAudio = (
|
||
artist: string,
|
||
title: string,
|
||
audio: string | Blob | File,
|
||
messageId?: string | null
|
||
) => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
|
||
// чтобы не было warning о неиспользуемых args при строгих правилах
|
||
void artist;
|
||
void title;
|
||
|
||
setLastError(null);
|
||
|
||
if (objectUrlRef.current) {
|
||
URL.revokeObjectURL(objectUrlRef.current);
|
||
objectUrlRef.current = null;
|
||
}
|
||
|
||
const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio);
|
||
if (typeof audio !== "string") {
|
||
objectUrlRef.current = audioSrc;
|
||
}
|
||
|
||
isLoadingRef.current = true;
|
||
isSeekingRef.current = false;
|
||
|
||
el.src = audioSrc;
|
||
durationRef.current = 0;
|
||
|
||
const msgId = messageId ?? null;
|
||
setCurrentMessageId(msgId);
|
||
if (msgId) setLastMessageId(msgId);
|
||
|
||
isPlayingRef.current = true;
|
||
setIsPlaying(true);
|
||
|
||
const prevDuration = durationRef.current;
|
||
requestAnimationFrame(() => {
|
||
if (durationRef.current === prevDuration) {
|
||
setDurationState(0);
|
||
}
|
||
});
|
||
|
||
void el.play().catch((err) => {
|
||
isLoadingRef.current = false;
|
||
commitPlaying(false);
|
||
setLastError(err instanceof Error ? err.message : "play() failed");
|
||
});
|
||
};
|
||
|
||
const pause = () => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
el.pause();
|
||
};
|
||
|
||
const resume = () => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
|
||
commitPlaying(true);
|
||
|
||
void el.play().catch((err) => {
|
||
commitPlaying(false);
|
||
setLastError(err instanceof Error ? err.message : "resume() failed");
|
||
});
|
||
};
|
||
|
||
const stop = () => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
|
||
isLoadingRef.current = true;
|
||
el.pause();
|
||
el.currentTime = 0;
|
||
isLoadingRef.current = false;
|
||
|
||
durationRef.current = 0;
|
||
setDurationState(0);
|
||
commitPlaying(false);
|
||
setCurrentMessageId(null);
|
||
};
|
||
|
||
const setDuration = (sec: number) => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
|
||
isSeekingRef.current = true;
|
||
el.currentTime = Math.max(0, sec);
|
||
commitDuration(el.currentTime || 0);
|
||
};
|
||
|
||
return (
|
||
<PlayerContext.Provider
|
||
value={{
|
||
playAudio,
|
||
playing: isPlaying,
|
||
pause,
|
||
resume,
|
||
stop,
|
||
setDuration,
|
||
duration,
|
||
totalDuration,
|
||
currentMessageId,
|
||
lastMessageId,
|
||
lastError,
|
||
}}
|
||
>
|
||
{props.children}
|
||
<audio ref={audioRef} />
|
||
</PlayerContext.Provider>
|
||
);
|
||
} |