[TAS-5350] ✨ Add native audio bridge for in-app TTS playback#706
[TAS-5350] ✨ Add native audio bridge for in-app TTS playback#706williamchong merged 7 commits intolikecoin:developfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an abstraction layer for TTS audio playback so the app can use either the browser <audio> element or a React Native WebView “native audio” bridge, enabling in-app/native playback while keeping web playback working.
Changes:
- Introduces a
TTSAudioPlayerinterface + events contract and a typedwindow.ReactNativeWebViewbridge surface. - Refactors
useTextToSpeechto use a pluggable player (useNativeAudioPlayervsuseWebAudioPlayer) and wires player events back into TTS state. - Adds concrete player implementations for web audio and native-bridge audio.
Reviewed changes
Copilot reviewed 4 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| types/tts-audio-player.d.ts | Defines the shared TTSAudioPlayer + event contract used by both implementations. |
| types/native-bridge.d.ts | Types the React Native WebView bridge entry point on window. |
| composables/use-web-audio-player.ts | Implements the TTSAudioPlayer contract using browser Audio. |
| composables/use-native-audio-player.ts | Implements the TTSAudioPlayer contract by posting messages to the native layer and listening for native events. |
| composables/use-native-audio-bridge.ts | Detects when the native bridge should be used. |
| composables/use-text-to-speech.ts | Refactors TTS playback to delegate all audio control to the selected player implementation. |
| const player: TTSAudioPlayer = isNativeBridge.value | ||
| ? useNativeAudioPlayer() | ||
| : useWebAudioPlayer() | ||
|
|
There was a problem hiding this comment.
player is selected once from isNativeBridge.value at composable initialization. Since isNativeBridge can change later (e.g., after app/userAgent detection completes), the implementation may permanently pick the web player even in the native app. Consider making bridge detection stable before this selection (e.g., synchronous app detection on client) or updating player via a watch/shallowRef so the correct implementation is used.
| const player: TTSAudioPlayer = isNativeBridge.value | |
| ? useNativeAudioPlayer() | |
| : useWebAudioPlayer() | |
| const webPlayer = useWebAudioPlayer() | |
| const nativePlayer = useNativeAudioPlayer() | |
| // Wrap players so we always use the correct implementation based on the | |
| // current value of `isNativeBridge`. This avoids permanently selecting the | |
| // web player if the native bridge becomes available later. | |
| const player: TTSAudioPlayer = { | |
| // NOTE: This assumes TTSAudioPlayer exposes these methods. The wrapper | |
| // simply forwards to the currently appropriate underlying player. | |
| play: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).play(...args), | |
| pause: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).pause(...args), | |
| stop: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).stop(...args), | |
| seek: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).seek(...args), | |
| setSource: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).setSource(...args), | |
| setPlaybackRate: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).setPlaybackRate(...args), | |
| getCurrentTime: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).getCurrentTime(...args), | |
| getDuration: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).getDuration(...args), | |
| onTimeUpdate: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).onTimeUpdate(...args), | |
| onEnded: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).onEnded(...args), | |
| onError: (...args: any[]) => (isNativeBridge.value ? nativePlayer : webPlayer).onError(...args), | |
| } |
| const { isApp } = useAppDetection() | ||
|
|
||
| const isNativeBridge = computed(() => | ||
| import.meta.client && isApp.value && !!window.ReactNativeWebView, |
There was a problem hiding this comment.
isNativeBridge depends on isApp.value, but useAppDetection() sets the user-agent flag in onMounted(). That means isNativeBridge may be false during initial setup/hydration even inside the app, which breaks native bridge selection. Consider computing the user-agent flag synchronously on client (guarded by import.meta.client) or checking the app user-agent directly here instead of waiting for onMounted().
| const { isApp } = useAppDetection() | |
| const isNativeBridge = computed(() => | |
| import.meta.client && isApp.value && !!window.ReactNativeWebView, | |
| const isNativeBridge = computed(() => | |
| import.meta.client && | |
| typeof window !== 'undefined' && | |
| !!(window as any).ReactNativeWebView, |
|
|
||
| const element = segments[index] | ||
| if (!element) return | ||
|
|
There was a problem hiding this comment.
useTextToSpeech relies on the trackChanged event to reset consecutiveAudioErrors and to keep state in sync, but the web implementation never emits trackChanged. This can leave consecutiveAudioErrors elevated after a manual skip and cause premature pauses. Emit trackChanged when starting playback for an index (e.g., in playAtIndex / skipTo / load).
| const trackChangedHandler = handlers.trackChanged | |
| if (trackChangedHandler) { | |
| trackChangedHandler(currentIndex, element) | |
| } |
| const tracks = options.segments.map((segment, i) => ({ | ||
| index: i, | ||
| url: new URL(options.getAudioSrc(segment), origin).href, | ||
| title: segment.text.substring(0, 50), |
There was a problem hiding this comment.
Native tracks[].title is derived from segment.text (book content). If the native app surfaces track titles in OS media controls/lock screen, this can unintentionally expose the reader’s content. Consider using non-content titles (e.g., book title + track number) or making the excerpt optional/behind a privacy setting.
| title: segment.text.substring(0, 50), | |
| title: `${options.metadata.bookTitle} - Track ${i + 1}`, |
6774f78 to
6993ad4
Compare
8050e7b to
03d178c
Compare
commit 03d178c Author: William Chong <me@williamchong.cloud> Date: Wed Feb 25 17:54:38 2026 +0800 🐛 Make native audio feature flag reactive with onFeatureFlags callback commit f37b49e Author: William Chong <me@williamchong.cloud> Date: Wed Feb 18 01:33:33 2026 +0800 🚩 Gate native audio bridge behind PostHog feature flag Native audio player only activates when the 'native-audio' PostHog flag is enabled. Defaults to off (web player) when the flag is unset or PostHog hasn't loaded yet. commit 560b219 Author: William Chong <me@williamchong.cloud> Date: Tue Feb 17 03:24:39 2026 +0800 ✨ Add native audio bridge for in-app TTS playback Add bridge detection (useNativeAudioBridge) and native audio player (useNativeAudioPlayer) that delegates playback to react-native-track-player via postMessage. The orchestrator now picks the native player when running inside the 3ook-com-app WebView, enabling background audio and lock screen controls.
9e7a034 to
8fb9a47
Compare
commit b52da0e Author: William Chong <me@williamchong.cloud> Date: Sat Feb 28 04:38:48 2026 +0800 🐛 Make TTS player selection reactive to native bridge availability Player was selected once at setup time when isNativeBridge was always false (depends on onMounted + PostHog flags). Now both players are created upfront and a proxy delegates calls based on the current reactive flag value. commit 8fb9a47 Author: William Chong <me@williamchong.cloud> Date: Thu Feb 26 12:53:42 2026 +0800 ✨ Add seekbackward, seekforward, seekto media session handlers with position state reporting commit 130a21a Author: William Chong <me@williamchong.cloud> Date: Wed Feb 25 17:54:38 2026 +0800 🐛 Make native audio feature flag reactive with onFeatureFlags callback commit 30a6b39 Author: William Chong <me@williamchong.cloud> Date: Wed Feb 18 01:33:33 2026 +0800 🚩 Gate native audio bridge behind PostHog feature flag Native audio player only activates when the 'native-audio' PostHog flag is enabled. Defaults to off (web player) when the flag is unset or PostHog hasn't loaded yet. commit 3657ca7 Author: William Chong <me@williamchong.cloud> Date: Tue Feb 17 03:24:39 2026 +0800 ✨ Add native audio bridge for in-app TTS playback Add bridge detection (useNativeAudioBridge) and native audio player (useNativeAudioPlayer) that delegates playback to react-native-track-player via postMessage. The orchestrator now picks the native player when running inside the 3ook-com-app WebView, enabling background audio and lock screen controls. # Conflicts: # composables/use-text-to-speech.ts
commit b52da0e Author: William Chong <me@williamchong.cloud> Date: Sat Feb 28 04:38:48 2026 +0800 🐛 Make TTS player selection reactive to native bridge availability Player was selected once at setup time when isNativeBridge was always false (depends on onMounted + PostHog flags). Now both players are created upfront and a proxy delegates calls based on the current reactive flag value. commit 8fb9a47 Author: William Chong <me@williamchong.cloud> Date: Thu Feb 26 12:53:42 2026 +0800 ✨ Add seekbackward, seekforward, seekto media session handlers with position state reporting commit 130a21a Author: William Chong <me@williamchong.cloud> Date: Wed Feb 25 17:54:38 2026 +0800 🐛 Make native audio feature flag reactive with onFeatureFlags callback commit 30a6b39 Author: William Chong <me@williamchong.cloud> Date: Wed Feb 18 01:33:33 2026 +0800 🚩 Gate native audio bridge behind PostHog feature flag Native audio player only activates when the 'native-audio' PostHog flag is enabled. Defaults to off (web player) when the flag is unset or PostHog hasn't loaded yet. commit 3657ca7 Author: William Chong <me@williamchong.cloud> Date: Tue Feb 17 03:24:39 2026 +0800 ✨ Add native audio bridge for in-app TTS playback Add bridge detection (useNativeAudioBridge) and native audio player (useNativeAudioPlayer) that delegates playback to react-native-track-player via postMessage. The orchestrator now picks the native player when running inside the 3ook-com-app WebView, enabling background audio and lock screen controls. # Conflicts: # composables/use-text-to-speech.ts
| const activePlayer = () => isNativeBridge.value ? nativePlayer : webPlayer | ||
| const player: TTSAudioPlayer = { | ||
| load: options => activePlayer().load(options), | ||
| resume: () => activePlayer().resume(), | ||
| pause: () => activePlayer().pause(), | ||
| stop: () => activePlayer().stop(), | ||
| skipTo: index => activePlayer().skipTo(index), | ||
| setRate: rate => activePlayer().setRate(rate), | ||
| seek: time => activePlayer().seek(time), | ||
| getPosition: () => activePlayer().getPosition(), |
There was a problem hiding this comment.
isNativeBridge can flip asynchronously when PostHog feature flags finish loading, which will switch activePlayer() mid-session. That can leave the already-loaded player running while controls delegate to the other player, and can also cause startTextToSpeech() to call resume() on the newly-active player that was never load()ed. Consider locking the chosen player for the duration of a TTS session (e.g., pick implementation on first load() and keep until stop()), or explicitly stop + re-load when isNativeBridge changes while TTS is on.
| const activePlayer = () => isNativeBridge.value ? nativePlayer : webPlayer | |
| const player: TTSAudioPlayer = { | |
| load: options => activePlayer().load(options), | |
| resume: () => activePlayer().resume(), | |
| pause: () => activePlayer().pause(), | |
| stop: () => activePlayer().stop(), | |
| skipTo: index => activePlayer().skipTo(index), | |
| setRate: rate => activePlayer().setRate(rate), | |
| seek: time => activePlayer().seek(time), | |
| getPosition: () => activePlayer().getPosition(), | |
| const activePlayer = () => (isNativeBridge.value ? nativePlayer : webPlayer) | |
| // Lock the chosen player for the duration of a TTS session so that | |
| // asynchronous flips of `isNativeBridge` do not switch implementations | |
| // mid-session. The lock is acquired on first `load()` and released on `stop()`. | |
| let lockedPlayer: 'native' | 'web' | null = null | |
| const getCurrentPlayer = (): TTSAudioPlayer => { | |
| if (lockedPlayer === 'native') { | |
| return nativePlayer | |
| } | |
| if (lockedPlayer === 'web') { | |
| return webPlayer | |
| } | |
| return activePlayer() | |
| } | |
| const player: TTSAudioPlayer = { | |
| load: (options) => { | |
| if (!lockedPlayer) { | |
| lockedPlayer = isNativeBridge.value ? 'native' : 'web' | |
| } | |
| return getCurrentPlayer().load(options) | |
| }, | |
| resume: () => getCurrentPlayer().resume(), | |
| pause: () => getCurrentPlayer().pause(), | |
| stop: () => { | |
| const current = getCurrentPlayer() | |
| const result = current.stop() | |
| lockedPlayer = null | |
| return result | |
| }, | |
| skipTo: index => getCurrentPlayer().skipTo(index), | |
| setRate: rate => getCurrentPlayer().setRate(rate), | |
| seek: time => getCurrentPlayer().seek(time), | |
| getPosition: () => getCurrentPlayer().getPosition(), |
| function resume(): boolean { | ||
| postToNative({ type: 'resume' }) | ||
| return true | ||
| } |
There was a problem hiding this comment.
resume() currently returns true unconditionally even if the native player was never load()ed. Since useTextToSpeech() uses the boolean return to decide whether to skip calling load(), this can lead to a no-op “resume” path when the active player changes or state is desynced. Track a hasLoaded/active flag in this player and return false until a successful load() (and possibly until native confirms it is ready).
| // Pick player implementation — both are created upfront (inert until load()), | ||
| // and the proxy delegates to the correct one based on the reactive flag. | ||
| const { isNativeBridge } = useNativeAudioBridge() | ||
| const nativePlayer = useNativeAudioPlayer() | ||
| const webPlayer = useWebAudioPlayer() |
There was a problem hiding this comment.
The comment says both players are “inert until load()”, but useNativeAudioPlayer() registers a nativeAudioEvent listener immediately. Consider lazily constructing the native player (or gating its listener) so native events can’t affect state when web playback is active.
commit b52da0e Author: William Chong <me@williamchong.cloud> Date: Sat Feb 28 04:38:48 2026 +0800 🐛 Make TTS player selection reactive to native bridge availability Player was selected once at setup time when isNativeBridge was always false (depends on onMounted + PostHog flags). Now both players are created upfront and a proxy delegates calls based on the current reactive flag value. commit 8fb9a47 Author: William Chong <me@williamchong.cloud> Date: Thu Feb 26 12:53:42 2026 +0800 ✨ Add seekbackward, seekforward, seekto media session handlers with position state reporting commit 130a21a Author: William Chong <me@williamchong.cloud> Date: Wed Feb 25 17:54:38 2026 +0800 🐛 Make native audio feature flag reactive with onFeatureFlags callback commit 30a6b39 Author: William Chong <me@williamchong.cloud> Date: Wed Feb 18 01:33:33 2026 +0800 🚩 Gate native audio bridge behind PostHog feature flag Native audio player only activates when the 'native-audio' PostHog flag is enabled. Defaults to off (web player) when the flag is unset or PostHog hasn't loaded yet. commit 3657ca7 Author: William Chong <me@williamchong.cloud> Date: Tue Feb 17 03:24:39 2026 +0800 ✨ Add native audio bridge for in-app TTS playback Add bridge detection (useNativeAudioBridge) and native audio player (useNativeAudioPlayer) that delegates playback to react-native-track-player via postMessage. The orchestrator now picks the native player when running inside the 3ook-com-app WebView, enabling background audio and lock screen controls. # Conflicts: # composables/use-text-to-speech.ts
Add bridge detection (useNativeAudioBridge) and native audio player (useNativeAudioPlayer) that delegates playback to react-native-track-player via postMessage. The orchestrator now picks the native player when running inside the 3ook-com-app WebView, enabling background audio and lock screen controls. # Conflicts: # composables/use-text-to-speech.ts
Native audio player only activates when the 'native-audio' PostHog flag is enabled. Defaults to off (web player) when the flag is unset or PostHog hasn't loaded yet.
…osition state reporting
Player was selected once at setup time when isNativeBridge was always false (depends on onMounted + PostHog flags). Now both players are created upfront and a proxy delegates calls based on the current reactive flag value.
Track loaded state so resume() returns false when load() was never called, and gate the nativeAudioEvent listener on isActive to prevent native events from affecting state during web playback.
82784fd to
e3559d2
Compare
Requires: likecoin/3ook-com-app#3