Hoist 'audio' (HTMLAudioElement) into parent component

A new approach that gives the caller (parent) control over
the HTMLAudioElement. This will hopefully provide a framework
for a stable implementation that can handle multiple retries
over a slow network.
This commit is contained in:
0x1eef 2024-05-01 01:28:00 -03:00
parent 34bcbcdc19
commit d3f075e98e
4 changed files with 72 additions and 60 deletions

View file

@ -3,64 +3,80 @@ import type { Surah, TSurah, Ayah, TAyah } from "Quran";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { SoundOnIcon, SoundOffIcon } from "~/components/Icon"; import { SoundOnIcon, SoundOffIcon } from "~/components/Icon";
type TAudioStatus = "play" | "pause" | "wait" | "end";
type Props = { type Props = {
autoPlay?: boolean;
onStatusChange?: (s: TAudioStatus) => void;
audio: HTMLAudioElement;
surah: Surah<TSurah>; surah: Surah<TSurah>;
ayah: Ayah<TAyah>; ayah: Ayah<TAyah>;
onStall?: (e?: Event) => void;
onPlay?: (e?: Event) => void;
onPlaying?: (e?: Event) => void;
onPause?: (e?: Event) => void;
onEnd?: (turnOffSound: () => void) => void;
}; };
export function AudioControl({ export function AudioControl({
autoPlay = false,
onStatusChange = () => null,
audio,
surah, surah,
ayah, ayah,
onPlay = () => null,
onPlaying = () => null,
onPause = () => null,
onStall = () => null,
onEnd = () => null,
}: Props) { }: Props) {
const [soundOn, setSoundOn] = useState<boolean>(false); const [enabled, setEnabled] = useState<boolean>(false);
const audio = useMemo(() => new Audio(), []); const [audioStatus, setAudioStatus] = useState<TAudioStatus>(null);
const turnOnSound = () => setSoundOn(true); const play = (audio: HTMLAudioElement) => audio.play().catch(() => null);
const turnOffSound = () => setSoundOn(false); const pause = (audio: HTMLAudioElement) => audio.pause();
const recover = () => {
if (!soundOn) return;
onStall();
audio.play().catch(() => setTimeout(recover, 50));
};
useEffect(() => { useEffect(() => {
audio.addEventListener("ended", () => onEnd(turnOffSound)); if (audio) {
audio.addEventListener("stalled", recover); audio.src = [
audio.addEventListener("waiting", onStall); "https://al-quran.reflectslight.io",
audio.addEventListener("play", onPlay); "audio",
audio.addEventListener("playing", onPlaying); "alafasy",
}, []); surah.id,
`${ayah.id}.mp3`,
useEffect(() => { ].join("/");
const src = [ if (autoPlay) {
"https://al-quran.reflectslight.io", play(audio);
"audio", }
"alafasy",
surah.id,
`${ayah.id}.mp3`,
].join("/");
if (soundOn) {
audio.src = src;
audio.play();
} else {
audio.pause();
onPause();
} }
}, [soundOn, ayah.id]); }, [ayah.id]);
useEffect(() => {
if (audioStatus === "end") {
setEnabled(false);
}
onStatusChange(audioStatus);
}, [audioStatus]);
useEffect(() => {
if (!audio) return;
const onPlay = () => setAudioStatus("play");
const onPause = () => setAudioStatus("pause");
const onEnd = () => setAudioStatus("end");
const onWait = () => [setAudioStatus("wait"), play(audio)];
audio.addEventListener("play", onPlay);
audio.addEventListener("playing", onPlay);
audio.addEventListener("pause", onPause);
audio.addEventListener("ended", onEnd);
audio.addEventListener("stalled", onWait);
audio.addEventListener("waiting", onWait);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("playing", onPlay);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("ended", onEnd);
audio.removeEventListener("stalled", onWait);
audio.removeEventListener("waiting", onWait);
};
}, [ayah.id]);
return ( return (
<> <>
{soundOn && <SoundOnIcon onClick={turnOffSound} />} {enabled && (
{!soundOn && <SoundOffIcon onClick={turnOnSound} />} <SoundOnIcon onClick={() => [setEnabled(false), pause(audio)]} />
)}
{!enabled && (
<SoundOffIcon onClick={() => [setEnabled(true), play(audio)]} />
)}
</> </>
); );
} }

View file

