Add AudioControl

This commit is contained in:
0x1eef 2023-10-12 05:10:24 -03:00
parent 4debbe218e
commit 04fb949a28
13 changed files with 220 additions and 142 deletions

View file

@ -1,3 +1,4 @@
-/node_modules/ -/node_modules/
-/tmp/ -/tmp/
-/build/ -/build/
-/.localgems/

View file

@ -12,10 +12,15 @@
scrollbar-gutter: stable; scrollbar-gutter: stable;
li.ayah { li.ayah {
span.surah-id.ayah-id { span.title {
display: block; display: flex;
align-items: center;
font-weight: 500; font-weight: 500;
} }
span.title .svg.sound-on, span.title .svg.sound-off {
cursor: pointer;
width: 24px;
}
p { p {
margin: 3px 0 10px 0 margin: 3px 0 10px 0
} }

View file

@ -6,7 +6,7 @@
} }
ul.body.stream li.ayah { ul.body.stream li.ayah {
span.surah-id.ayah-id { span.title {
color: $green; color: $green;
} }
p { } p { }
@ -17,17 +17,6 @@
color: $white; color: $white;
} }
.row svg g {
polygon {
stroke: $green;
stroke-width: 5;
}
path {
stroke: $green;
stroke-width: 5;
}
}
.row .shape.refresh { .row .shape.refresh {
background: unset; background: unset;
fill: $green; fill: $green;
@ -36,4 +25,11 @@
.row .loading div { .row .loading div {
background: $green; background: $green;
} }
.svg.sound-on, .svg.sound-off {
polygon {
fill: $green;
stroke-width: 2;
}
}
} }

View file

@ -7,8 +7,7 @@
ul.body.stream { ul.body.stream {
li.ayah { li.ayah {
/* TODO "surah.id", "ayah.id" */ span.title {
span.surah-id.ayah-id {
color: $gold; color: $gold;
} }
p { } p { }
@ -29,18 +28,14 @@
fill: $gold; fill: $gold;
} }
.row svg g {
polygon {
stroke: $gold;
stroke-width: 5;
}
path {
stroke: $gold;
stroke-width: 5;
}
}
.row .loading div { .row .loading div {
background: $gold; background: $gold;
} }
.svg.sound-on, .svg.sound-off {
polygon {
fill: $blue;
stroke-width: 2;
}
}
} }

View file

@ -34,7 +34,7 @@
data-surah-id="<%= context.surah.id %>"> data-surah-id="<%= context.surah.id %>">
</div> </div>
<%= inline_json("/i18n.json") %> <%= inline_json("/i18n.json") %>
<%= inline_json("/reciters.json") %> <%= inline_json("/recitations.json") %>
<script src="/js/loaders/surah-stream-loader.js"></script> <script src="/js/loaders/surah-stream-loader.js"></script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,56 @@
import url from "url";
import * as Quran from "lib/Quran";
import React, { useEffect, useMemo, useState } from "react";
import { SoundOnShape, SoundOffShape } from "components/Shape";
type Props = {
recitation: Quran.Recitation;
surah: Quran.Surah;
ayah: Quran.Ayah;
onStall?: (e?: Event) => void;
onPlay?: (e?: Event) => void;
onPlaying?: (e?: Event) => void;
onPause?: (e?: Event) => void;
onEnd?: (turnOffSound: () => void) => void;
};
export function AudioControl({
recitation,
surah,
ayah,
onPlay = () => null,
onPlaying = () => null,
onPause = () => null,
onStall = () => null,
onEnd = () => null,
}: Props) {
const [soundOn, setSoundOn] = useState<boolean>(false);
const audio = useMemo(() => new Audio(), []);
const turnOnSound = () => setSoundOn(true);
const turnOffSound = () => setSoundOn(false);
useEffect(() => {
audio.addEventListener("ended", () => onEnd(turnOffSound));
audio.addEventListener("stalled", onStall);
audio.addEventListener("waiting", onStall);
audio.addEventListener("play", onPlay);
audio.addEventListener("playing", onPlaying);
}, []);
useEffect(() => {
if (soundOn) {
audio.src = [url.format(recitation.url), surah.id, `${ayah.id}.mp3`].join("/");
audio.play();
} else {
audio.pause();
onPause();
}
}, [soundOn, ayah.id]);
return (
<>
{soundOn && <SoundOnShape onClick={turnOffSound} />}
{!soundOn && <SoundOffShape onClick={turnOnSound} />}
</>
);
}

