diff --git a/components/CommunityAudio/AudioPreview.js b/components/CommunityAudio/AudioPreview.js new file mode 100644 index 00000000..d9d63ce6 --- /dev/null +++ b/components/CommunityAudio/AudioPreview.js @@ -0,0 +1,47 @@ +import AudioPause from 'public/icons/audioPause.svg' +import AudioPlay from 'public/icons/audioPlay.svg' +import Download from 'public/icons/download-audio.svg' +import Loading from 'public/icons/progress.svg' + +export default function AudioPreview({ + audioUrl, + onPlay, + onPause, + isPlaying, + audioName, + loading, +}) { + return ( +
+

{audioName || 'audio'}

+ + {loading && !audioUrl ? ( + + ) : ( + + )} + + +
+ ) +} diff --git a/components/CommunityAudio/BookListReader.js b/components/CommunityAudio/BookListReader.js new file mode 100644 index 00000000..8661b724 --- /dev/null +++ b/components/CommunityAudio/BookListReader.js @@ -0,0 +1,210 @@ +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useRouter } from 'next/router' + +import { Disclosure, Tab } from '@headlessui/react' +import { useTranslation } from 'next-i18next' + +import ChecksIcon from 'components/Project/BookList/ChecksIcon' +import Card from 'components/Project/Card' + +import { checkChapterVersesExist } from 'utils/helper' +import { useGetChaptersTranslate } from 'utils/hooks' + +import Down from 'public/icons/arrow-down.svg' + +function BookListReader({ books, setReference, reference, project, code }) { + const [currentBook, setCurrentBook] = useState(null) + const [createdOldTestamentBooks, createdNewTestamentBooks] = books + const { query, replace } = useRouter() + const { t } = useTranslation(['common', 'books']) + const refs = useRef([]) + const [chapters] = useGetChaptersTranslate({ code }) + + const scrollRefs = useRef({}) + const handleClose = (index) => { + refs.current.map((closeFunction, refIndex) => { + if (refIndex !== index) { + closeFunction() + } + }) + } + + const handleScroll = (bookid) => { + if (scrollRefs?.current && Object.keys(scrollRefs?.current).length) { + verseRef(scrollRefs.current[bookid]) + } + } + + const scrollTo = (currentBook, position) => { + let offset = 0 + const top = currentBook.offsetTop - 95 + switch (position) { + case 'center': + offset = currentBook.clientHeight / 2 - currentBook.parentNode.clientHeight / 2 + break + case 'top': + default: + break + } + + currentBook.parentNode.scrollTo({ left: 0, top: top + offset, behavior: 'smooth' }) + } + + const { tabs, defaultIndex } = useMemo(() => { + const index = [createdNewTestamentBooks, createdOldTestamentBooks]?.findIndex( + (list) => list?.find((el) => el.code === query.bookid) + ) + + const tabs = + project?.type === 'obs' ? ['OpenBibleStories'] : ['NewTestament', 'OldTestament'] + + const defaultIndex = index === -1 ? 0 : index + + return { defaultIndex, tabs } + }, [createdNewTestamentBooks, createdOldTestamentBooks, query.bookid, project?.type]) + + const verseRef = useCallback((node) => { + if (node !== null) { + setCurrentBook(node) + } + }, []) + + useEffect(() => { + if (currentBook) { + scrollTo(currentBook, 'top') + } + }, [currentBook]) + + useEffect(() => { + if (reference?.bookid) { + handleScroll(reference.bookid) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reference?.bookid]) + return ( + +
+ + + {tabs.map((tab) => ( + + {({ selected }) => ( +
+ {t(tab)} +
+ )} +
+ ))} +
+ + + {[ + ...(createdNewTestamentBooks !== undefined + ? [createdNewTestamentBooks] + : []), + ...(createdOldTestamentBooks !== undefined + ? [createdOldTestamentBooks] + : []), + ].map((list, idx) => ( + + {list?.map((book, index) => ( + (scrollRefs.current[book.code] = ref)} + > + {({ open, close }) => { + return ( + <> + (refs.current[index] = close)} + onClick={() => { + handleClose(index) + replace( + { + query: { ...query, bookid: book.code }, + }, + undefined, + { shallow: true } + ) + }} + className={`flex w-full items-center justify-between py-2 hover:opacity-70 ${ + !open ? 'border-b border-th-secondary-300' : '' + }`} + > +
+ +
{t('books:' + book.code)}
+
+ +
+ +
+ {[...Array(Object.keys(book.chapters).length).keys()] + .map((el) => el + 1) + .map((index) => ( + + ))} +
+
+ + ) + }} +
+ ))} +
+ ))} +
+
+
+
+ ) +} + +export default BookListReader diff --git a/components/CommunityAudio/CommunityAudio.js b/components/CommunityAudio/CommunityAudio.js new file mode 100644 index 00000000..9f0c0784 --- /dev/null +++ b/components/CommunityAudio/CommunityAudio.js @@ -0,0 +1,205 @@ +import { useEffect, useMemo, useState } from 'react' + +import Link from 'next/link' +import { useRouter } from 'next/router' + +import { useTranslation } from 'react-i18next' + +import ParticipantInfo from 'components/Project/ParticipantInfo' + +import BookListReader from './BookListReader' +import CommunityAudioRecorder from './CommunityAudioRecorder' +import Teleprompter from './Teleprompter' +import { useAudioRecorder } from './useAudio' + +import { useCurrentUser } from 'lib/UserContext' + +import { newTestamentList, oldTestamentList, usfmFileNames } from 'utils/config' +import { checkBookCodeExists, getVerseObjectsForBookAndChapter } from 'utils/helper' +import { + useAccess, + useGetBooks, + useGetChaptersTranslate, + useGetResource, + useProject, +} from 'utils/hooks' + +import Left from 'public/icons/left.svg' + +function CommunityAudio({ code, bookid }) { + const defaultValues = { + fontSize: 16, + textSpeed: 1, + } + const [fontSize, setFontSize] = useState(defaultValues.fontSize) + const [textSpeed, setTextSpeed] = useState(defaultValues.textSpeed) + const { t } = useTranslation(['books']) + const { lang } = useRouter() + + const { + isRecording, + isPaused, + audioUrl, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + loading, + } = useAudioRecorder() + + const { user } = useCurrentUser() + const [reference, setReference] = useState() + const [books] = useGetBooks({ + code, + }) + const [project] = useProject({ code }) + const [{ isCoordinatorAccess }] = useAccess({ + user_id: user?.id, + code: project?.code, + }) + const [chapters] = useGetChaptersTranslate({ code }) + + const resource = useMemo(() => { + if (reference?.checks) { + const resource = reference?.checks?.url?.split('/') + return { + owner: resource[3], + repo: resource[4], + commit: resource[6], + bookPath: + project?.type === 'obs' ? './content' : './' + usfmFileNames[reference?.bookid], + } + } + }, [project?.type, reference?.bookid, reference?.checks]) + + const { isLoading, data: verseObjects } = useGetResource({ + config: { + reference: { book: reference?.bookid, chapter: reference?.chapter }, + resource: resource || { owner: '', repo: '', commit: '', bookPath: '' }, + }, + url: `/api/git/${project?.type}`, + }) + + const verseObjectsToUse = + verseObjects || + getVerseObjectsForBookAndChapter(chapters, reference?.bookid, reference?.chapter) + + const { createdNewTestamentBooks, createdOldTestamentBooks } = useMemo(() => { + const createdNewTestamentBooks = books + ? books + .filter((book) => + Object.keys(newTestamentList).some( + (nt) => + nt === book.code && + (book?.level_checks || checkBookCodeExists(book.code, chapters)) + ) + ) + .sort((a, b) => { + return ( + Object.keys(newTestamentList).indexOf(a.code) - + Object.keys(newTestamentList).indexOf(b.code) + ) + }) + : [] + const createdOldTestamentBooks = books + ? books + .filter((book) => + Object.keys(oldTestamentList).some( + (ot) => + ot === book.code && + (book?.level_checks || checkBookCodeExists(book.code, chapters)) + ) + ) + .sort((a, b) => { + return ( + Object.keys(oldTestamentList).indexOf(a.code) - + Object.keys(oldTestamentList).indexOf(b.code) + ) + }) + : [] + + return { createdNewTestamentBooks, createdOldTestamentBooks } + }, [books, chapters]) + + useEffect(() => { + if (bookid && books) { + const book = books.find((book) => book.code === bookid) + setReference((prev) => ({ + ...prev, + chapter: 1, + bookid, + checks: book.level_checks, + })) + } + }, [bookid, books]) + + const audioName = + reference && + `${t(`books:${reference.bookid}_abbr`)}_${reference.chapter}_${lang === 'ru' ? `${new Date().getDate()}${new Date().getMonth()}` : `${new Date().getMonth()}${new Date().getDate()}`}${new Date().getFullYear().toString().slice(2)}` + + return ( +
+
+
+ +
+
+ +
+
+
+ +
+
+ + + +
+ +
+
+
+ ) +} + +export default CommunityAudio diff --git a/components/CommunityAudio/CommunityAudioRecorder.js b/components/CommunityAudio/CommunityAudioRecorder.js new file mode 100644 index 00000000..bd1b573e --- /dev/null +++ b/components/CommunityAudio/CommunityAudioRecorder.js @@ -0,0 +1,55 @@ +import AudioPreview from './AudioPreview' +import FontSizeSetting from './FontSizeSetting' +import PauseButton from './PauseButton' +import RecordButton from './RecordButton' +import SpeedSetting from './SpeedSetting' +import StopButton from './StopButton' +import { useAudioPreview } from './useAudio' + +function CommunityAudioRecorder({ + isRecording = false, + isPaused = false, + audioUrl, + audioName = '', + loading = false, + recordingMethods: { startRecording, stopRecording, pauseRecording, resumeRecording }, + textAdjustment: { fontSize = 16, setFontSize, textSpeed = 1, setTextSpeed }, +}) { + const { isPlaying, play, pause } = useAudioPreview(audioUrl || '') + + return ( +
+
+ + +
+
+ + + +
+
+ +
+
+ ) +} + +export default CommunityAudioRecorder diff --git a/components/CommunityAudio/FontSizeSetting.js b/components/CommunityAudio/FontSizeSetting.js new file mode 100644 index 00000000..f2dc5875 --- /dev/null +++ b/components/CommunityAudio/FontSizeSetting.js @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' + +import { useTranslation } from 'react-i18next' + +export default function FontSizeSetting({ fontSize, setFontSize }) { + const [isMounted, setIsMounted] = useState(false) + + const { t } = useTranslation(['common']) + + const minSize = 12 + const maxSize = 48 + + useEffect(() => { + setIsMounted(true) + }, []) + + if (!isMounted || !fontSize || !setFontSize) return null + + return ( +
+
+ + +
+
+

