Skip to content

Commit

Permalink
feat: hebrew audio in reading mode (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
arrocke authored Oct 25, 2024
1 parent 96f6e23 commit f1134a7
Show file tree
Hide file tree
Showing 19 changed files with 41,261 additions and 1,135 deletions.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,29 @@ Both the database and dev server can be run from a docker container using the co
docker compose up
```

If you have added new node_modules, you need to run:
```bash
docker compose up --build
```

To reset the database and rebuild from scratch, run:
```bash
docker compose down
docker volume rm platform_db-data
docker compose up
```

To get a shell in a container run:
```bash
docker exec -it [platform-db-1|platform-server-1] [bash|sh]
```

## Database

### Set up database

If you are running these from the db container, you can use an absolute URL (`/db/data.dump`) because the db directory is mounted at the root of the container.

Restore the database schema:
```bash
psql DATABASE_URL db/schema.sql
Expand All @@ -34,7 +47,7 @@ pg_dump --no-owner --schema-only DATABASE_URL > db/schema.sql

Export the latest database seed data:
```bash
pg_dump -Fc --data-only DATABASE_URL > db/data.dump
pg_dump -Fc --data-only DATABASE_URL > db/data.dump
```

### Migrations
Expand Down
159 changes: 159 additions & 0 deletions app/[locale]/(authenticated)/read/[code]/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"use client";

import Button from "@/app/components/Button";
import ComboboxInput from "@/app/components/ComboboxInput";
import { Icon } from "@/app/components/Icon";
import { bookKeys } from "@/data/book-keys";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";

interface VerseAudioTiming {
verseId: string
start: number
}

export interface AudioPlayerProps {
className?: string
chapterId: string
onVerseChange?(verseId: string | undefined): void
}

const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5]
const PREV_THRESHOLD = 1.5 // The time threshold after which clicking previous verse, restarts the current verse.

