diff --git a/convert/convertConfig.ts b/convert/convertConfig.ts index cb37da3a4..67369369c 100644 --- a/convert/convertConfig.ts +++ b/convert/convertConfig.ts @@ -1876,10 +1876,8 @@ function filterFeaturesNotReady(data: ScriptureConfig | DictionaryConfig) { data.mainFeatures['settings-keep-screen-on'] = false; data.mainFeatures['settings-share-usage-data'] = false; - // Offline audio is not done - data.mainFeatures['settings-audio-access-method'] = false; + // Offline video is not done data.mainFeatures['settings-video-access-method'] = false; - data.mainFeatures['settings-audio-download-mode'] = false; /** * Keyboards are not supported (documentation links were added in 14.2) diff --git a/src/lib/components/AudioBar.svelte b/src/lib/components/AudioBar.svelte index 2eeffebc4..961546ef6 100644 --- a/src/lib/components/AudioBar.svelte +++ b/src/lib/components/AudioBar.svelte @@ -32,6 +32,11 @@ TODO: import PlayButton from './PlayButton.svelte'; import RepeatButton from './RepeatButton.svelte'; + interface Props { + checkAudioAvailability?: () => Promise; //checkAudioAvailability downloads the audio if necessary and returns true if the audio is available + } + + let { checkAudioAvailability = () => Promise.resolve(true) }: Props = $props(); function mayResetPlayMode(hasTiming: boolean) { // If the current mode is repeatSelection and the reference is changed to something without timing // (even chapter without audio), then reset the playMode. This matches how the Android app behaves. @@ -137,7 +142,16 @@ TODO: {/if} - + + checkAudioAvailability().then((audioAvailable) => { + if (audioAvailable) { + playPause(); + } + })} + /> {#if $refs.hasAudio?.timingFile} {/if} - {#if !$refs.hasAudio.timingFile} + {#if !$refs.hasAudio?.timingFile}
{format($audioPlayer.progress)} diff --git a/src/lib/components/AudioDownloadModal.svelte b/src/lib/components/AudioDownloadModal.svelte new file mode 100644 index 000000000..696c1ffa4 --- /dev/null +++ b/src/lib/components/AudioDownloadModal.svelte @@ -0,0 +1,157 @@ + + + + + +
+
+
+
+ {$t['Audio_Download_Title']} +
+
+ {$t['Audio_Download_Confirm']} +
+ + +
{ + downloadAutomatically = !downloadAutomatically; + }} + > +
+ {#if downloadAutomatically} + + {:else} + + {/if} +
+
{$t['Audio_Download_Auto']}
+
+
+ + +
+
+{#if error} +
+
+ {error} +
+
+{/if} +{#if downloadProgress > 0} +
+
+
+
+
{$t['Audio_Download_Title']}
+
{audioDownloadingMessage}
+ +
+
+
+
+
+
+
+ +
+
+
+{/if} diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 81f822bbc..1de23b3f1 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -20,13 +20,7 @@ See https://daisyui.com/components/modal/#modal-that-closes-when-clicked-outside dialog?: HTMLDialogElement; } - let { - id, - children, - styling = `background-color:${$themeColors['DialogBackgroundColor']};`, - onclose, - dialog = $bindable() - }: Props = $props(); + let { id, children, styling, onclose, dialog = $bindable() }: Props = $props(); /** * This exported function allows buttons/labels * in other divs to trigger the modal popup @@ -43,7 +37,11 @@ See https://daisyui.com/components/modal/#modal-that-closes-when-clicked-outside class="dy-modal cursor-pointer" style:direction={$direction} > -
+ {@render children?.()}
diff --git a/src/lib/data/audio.ts b/src/lib/data/audio.ts index 1d253c821..dfbe35fd4 100644 --- a/src/lib/data/audio.ts +++ b/src/lib/data/audio.ts @@ -6,9 +6,12 @@ import { audioPlayerDefault, audioPlayer as audioPlayerStore, defaultPlayMode, + modal, + ModalType, playMode, PlayMode, refs, + userSettings, type AudioPlayer, type PlayModeRange, type PlayModeSettings, @@ -16,7 +19,14 @@ import { } from '$lib/data/stores'; import { getBibleBrainUrl } from '$lib/scripts/mediaUtils'; import { pathJoin } from '$lib/scripts/stringUtils'; +import { get } from 'svelte/store'; import { logAudioDuration, logAudioPlay } from './analytics'; +import { findAudioClip } from './audioclipsDB'; + +export const audioClipUrls = new MRUCache(10, (item, key) => { + URL.revokeObjectURL(item); + cache.delete(key); +}); const audioSources = import.meta.glob('./*', { import: 'default', @@ -62,6 +72,15 @@ export function updateAudioPlayer(item: { collection: string; book: string; chap audioPlayer.timeIndex = 0; } } + if (currentAudioPlayer && currentAudioPlayer !== audioPlayer) { + if (currentAudioPlayer.audio) { + currentAudioPlayer.audio.pause(); + if (currentAudioPlayer.timer) { + clearInterval(currentAudioPlayer.timer); + currentAudioPlayer.timer = null; + } + } + } audioPlayerStore.set(audioPlayer); } // some browsers don't support all sources (e.g. Mobile Safari doesn't support webm) @@ -103,6 +122,7 @@ async function getAudio() { if (!currentAudioPlayer || currentAudioPlayer.loaded) { return; } + const player = currentAudioPlayer; currentAudioPlayer.duration = 0; currentAudioPlayer.progress = 0; if (currentAudioPlayer.playing) { @@ -115,6 +135,9 @@ async function getAudio() { currentAudioPlayer.timing = audioSourceInfo.timing; const a = createAudio(audioSourceInfo.source); a.onloadedmetadata = () => { + if (player !== currentAudioPlayer) { + return; + } currentAudioPlayer!.duration = a.duration; currentAudioPlayer!.timeIndex = 0; currentAudioPlayer!.loaded = true; @@ -697,12 +720,28 @@ export async function getAudioSourceInfo( let audioPath = null; if (audioSource?.type === 'fcbh') { - const result = await getBibleBrainUrl(audioSource, item, getDamId); - if (result.error) { - throw new Error(`Failed to connect to BibleBrain: ${result.error}`); + if (audioSource.accessMethods?.includes('download')) { + const foundAudioClip = await findAudioClip({ + collection: item.collection || '', + book: item.book || '', + chapter: item.chapter || '' + }); //If the audio has been downloaded already, use that. + if (foundAudioClip) { + const clipKey = `${item.collection}-${item.book}-${item.chapter}`; + if (!audioClipUrls.get(clipKey)) { + audioClipUrls.put(clipKey, URL.createObjectURL(foundAudioClip.blob)); + } + audioPath = audioClipUrls.get(clipKey)!; + } } + if (!audioPath) { + const result = await getBibleBrainUrl(audioSource, item, getDamId); + if (result.error) { + throw new Error(`Failed to connect to BibleBrain: ${result.error}`); + } - audioPath = result.path; + audioPath = result.path; + } } else if (audioSource?.type === 'assets') { const audioKey = `./${audio.filename}`; if (!audioSources[audioKey]) { @@ -711,6 +750,18 @@ export async function getAudioSourceInfo( audioPath = audioSources[audioKey]; } else if (audioSource?.type === 'download') { audioPath = pathJoin([audioSource.address, audio.filename]); + const foundAudioClip = await findAudioClip({ + collection: item.collection || '', + book: item.book || '', + chapter: item.chapter || '' + }); //If the audio has been downloaded already, use that. + if (foundAudioClip) { + const clipKey = `${item.collection}-${item.book}-${item.chapter}`; + if (!audioClipUrls.get(clipKey)) { + audioClipUrls.put(clipKey, URL.createObjectURL(foundAudioClip.blob)); + } + audioPath = audioClipUrls.get(clipKey)!; + } } //parse timing file const timing: Timing[] = []; @@ -748,7 +799,6 @@ export async function getAudioSourceInfo( } } } - return { source: audioPath, timing: timing.length > 0 ? timing : null @@ -823,3 +873,51 @@ function getVerseTimingRange(startVerse: string, endVerse: string) { return { start, end } as PlayModeRange; } + +export async function checkAudioAvailability() { + let curRefs = get(refs); + const audio = scriptureConfig.bookCollections + ?.find((c) => curRefs.collection === c.id) + ?.books?.find((b) => b.id === curRefs.book) + ?.audio?.find((a) => curRefs.chapter === '' + a.num); + if (audio && get(userSettings)['audio-access-method'] === 'download') { + const audioSource = scriptureConfig.audio?.sources[audio.src]; + const foundAudioClip = await findAudioClip({ + collection: curRefs.collection || '', + book: curRefs.book || '', + chapter: curRefs.chapter || '' + }); + curRefs = get(refs); + if (!foundAudioClip) { + let audioPath = ''; + if (audioSource?.type === 'download') { + audioPath = pathJoin([audioSource.address, audio.filename]); + } else if (audioSource?.type === 'fcbh') { + const result = await getBibleBrainUrl( + audioSource, + { + collection: curRefs.collection || '', + book: curRefs.book || '', + chapter: curRefs.chapter || '' + }, + getDamId + ); + if (result.error) { + throw new Error(`Failed to connect to BibleBrain: ${result.error}`); + } + if (result.path) { + audioPath = result.path; + } + } + if (audioSource?.accessMethods?.includes('download')) { + if (get(userSettings)['audio-auto-download'] === 'auto') { + modal.open(ModalType.DownloadAudio, { audioPath, show: false }); //Just download it without showing the modal + } else { + modal.open(ModalType.DownloadAudio, { audioPath, show: true }); + } + return false; + } + } + } + return true; +} diff --git a/src/lib/data/audioclipsDB.ts b/src/lib/data/audioclipsDB.ts new file mode 100644 index 000000000..cebe7f1cc --- /dev/null +++ b/src/lib/data/audioclipsDB.ts @@ -0,0 +1,109 @@ +import { scriptureConfig } from '$assets/config'; +import { openDB, type DBSchema } from 'idb'; + +export interface AudioItem { + date: number; + docSet: string; + collection: string; + book: string; + chapter: string; + blob: Blob; +} +interface AudioClips extends DBSchema { + audioclips: { + key: number; + value: AudioItem; + indexes: { + 'collection, book, chapter': string[]; + date: string; + }; + }; +} +let audioDB: Awaited>> | null = null; +async function openAudioClips() { + if (!audioDB) { + audioDB = await openDB('audioclips', 1, { + upgrade(db) { + const audioStore = db.createObjectStore('audioclips', { + keyPath: 'date' + }); + audioStore.createIndex('collection, book, chapter', [ + 'collection', + 'book', + 'chapter' + ]); + audioStore.createIndex('date', ['date']); + } + }); + } + return audioDB; +} +export async function addAudioClip( + item: { + docSet: string; + collection: string; + book: string; + chapter: string; + }, + url: string, + abortController: AbortController, + onProgress?: (percent: number) => void +) { + try { + const response = await fetch(url, { signal: abortController.signal }); + if (!response.ok) { + return false; + } + const contentLength = response.headers.get('Content-Length'); + const total = contentLength ? parseInt(contentLength, 10) : 0; + if (!response.body) { + return false; + } + const reader = response.body.getReader(); + const chunks: BlobPart[] = []; + let received = 0; + + while (true) { + if (abortController.signal.aborted) { + return false; + } + const { done, value } = await reader.read(); + if (done) { + break; + } + + chunks.push(value); + + received += value.length; + + if (total && onProgress) { + const percent = Math.round((received / total) * 100); + onProgress(percent); + } + } + + const blob = new Blob(chunks); + const audioClips = await openAudioClips(); + const date = new Date().getTime(); + const bookIndex = scriptureConfig.bookCollections + ?.find((x) => x.id === item.collection) + ?.books.findIndex((x) => x.id === item.book); + if (bookIndex !== undefined && bookIndex >= 0) { + const nextItem = { ...item, date: date, blob: blob }; + await audioClips.add('audioclips', nextItem); + return true; + } + return false; + } catch (error) { + console.error('Error downloading audio:', error); + return false; + } +} +export async function findAudioClip(item: { collection: string; book: string; chapter: string }) { + const audioClips = await openAudioClips(); + const tx = audioClips.transaction('audioclips', 'readonly'); + const index = tx.store.index('collection, book, chapter'); + const result = await index.getAll([item.collection, item.book, item.chapter]); + await tx.done; + return result[0]; +} diff --git a/src/lib/data/mrucache.ts b/src/lib/data/mrucache.ts index d66856ab3..7e77b449e 100644 --- a/src/lib/data/mrucache.ts +++ b/src/lib/data/mrucache.ts @@ -1,10 +1,12 @@ export class MRUCache { private capacity: number; private cache: Map; + private cleanupItem: undefined | ((item: TValue, key: TKey) => void); - constructor(capacity: number) { + constructor(capacity: number, cleanupItem?: (item: TValue, key: TKey) => void) { this.capacity = capacity; this.cache = new Map(); + this.cleanupItem = cleanupItem; } get(key: TKey): TValue | null { @@ -19,15 +21,25 @@ export class MRUCache { put(key: TKey, data: TValue): void { if (this.cache.has(key)) { - this.cache.delete(key); + this.delete(key); } else if (this.cache.size === this.capacity) { const firstKey = this.cache.keys().next().value; - this.cache.delete(firstKey); + this.delete(firstKey!); } this.cache.set(key, data); } + delete(key: TKey): void { + if (this.cache.has(key)) { + this.cleanupItem?.(this.cache.get(key)!, key); + this.cache.delete(key); + } + } + clear(): void { + for (const v of this.cache.keys()) { + this.cleanupItem?.(this.cache.get(v)!, v); + } this.cache.clear(); } diff --git a/src/lib/data/stores/view.ts b/src/lib/data/stores/view.ts index da6240ea7..5f87da9af 100644 --- a/src/lib/data/stores/view.ts +++ b/src/lib/data/stores/view.ts @@ -33,7 +33,8 @@ export const ModalType = { PlaybackSpeed: 'playback-speed', VerseOnImage: 'verse-on-image', Download: 'download', - Collection: 'collection' + Collection: 'collection', + DownloadAudio: 'download-audio' } as const; export type ModalType = (typeof ModalType)[keyof typeof ModalType]; diff --git a/src/lib/scripts/mediaUtils.ts b/src/lib/scripts/mediaUtils.ts index 29b699424..40cb1f66f 100644 --- a/src/lib/scripts/mediaUtils.ts +++ b/src/lib/scripts/mediaUtils.ts @@ -11,9 +11,7 @@ export async function getBibleBrainUrl( ): Promise<{ error: unknown; path?: never } | { error?: never; path: string }> { try { const dbp4 = audioSource.address ? audioSource.address : 'https://4.dbt.io'; - // if (source.accessMethods.includes('download')) { - // // TODO: Figure out how to use Cache API to download audio to PWA - // } + const damId = getDamId(audioSource); const request = `${dbp4}/api/bibles/filesets/${damId}/${item.book}/${item.chapter}?v=4&key=${audioSource.key}`; const response = await fetch(request, { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 251968cc8..b9787d0f5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import manifestHref from '$assets/manifestUrl.json'; import '$lib/styles/app.css'; import config from '$assets/config'; + import AudioDownloadModal from '$lib/components/AudioDownloadModal.svelte'; import AudioPlaybackSpeed from '$lib/components/AudioPlaybackSpeed.svelte'; import CollectionModal from '$lib/components/CollectionModal.svelte'; import FontSelector from '$lib/components/FontSelector.svelte'; @@ -83,6 +84,16 @@ planStopId = data as string; planStopDialog?.showModal(); break; + case ModalType.DownloadAudio: + { + const audioModalData = data as { audioPath: string; show: boolean }; + if (audioModalData.show) { + audioDownloadModal?.showModal(audioModalData.audioPath); + } else { + audioDownloadModal?.downloadAudio(audioModalData.audioPath); + } + } + break; case ModalType.PlaybackSpeed: audioPlaybackSpeed?.showModal(); break; @@ -112,6 +123,7 @@ let noteDialog: NoteDialog | undefined = $state(); let collectionModal: CollectionModal | undefined = $state(); let planStopDialog: PlanStopDialog | undefined = $state(undefined); + let audioDownloadModal: AudioDownloadModal | undefined = $state(); let planStopId: string = $state(''); let audioPlaybackSpeed: AudioPlaybackSpeed | undefined = $state(); @@ -161,6 +173,7 @@ bind:planId={planStopId} vertOffset={NAVBAR_HEIGHT} /> +
diff --git a/src/routes/text/+page.svelte b/src/routes/text/+page.svelte index 2b84f46c9..5cbda10c6 100644 --- a/src/routes/text/+page.svelte +++ b/src/routes/text/+page.svelte @@ -17,7 +17,12 @@ import StackView from '$lib/components/StackView.svelte'; import { showTextAppearance } from '$lib/components/TextAppearanceSelector.svelte'; import TextSelectionToolbar from '$lib/components/TextSelectionToolbar.svelte'; - import { playStop, seekToVerse, updateAudioPlayer } from '$lib/data/audio'; + import { + checkAudioAvailability, + playStop, + seekToVerse, + updateAudioPlayer + } from '$lib/data/audio'; import { actionBarColor, analytics, @@ -59,6 +64,7 @@ } from '$lib/icons'; import { navigateToTextChapterInDirection } from '$lib/navigate'; import { getFeatureValueBoolean, getFeatureValueString } from '$lib/scripts/configUtils'; + import { pathJoin } from '$lib/scripts/stringUtils'; import { resolve } from '$lib/utils/paths'; import { onDestroy, onMount } from 'svelte'; import { @@ -474,6 +480,9 @@