+ {fontSize} {t('FontSize')} +

+
+
+ ) +} diff --git a/components/CommunityAudio/PauseButton.js b/components/CommunityAudio/PauseButton.js new file mode 100644 index 00000000..58340929 --- /dev/null +++ b/components/CommunityAudio/PauseButton.js @@ -0,0 +1,15 @@ +import AudioPause from 'public/icons/audioPause.svg' + +export default function PauseButton({ isPaused, isRecording, onPause, onResume }) { + return ( + + ) +} diff --git a/components/CommunityAudio/RecordButton.js b/components/CommunityAudio/RecordButton.js new file mode 100644 index 00000000..e8336715 --- /dev/null +++ b/components/CommunityAudio/RecordButton.js @@ -0,0 +1,23 @@ +import Record from 'public/icons/audioRecord.svg' + +export default function RecordButton({ + isPaused, + isRecording, + startRecording, + resumeRecording, +}) { + return ( + + ) +} diff --git a/components/CommunityAudio/SpeedSetting.js b/components/CommunityAudio/SpeedSetting.js new file mode 100644 index 00000000..c01eec75 --- /dev/null +++ b/components/CommunityAudio/SpeedSetting.js @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' + +import { useTranslation } from 'react-i18next' + +import Minus from 'public/icons/audioMinus.svg' +import Plus from 'public/icons/audioPlus.svg' + +export default function SpeedSetting({ textSpeed, setTextSpeed }) { + const [isMounted, setIsMounted] = useState(false) + const { t } = useTranslation(['common']) + + const minSpeed = 1 + const maxSpeed = 15 + + useEffect(() => { + setIsMounted(true) + }, []) + + if (!isMounted || !textSpeed || !setTextSpeed) return null + + return ( +
+
+ + +
+
+