export default function AudioPlayer({ className = '', chapterId, onVerseChange }: AudioPlayerProps) {
const t = useTranslations('AudioPlayer')

const bookId = parseInt(chapterId.slice(0, 2)) || 1;
const chapter = parseInt(chapterId.slice(2, 5)) || 1;

const [isPlaying, setPlaying] = useState(false)
const [speaker, setSpeaker] = useState('HEB')
const [speed, setSpeed] = useState(2)

const { data } = useSWR(['chapter-audio', speaker, chapterId], async ([,speaker, chapterId]) => {
const response = await fetch(`/api/audio/${speaker}/${chapterId}`)
return await response.json() as Promise<VerseAudioTiming[]>
})

const canPlay = !!data

const audio = useRef<HTMLAudioElement>(null)

function reset() {
const el = audio.current
if (!el) return

el.currentTime = data?.[0].start ?? 0
}

function prevVerse() {
const el = audio.current
if (!el || !data) return

const currentIndex = data.reduce(
(last, v, i) => v.start > el.currentTime ? last : i,
-1
)
if (currentIndex < 0) return

if (el.currentTime - data[currentIndex].start < PREV_THRESHOLD) {
el.currentTime = data[currentIndex - 1].start
} else if (currentIndex > 0) {
el.currentTime = data[currentIndex].start
}
}

function nextVerse() {
const el = audio.current
if (!el || !data) return

const currentIndex = data.reduce(
(last, v, i) => v.start > el.currentTime ? last : i,
-1
)
if (currentIndex < 0) return

if (data[currentIndex + 1]) {
el.currentTime = data[currentIndex + 1].start
}
}

function toggleSpeed() {
const newIndex = (speed + 1) % SPEEDS.length
setSpeed(newIndex)

const el = audio.current
if (!el) return

el.playbackRate = SPEEDS[newIndex]
}

const src = `https://gbt-audio.s3.amazonaws.com/${speaker}/${bookKeys[bookId - 1]}/${chapter.toString().padStart(3, '0')}.mp3`
const progressInterval = useRef<NodeJS.Timeout>()
const lastVerseId = useRef<string>()
useEffect(() => {
if (isPlaying && data) {
audio.current?.play()
progressInterval.current = setInterval(() => {
const el = audio.current
if (!el) return

const verse = data.reduce<VerseAudioTiming | undefined>(
(last, v) => v.start > el.currentTime ? last : v,
undefined
)

if (lastVerseId.current !== verse?.verseId) {
onVerseChange?.(verse?.verseId)
lastVerseId.current = verse?.verseId
}
}, 500)
return () => clearInterval(progressInterval.current)
} else {
audio.current?.pause()
if (lastVerseId.current !== undefined) {
onVerseChange?.(undefined)
lastVerseId.current = undefined
}
}
}, [isPlaying, src, data])

useEffect(() => {
const el = audio.current
if (!el) return

el.currentTime = data?.[0].start ?? 0
}, [data])

return <div className={`${className} flex items-center`}>
<audio ref={audio} src={src} />
<Button variant="tertiary" className="w-8" disabled={!canPlay} onClick={prevVerse}>
<Icon icon='caret-left' size="lg" />
<span className="sr-only">{t('prev')}</span>
</Button>
<Button variant="tertiary" className="w-8" disabled={!canPlay} onClick={reset}>
<Icon icon="arrow-rotate-left" />
<span className="sr-only">{t('restart')}</span>
</Button>
<Button variant="tertiary" className="w-8" disabled={!canPlay} onClick={() => setPlaying(playing => !playing)}>
<Icon icon={isPlaying ? 'pause' : 'play'} />
<span className="sr-only">{t(isPlaying ? 'pause' : 'play')}</span>
</Button>
<Button variant="tertiary" className="w-8" disabled={!canPlay} onClick={nextVerse}>
<Icon icon='caret-right' size="lg" />
<span className="sr-only">{t('next')}</span>
</Button>
<Button variant="tertiary" className="w-10 text-sm !justify-start !ps-1" disabled={!canPlay} onClick={toggleSpeed}>
<span className="sr-only">{t('speed')}</span>
{SPEEDS[speed]}x
</Button>
<ComboboxInput
className="ms-2 w-56"
items={[{ label: 'Abraham Schmueloff', value: 'HEB' }, { label: 'Rabbi Dan Beeri', value: 'RDB' }]}
value={speaker} onChange={setSpeaker}
aria-label={t('speaker')}
/>
</div>
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
"use client";

import { useParams } from "next/navigation";
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { createContext, ReactNode, useContext, useState } from "react";

interface ReadingClientState {
textSize: number
audioVerse?: string
setTextSize(textSize: number): void
setAudioVerse(verseId?: string): void
}

const ReadingClientStateContext = createContext<ReadingClientState | null>(null)

export function ReadingClientStateProvider({ children }: { children: ReactNode }) {
const [textSize, setTextSize] = useState(3)
const [audioVerse, setAudioVerse] = useState<string>()

return <ReadingClientStateContext.Provider value={{ textSize, setTextSize }}>
return <ReadingClientStateContext.Provider value={{ textSize, audioVerse, setTextSize, setAudioVerse }}>
{children}
</ReadingClientStateContext.Provider>
}
Expand Down
21 changes: 12 additions & 9 deletions app/[locale]/(authenticated)/read/[code]/ReadingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { Icon } from "@/app/components/Icon";
import TextInput from "@/app/components/TextInput";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { bookFirstChapterId, bookLastChapterId, decrementChapterId, incrementChapterId } from "@/app/verse-utils";
import { useReadingClientState } from "./ReadingClientState";
import SliderInput from "@/app/components/SliderInput";
import { changeChapter } from "./actions";
import AudioPlayer from "./AudioPlayer";

export interface TranslationToolbarProps {
languages: { name: string; code: string }[];
Expand All @@ -23,15 +24,15 @@ export default function ReadingToolbar({
const t = useTranslations("ReadingToolbar");
const { chapterId, code } = useParams<{ locale: string, code: string, chapterId: string }>()
const router = useRouter()
const { textSize, setAudioVerse, setTextSize } = useReadingClientState()

const bookId = parseInt(chapterId.slice(0, 2)) || 1
const chapter = parseInt(chapterId.slice(2, 5)) || 1

const [reference, setReference] = useState('')
useEffect(() => {
if (!chapterId) return setReference('')

const bookId = parseInt(chapterId.slice(0, 2)) || 1
const chapter = parseInt(chapterId.slice(2, 5)) || 1
setReference(t('verse_reference', { bookId, chapter }))
}, [chapterId, t])
}, [bookId, chapter])

useEffect(() => {
if (!chapterId) return
Expand All @@ -54,8 +55,6 @@ export default function ReadingToolbar({
return () => window.removeEventListener('keydown', keydownCallback);
}, [router, chapterId]);

const { textSize, setTextSize } = useReadingClientState()

return (
<div>
<div className="flex items-center shadow-md dark:shadow-none dark:border-b dark:border-gray-500 px-6 md:px-8 py-4">
Expand Down Expand Up @@ -104,7 +103,7 @@ export default function ReadingToolbar({
autoComplete="off"
/>
</div>
<div className="me-2">
<div className="me-16">
<FormLabel htmlFor="text-size">{t("text_size")}</FormLabel>
<div className="h-[34px] flex items-center">
<SliderInput
Expand All @@ -118,6 +117,10 @@ export default function ReadingToolbar({
/>
</div>
</div>
<div className="me-2">
<FormLabel >{t("audio")}</FormLabel>
<AudioPlayer className="h-[34px]" chapterId={chapterId} onVerseChange={setAudioVerse} />
</div>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface VerseWord {
}

interface Verse {
id: string
number: number
words: VerseWord[]
}
Expand Down Expand Up @@ -68,7 +69,7 @@ export default function ReadingView({ chapterId, language, verses }: ReadingView

const sidebarRef = useRef<ReadingSidebarRef>(null)

const { textSize } = useReadingClientState()
const { textSize, audioVerse } = useReadingClientState()

return <>
<div className="flex flex-col flex-grow lg:justify-center w-full min-h-0 lg:flex-row">
Expand Down Expand Up @@ -114,7 +115,15 @@ export default function ReadingView({ chapterId, language, verses }: ReadingView
{verse.number}&nbsp;
</span>
);
return words;
return <span
key={verse.id}
className={`
rounded
${audioVerse === verse.id ? 'bg-green-200 dark:bg-gray-600' : ''}
`}
>
{words}
</span>;
})}
</div>
{showSidebar && (
Expand Down
Loading

0 comments on commit f1134a7

Please sign in to comment.