View file

@ -23,7 +23,7 @@ export function PauseShape({ onClick }: Props) {
export function SoundOnShape({ onClick }: Props) { export function SoundOnShape({ onClick }: Props) {
return ( return (
<svg viewBox="0 0 100 100" className="svg sound-on" onClick={onClick}> <svg viewBox="0 -18 100 100" className="svg sound-on" onClick={onClick}>
<g> <g>
<polygon <polygon
fill="none" fill="none"
@ -54,7 +54,7 @@ export function SoundOnShape({ onClick }: Props) {
export function SoundOffShape({ onClick }: Props) { export function SoundOffShape({ onClick }: Props) {
return ( return (
<svg viewBox="0 0 100 100" className="svg sound-off" onClick={onClick}> <svg viewBox="0 -18 100 100" className="svg sound-off" onClick={onClick}>
<g> <g>
<polygon <polygon
fill="none" fill="none"

View file

@ -1,9 +1,11 @@
import * as Quran from "lib/Quran";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import * as Quran from "lib/Quran";
import { AudioControl } from "components/AudioControl";
import { formatNumber, TFunction } from "lib/i18n"; import { formatNumber, TFunction } from "lib/i18n";
import classNames from "classnames"; import classNames from "classnames";
interface Props { interface Props {
recitation: Quran.Recitation;
surah: Quran.Surah; surah: Quran.Surah;
stream: Quran.Ayat; stream: Quran.Ayat;
locale: Quran.Locale; locale: Quran.Locale;
@ -12,17 +14,35 @@ interface Props {
t: TFunction; t: TFunction;
} }
export function Stream({ surah, stream, locale, endOfStream, isPaused, t }: Props) { export function Stream({
recitation,
surah,
stream,
locale,
endOfStream,
isPaused,
t,
}: Props) {
const className = classNames("body", "stream"); const className = classNames("body", "stream");
const style: React.CSSProperties = const style: React.CSSProperties =
endOfStream || isPaused ? { overflowY: "auto" } : { overflowY: "hidden" }; endOfStream || isPaused ? { overflowY: "auto" } : { overflowY: "hidden" };
const ayat = stream.map((ayah: Quran.Ayah) => { const ayat = stream.map((ayah: Quran.Ayah) => {
return ( return (
<li key={ayah.id} className="ayah fade"> <li key={ayah.id} className="ayah fade">
<span className="surah-id ayah-id"> <span className="title">
{(isPaused || endOfStream) && (
<AudioControl
recitation={recitation}
surah={surah}
ayah={ayah}
onEnd={turnOffSound => turnOffSound()}
/>
)}
<span>
{t(locale, "surah")} {formatNumber(surah.id, locale)} {t(locale, "surah")} {formatNumber(surah.id, locale)}
{t(locale, "comma")} {t(locale, "ayah")} {formatNumber(ayah.id, locale)} {t(locale, "comma")} {t(locale, "ayah")} {formatNumber(ayah.id, locale)}
</span> </span>
</span>
<p>{ayah.text}</p> <p>{ayah.text}</p>
</li> </li>
); );

View file

@ -4,6 +4,17 @@ import { Surah } from "lib/Quran/Surah";
type Locale = "ar" | "en"; type Locale = "ar" | "en";
type Ayat = Ayah[]; type Ayat = Ayah[];
type Reciter = { id: string; name: string; nationality: string; url: string }; type Recitation = {
id: string;
author: {
name: string;
nationality: string;
};
url: {
protocol: string;
hostname: string;
pathname: string;
};
};
export { Surah, Ayah, Ayat, Reciter, Locale, JSON }; export { Surah, Ayah, Ayat, Recitation, Locale, JSON };

View file

@ -1,4 +1,5 @@
import postman, { item } from "postman"; import postman, { item } from "postman";
import url from "url";
import * as Quran from "lib/Quran"; import * as Quran from "lib/Quran";
(function () { (function () {
@ -7,8 +8,8 @@ import * as Quran from "lib/Quran";
const progressNumber: HTMLSpanElement = parent.querySelector(".percentage")!; const progressNumber: HTMLSpanElement = parent.querySelector(".percentage")!;
const inlineStyle: HTMLStyleElement = document.querySelector(".css.postman")!; const inlineStyle: HTMLStyleElement = document.querySelector(".css.postman")!;
const { locale, surahId } = document.querySelector<HTMLElement>(".root")!.dataset; const { locale, surahId } = document.querySelector<HTMLElement>(".root")!.dataset;
const reciters = JSON.parse( const recitations = JSON.parse(
document.querySelector<HTMLElement>(".json.reciters")!.innerText, document.querySelector<HTMLElement>(".json.recitations")!.innerText,
); );
postman( postman(
@ -20,10 +21,10 @@ import * as Quran from "lib/Quran";
item.font("Vazirmatn Regular", "url(/fonts/vazirmatn-regular.ttf)"), item.font("Vazirmatn Regular", "url(/fonts/vazirmatn-regular.ttf)"),
item.font("Roboto Mono Regular", "url(/fonts/roboto-mono-regular.ttf)"), item.font("Roboto Mono Regular", "url(/fonts/roboto-mono-regular.ttf)"),
item.json(`/${locale}/${surahId}/surah.json`, { className: "surah" }), item.json(`/${locale}/${surahId}/surah.json`, { className: "surah" }),
...reciters.map((reciter: Quran.Reciter) => { ...recitations.map((recitation: Quran.Recitation) => {
const { url: baseUrl } = reciter; const ts = [url.format(recitation.url), "time_slots", `${surahId}.json`].join("/");
return item.json(`${baseUrl}/time_slots/${surahId}.json`, { return item.json(ts, {
className: `reciter time-slots ${reciter.id}`, className: `recitation time-slots ${recitation.id}`,
}); });
}), }),
item.progress((percent: number) => { item.progress((percent: number) => {

View file

@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import classNames from "classnames"; import classNames from "classnames";
import { get as getCookie } from "es-cookie"; import { get as getCookie } from "es-cookie";
@ -7,50 +7,38 @@ import { Stream } from "components/Stream";
import { SelectOption } from "components/Select"; import { SelectOption } from "components/Select";
import { ThemeSelect } from "components/ThemeSelect"; import { ThemeSelect } from "components/ThemeSelect";
import { LanguageSelect } from "components/LanguageSelect"; import { LanguageSelect } from "components/LanguageSelect";
import { import { AudioControl } from "components/AudioControl";
PlayShape, import { PlayShape, PauseShape, RefreshShape, LoadingShape } from "components/Shape";
PauseShape,
SoundOnShape,
SoundOffShape,
RefreshShape,
LoadingShape,
} from "components/Shape";
import * as Quran from "lib/Quran"; import * as Quran from "lib/Quran";
import { i18n, TFunction } from "lib/i18n"; import { i18n, TFunction } from "lib/i18n";
interface Props { interface Props {
node: HTMLScriptElement; node: HTMLScriptElement;
reciters: Quran.Reciter[]; recitations: Quran.Recitation[];
locale: Quran.Locale; locale: Quran.Locale;
paused: boolean; paused: boolean;
t: TFunction; t: TFunction;
} }
const getTimeSlots = (reciter: Quran.Reciter) => { const getTimeSlots = (recitation: Quran.Recitation) => {
const selector = `script.reciter.time-slots.${reciter.id}`; const selector = `script.recitation.time-slots.${recitation.id}`;
const timeSlots: HTMLScriptElement = document.querySelector(selector)!; const timeSlots: HTMLScriptElement = document.querySelector(selector)!;
return timeSlots; return timeSlots;
}; };
const getAudioURL = (reciter: Quran.Reciter, surah: Quran.Surah, stream: Quran.Ayat) => { function SurahStream({ node, recitations, locale, paused, t }: Props) {
const { url: baseUrl } = reciter;
const ayah = stream[stream.length - 1];
return `${baseUrl}/${surah.id}/${ayah?.id}.mp3`;
};
function SurahStream({ node, reciters, locale, paused, t }: Props) {
const [stream, setStream] = useState<Quran.Ayat>([]); const [stream, setStream] = useState<Quran.Ayat>([]);
const [isPaused, setIsPaused] = useState<boolean>(paused); const [isPaused, setIsPaused] = useState<boolean>(paused);
const [soundOn, setSoundOn] = useState<boolean>(false); const [soundOn, setSoundOn] = useState<boolean>(false);
const [isStalled, setIsStalled] = useState<boolean>(false); const [isStalled, setIsStalled] = useState<boolean>(false);
const [endOfStream, setEndOfStream] = useState<boolean>(false); const [endOfStream, setEndOfStream] = useState<boolean>(false);
const [theme, setTheme] = useState(getCookie("theme") || "moon"); const [theme, setTheme] = useState(getCookie("theme") || "moon");
const [reciter] = useState<Quran.Reciter>(reciters[0]); const [recitation] = useState<Quran.Recitation>(recitations[0]);
const [surah] = useState<Quran.Surah>( const [surah] = useState<Quran.Surah>(
Quran.Surah.fromDOMNode(locale, node, getTimeSlots(reciter)), Quran.Surah.fromDOMNode(locale, node, getTimeSlots(recitation)),
); );
const readyToRender = stream.length > 0; const readyToRender = stream.length > 0;
const audioRef = useRef<HTMLAudioElement>(null); const ayah = stream[stream.length - 1];
const onLanguageChange = (o: SelectOption) => { const onLanguageChange = (o: SelectOption) => {
const locale = o.value; const locale = o.value;
const params = [["paused", isPaused ? "t" : null]]; const params = [["paused", isPaused ? "t" : null]];
@ -66,40 +54,6 @@ function SurahStream({ node, reciters, locale, paused, t }: Props) {
setStream([surah.ayat[0]]); setStream([surah.ayat[0]]);
}, [stream.length === 0]); }, [stream.length === 0]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
} else if (isPaused) {
audio.pause();
} else if (audio.paused) {
audio.play().catch(() => setSoundOn(false));
}
}, [soundOn, isPaused, stream.length]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onEnded = () => {
const src = getAudioURL(reciter, surah, stream);
if (src !== audio.src) {
audio.src = src;
}
};
const isStalled = () => setIsStalled(true);
const unStalled = () => setIsStalled(false);
audio.addEventListener("ended", onEnded);
audio.addEventListener("stalled", isStalled);
audio.addEventListener("waiting", isStalled);
audio.addEventListener("playing", unStalled);
return () => {
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("stalled", isStalled);
audio.removeEventListener("waiting", isStalled);
audio.removeEventListener("playing", unStalled);
};
}, [soundOn, stream.length]);
return ( return (
<div className={classNames("content", "theme", theme, locale)}> <div className={classNames("content", "theme", theme, locale)}>
<div className="header"> <div className="header">
@ -122,6 +76,7 @@ function SurahStream({ node, reciters, locale, paused, t }: Props) {
)} )}
{readyToRender && ( {readyToRender && (
<Stream <Stream
recitation={recitation}
surah={surah} surah={surah}
stream={stream} stream={stream}
locale={locale} locale={locale}
@ -137,11 +92,16 @@ function SurahStream({ node, reciters, locale, paused, t }: Props) {
{readyToRender && !isPaused && !endOfStream && ( {readyToRender && !isPaused && !endOfStream && (
<PauseShape onClick={() => setIsPaused(true)} /> <PauseShape onClick={() => setIsPaused(true)} />
)} )}
{readyToRender && !endOfStream && soundOn && ( {readyToRender && !endOfStream && (
<SoundOnShape onClick={() => setSoundOn(false)} /> <AudioControl
)} recitation={recitation}
{readyToRender && !endOfStream && !soundOn && ( surah={surah}
<SoundOffShape onClick={() => setSoundOn(true)} /> ayah={ayah}
onPlay={() => setSoundOn(true)}
onPause={() => setSoundOn(false)}
onPlaying={() => setIsStalled(false)}
onStall={() => setIsStalled(true)}
/>
)} )}
{readyToRender && !endOfStream && ( {readyToRender && !endOfStream && (
<Timer <Timer
@ -162,9 +122,6 @@ function SurahStream({ node, reciters, locale, paused, t }: Props) {
<LoadingShape /> <LoadingShape />
</div> </div>
)} )}
{readyToRender && soundOn && (
<audio ref={audioRef} src={getAudioURL(reciter, surah, stream)} />
)}
</div> </div>
); );
} }
@ -177,12 +134,18 @@ function SurahStream({ node, reciters, locale, paused, t }: Props) {
str !== null && ["1", "t", "true", "yes"].includes(str); str !== null && ["1", "t", "true", "yes"].includes(str);
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const paused = toBoolean(params.get("paused")); const paused = toBoolean(params.get("paused"));
const reciters = JSON.parse( const recitations = JSON.parse(
document.querySelector<HTMLElement>(".json.reciters")!.innerText, document.querySelector<HTMLElement>(".json.recitations")!.innerText,
); );
const t = i18n(document.querySelector<HTMLElement>(".json.i18n")!.innerText); const t = i18n(document.querySelector<HTMLElement>(".json.i18n")!.innerText);
ReactDOM.createRoot(root).render( ReactDOM.createRoot(root).render(
<SurahStream reciters={reciters} node={node} locale={locale} paused={paused} t={t} />, <SurahStream
recitations={recitations}
node={node}
locale={locale}
paused={paused}
t={t}
/>,
); );
})(); })();

62
src/recitations.json Normal file
View file

@ -0,0 +1,62 @@
[
{
"id": "mishari_alafasy",
"author": {
"name": "Mishari bin Rashed Alafasy",
"nationality": "KW"
},
"url": {
"protocol": "https",
"hostname": "al-quran.reflectslight.io",
"pathname": "/audio/alafasy"
}
},
{
"id": "aziz_alili",
"author": {
"name": "Aziz Alili",
"nationality": "MK"
},
"url": {
"protocol": "https",
"hostname": "al-quran.reflectslight.io",
"pathname": "/audio/aziz_alili"
}
},
{
"id": "abdullah_awad_al_juhany",
"author": {
"name": "Abdullah Awad Al Juhany",
"nationality": "SA"
},
"url": {
"protocol": "https",
"hostname": "al-quran.reflectslight.io",
"pathname": "/audio/abdullah_awad_al_juhany"
}
},
{
"id": "ahmad_bin_ali_al_ajmi",
"author": {
"name": "Ahmad bin Ali Al-Ajmi",
"nationality": "SA"
},
"url": {
"protocol": "https",
"hostname": "al-quran.reflectslight.io",
"pathname": "/audio/ahmad_bin_ali_al_ajmi"
}
},
{
"id": "sahl_yassin",
"author": {
"name": "Sahl Yassin",
"nationality": "SA"
},
"url": {
"protocol": "https",
"hostname": "al-quran.reflectslight.io",
"pathname": "/audio/sahl_yassin"
}
}
]

View file

@ -1,32 +0,0 @@
[
{
"id": "mishari_alafasy",
"name": "Mishari bin Rashed Alafasy",
"nationality": "KW",
"url": "https://al-quran.reflectslight.io/audio/alafasy/"
},
{
"id": "aziz_alili",
"name": "Aziz Alili",
"nationality": "MK",
"url": "https://al-quran.reflectslight.io/audio/aziz_alili/"
},
{
"id": "abdullah_awad_al_juhany",
"name": "Abdullah Awad Al Juhany",
"nationality": "SA",
"url": "https://al-quran.reflectslight.io/audio/abdullah_awad_al_juhany/"
},
{
"id": "ahmad_bin_ali_al_ajmi",
"name": "Ahmad bin Ali Al-Ajmi",
"nationality": "SA",
"url": "https://al-quran.reflectslight.io/audio/ahmad_bin_ali_al_ajmi/"
},
{
"id": "sahl_yassin",
"name": "Sahl Yassin",
"nationality": "SA",
"url": "https://al-quran.reflectslight.io/audio/sahl_yassin/"
}
]