+ {textSpeed.toString().padStart(2, '0')}{' '} + {t('TextSpeed')} +

+
+
+ ) +} diff --git a/components/CommunityAudio/StopButton.js b/components/CommunityAudio/StopButton.js new file mode 100644 index 00000000..fc31a357 --- /dev/null +++ b/components/CommunityAudio/StopButton.js @@ -0,0 +1,15 @@ +import Stop from 'public/icons/audioStop.svg' + +export default function StopButton({ isRecording, stopRecording }) { + return ( + + ) +} diff --git a/components/CommunityAudio/Teleprompter.js b/components/CommunityAudio/Teleprompter.js new file mode 100644 index 00000000..68741cd4 --- /dev/null +++ b/components/CommunityAudio/Teleprompter.js @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useRouter } from 'next/router' + +import { useTranslation } from 'next-i18next' + +import Breadcrumbs from './TeleprompterBreadCrumbs' + +import { getVerseCount, getVerseCountOBS } from 'utils/helper' +import { useAccess, useGetBooks, useProject } from 'utils/hooks' + +function Teleprompter({ + verseObjects, + user, + reference, + isLoading, + isRecording, + isPaused, + stopRecording, + textProperties: { fontSize, textSpeed }, +}) { + const { + push, + query: { bookid, code }, + } = useRouter() + + const [{ isCoordinatorAccess }] = useAccess({ + user_id: user?.id, + code, + }) + const [project] = useProject({ code }) + const { t } = useTranslation(['common', 'books']) + const [books] = useGetBooks({ code }) + + const [isPlaying, setIsPlaying] = useState(false) + const containerRef = useRef(null) + const animationRef = useRef(null) + const scrollPositionRef = useRef(0) + + const verseCount = useMemo(() => { + if (project?.type === 'obs') { + return getVerseCountOBS(books, reference?.chapter) + } else { + return getVerseCount(books, bookid, reference?.chapter) + } + }, [books, project?.type, bookid, reference?.chapter]) + + const handleReset = useCallback(() => { + setIsPlaying(false) + scrollPositionRef.current = 0 + if (containerRef.current) { + containerRef.current.scrollTop = 0 + } + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + }, []) + + const LoadingSection = () => ( +
+
+ {[...Array(22).keys()].map((el) => ( +
+
+
+ ))} +
+ ) + + const NoContentSection = () => ( + <> +

{t('NoContent')}

+ {isCoordinatorAccess && ( +
+ push({ + pathname: `/projects/${project?.code}`, + query: { properties: bookid, levels: true }, + }) + } + > + {t('CheckLinkResource')} +
+ )} + + ) + + useEffect(() => { + if (isRecording && !isPaused) { + setIsPlaying(true) + } + if (isPaused) { + setIsPlaying(false) + } + + if (!isRecording) { + handleReset() + } + }, [fontSize, textSpeed, isRecording, isPaused, handleReset]) + + useEffect(() => { + let previousTimestamp = null + + const animate = (timestamp) => { + if (!previousTimestamp) previousTimestamp = timestamp + if (!containerRef.current) return + + const elapsed = timestamp - previousTimestamp + const speed = textSpeed * 0.004 + + scrollPositionRef.current += elapsed * speed + containerRef.current.scrollTop = scrollPositionRef.current + + previousTimestamp = timestamp + + if ( + containerRef.current.scrollTop >= + containerRef.current.scrollHeight - containerRef.current.clientHeight + ) { + stopRecording() + setIsPlaying(false) + handleReset() + return + } + + if (isPlaying) { + animationRef.current = requestAnimationFrame(animate) + } + } + + if (isPlaying) { + animationRef.current = requestAnimationFrame(animate) + } + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + }, [isPlaying, textSpeed, handleReset, stopRecording]) + + return ( +
+
+ +
+
+
+ +
+ {!isLoading ? ( + verseObjects ? ( +
+ {reference?.chapter && ( +

{t('Chapter') + ' ' + reference?.chapter}

+ )} + + {Array.from({ length: Math.min(verseCount + 1, 200) }).map((_, index) => { + const verseIndex = verseObjects?.verseObjects?.findIndex( + (verse) => parseInt(verse.verse) === index + ) + const text = + verseObjects?.verseObjects && verseIndex !== -1 + ? verseObjects.verseObjects[verseIndex].text + : ' ' + + return ( +
+

+ {index !== 0 && {index}} {text} +

+
+ ) + })} + {verseObjects?.verseObjects && ( +
+ {verseObjects.verseObjects.find((verse) => verse.verse === 200)?.text} +
+ )} +
+ ) : ( + + ) + ) : ( + + )} + +
+
+
+
+ ) +} + +export default Teleprompter diff --git a/components/CommunityAudio/TeleprompterBreadCrumbs.js b/components/CommunityAudio/TeleprompterBreadCrumbs.js new file mode 100644 index 00000000..67df3dac --- /dev/null +++ b/components/CommunityAudio/TeleprompterBreadCrumbs.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +import LeftArrow from 'public/icons/left.svg' + +export default function Breadcrumbs({ full, title, backLink }) { + return ( +
+
+ + + +

{title}

+
+
+ ) +} diff --git a/components/CommunityAudio/useAudio.js b/components/CommunityAudio/useAudio.js new file mode 100644 index 00000000..1475a8f7 --- /dev/null +++ b/components/CommunityAudio/useAudio.js @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Mp3Encoder } from 'lamejs' +import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' + +export function useAudioRecorder(bitrate = 128) { + const [isRecording, setIsRecording] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [audioUrl, setAudioUrl] = useState(null) + const [loading, setLoading] = useState(false) + + const mediaRecorder = useRef(null) + const audioChunks = useRef([]) + + const { t } = useTranslation(['audio']) + + const encodeToMp3 = useCallback( + async (chunks) => { + try { + const samplesAmount = 1152 + const maxPosValue = 32767 + const audioBlob = new Blob(chunks, { type: 'audio/webm' }) + const arrayBuffer = await audioBlob.arrayBuffer() + + const audioContext = new AudioContext() + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + + const mp3Data = [] + const encoder = new Mp3Encoder(1, audioBuffer.sampleRate, bitrate) + + const samples = audioBuffer.getChannelData(0) + const buffer = new Int16Array(samples.length) + + for (let i = 0; i < samples.length; i++) { + buffer[i] = samples[i] * maxPosValue + } + + let sampleIndex = 0 + while (sampleIndex < buffer.length) { + const sampleChunk = buffer.subarray(sampleIndex, sampleIndex + samplesAmount) + const mp3Chunk = encoder.encodeBuffer(sampleChunk) + if (mp3Chunk.length > 0) mp3Data.push(new Uint8Array(mp3Chunk)) + sampleIndex += samplesAmount + } + + const mp3Final = encoder.flush() + if (mp3Final.length > 0) mp3Data.push(new Uint8Array(mp3Final)) + + const mp3Blob = new Blob(mp3Data, { type: 'audio/mp3' }) + return URL.createObjectURL(mp3Blob) + } catch (error) { + console.error('Error encoding MP3:', error) + toast.error(t('audio:EncodingError'), { position: 'bottom-right' }) + return null + } + }, + [t, bitrate] + ) + + const startRecording = useCallback(async () => { + audioChunks.current = [] + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + mediaRecorder.current = new MediaRecorder(stream) + + mediaRecorder.current.ondataavailable = (event) => { + audioChunks.current.push(event.data) + } + + mediaRecorder.current.onstop = async () => { + const mp3Url = await encodeToMp3(audioChunks.current) + setAudioUrl(mp3Url) + } + + mediaRecorder.current.start() + setIsRecording(true) + setIsPaused(false) + } catch (error) { + toast.error(t('audio:TurnMicrophone'), { position: 'bottom-right' }) + console.error('Error accessing microphone:', error) + } + }, [t, encodeToMp3]) + + const stopRecording = useCallback(() => { + if (mediaRecorder.current) { + mediaRecorder.current.stop() + setIsRecording(false) + setIsPaused(false) + setLoading(true) + } + }, []) + + const pauseRecording = useCallback(() => { + if (mediaRecorder.current) { + mediaRecorder.current.pause() + setIsPaused(true) + } + }, []) + + const resumeRecording = useCallback(() => { + if (mediaRecorder.current) { + mediaRecorder.current.resume() + setIsPaused(false) + } + }, []) + + useEffect(() => { + if (audioUrl) setLoading(false) + }, [audioUrl]) + + return { + isRecording, + isPaused, + audioUrl, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + loading, + } +} + +export function useAudioPreview(audioUrl) { + const [isPlaying, setIsPlaying] = useState(false) + const [preview, setPreview] = useState(null) + + useEffect(() => { + if (audioUrl) { + const audio = new Audio(audioUrl) + setPreview(audio) + + audio.addEventListener('ended', () => setIsPlaying(false)) + + return () => audio.removeEventListener('ended', () => setIsPlaying(false)) + } + }, [audioUrl]) + + const play = useCallback(() => { + if (preview) { + preview.play() + setIsPlaying(true) + } + }, [preview]) + + const pause = useCallback(() => { + if (preview) { + preview.pause() + setIsPlaying(false) + } + }, [preview]) + + return { isPlaying, play, pause } +} diff --git a/components/Project/BookList/Testament.js b/components/Project/BookList/Testament.js index d2fd59b6..02b1246d 100644 --- a/components/Project/BookList/Testament.js +++ b/components/Project/BookList/Testament.js @@ -19,6 +19,7 @@ import DownloadIcon from 'public/icons/download.svg' import Elipsis from 'public/icons/elipsis.svg' import Gear from 'public/icons/gear.svg' import Play from 'public/icons/play.svg' +import Recorder from 'public/icons/recorder.svg' function Testament({ bookList, @@ -106,6 +107,20 @@ function Testament({ >
+ + + + {isCoordinatorAccess && isBookCreated && ( )} + {!isBookCreated && isCoordinatorAccess && (