@ -45,11 +45,7 @@ export function Stream({
className={classNames("flex h-8 items-center", { "mb-2": rtl })} className={classNames("flex h-8 items-center", { "mb-2": rtl })}
> >
{(isPaused || endOfStream) && ( {(isPaused || endOfStream) && (
<AudioControl <AudioControl audio={new Audio()} surah={surah} ayah={ayah} />
surah={surah}
ayah={ayah}
onEnd={turnOffSound => turnOffSound()}
/>
)} )}
<span> <span>
{t(locale, "surah")} {formatNumber(surah.id, locale)} {t(locale, "surah")} {formatNumber(surah.id, locale)}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useMemo, useRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Surah, TSurah, TAyat, TLocale } from "Quran"; import { Surah, TSurah, TAyat, TLocale } from "Quran";
import { useTheme } from "~/hooks/useTheme"; import { useTheme } from "~/hooks/useTheme";
@ -23,14 +23,14 @@ type Props = {
export function SurahStream({ surah, locale, t }: Props) { export function SurahStream({ surah, locale, t }: Props) {
const [stream, setStream] = useState<TAyat>([]); const [stream, setStream] = useState<TAyat>([]);
const [isPaused, setIsPaused] = useState<boolean>(false); const [isPaused, setIsPaused] = useState<boolean>(false);
const [soundOn, setSoundOn] = useState<boolean>(false); const [audioStatus, setAudioStatus] = useState<TAudioStatus>(null);
const [isStalled, setIsStalled] = useState<boolean>(false);
const [endOfStream, setEndOfStream] = useState<boolean>(false); const [endOfStream, setEndOfStream] = useState<boolean>(false);
const [theme, setTheme] = useTheme(); const [theme, setTheme] = useTheme();
const readyToRender = stream.length > 0; const readyToRender = stream.length > 0;
const ayah = stream[stream.length - 1]; const ayah = stream[stream.length - 1];
const [ms, setMs] = useState<number | null>(null); const [ms, setMs] = useState<number | null>(null);
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
const audio = useMemo(() => new Audio(), []);
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
@ -84,16 +84,15 @@ export function SurahStream({ surah, locale, t }: Props) {
{readyToRender && !endOfStream && ( {readyToRender && !endOfStream && (
<div className="sound-box flex w-14 justify-end"> <div className="sound-box flex w-14 justify-end">
<AudioControl <AudioControl
autoPlay={true}
audio={audio}
surah={surah} surah={surah}
ayah={ayah} ayah={ayah}
onPlay={() => setSoundOn(true)} onStatusChange={s => setAudioStatus(s)}
onPause={() => setSoundOn(false)}
onPlaying={() => setIsStalled(false)}
onStall={() => setIsStalled(true)}
/> />
</div> </div>
)} )}
{readyToRender && !endOfStream && !isStalled && ( {readyToRender && !endOfStream && audioStatus !== "wait" && (
<Timer <Timer
surah={surah} surah={surah}
setStream={setStream} setStream={setStream}
@ -101,13 +100,12 @@ export function SurahStream({ surah, locale, t }: Props) {
stream={stream} stream={stream}
locale={locale} locale={locale}
isPaused={isPaused} isPaused={isPaused}
soundOn={soundOn} isStalled={audioStatus === "wait"}
isStalled={isStalled}
ms={ms} ms={ms}
setMs={setMs} setMs={setMs}
/> />
)} )}
{readyToRender && soundOn && isStalled && <StalledIcon />} {readyToRender && audioStatus === "wait" && <StalledIcon />}
{readyToRender && endOfStream && ( {readyToRender && endOfStream && (
<RefreshIcon onClick={() => setStream([])} /> <RefreshIcon onClick={() => setStream([])} />
)} )}

View file

@ -7,8 +7,10 @@ import { SurahIndex } from "~/components/SurahIndex";
(function () { (function () {
const root: HTMLElement = document.querySelector(".root")!; const root: HTMLElement = document.querySelector(".root")!;
const locale = root.getAttribute("data-locale") as TLocale; const locale = root.getAttribute("data-locale") as TLocale;
const surahs: Surah<TSurah>[] = require("@json/surahs").map((e: TSurah) => new Surah(e));
const t = T(require("@json/t.json")); const t = T(require("@json/t.json"));
const surahs: Surah<TSurah>[] = require("@json/surahs").map(
(e: TSurah) => new Surah(e),
);
ReactDOM.createRoot(root).render( ReactDOM.createRoot(root).render(
<SurahIndex locale={locale} surahs={surahs} t={t} />, <SurahIndex locale={locale} surahs={surahs} t={t} />,
); );