Skip to content

[TAS-5350] ✨ Add native audio bridge for in-app TTS playback#706

Merged
williamchong merged 7 commits intolikecoin:developfrom
williamchong:feature/tts-app
Mar 2, 2026
Merged

[TAS-5350] ✨ Add native audio bridge for in-app TTS playback#706
williamchong merged 7 commits intolikecoin:developfrom
williamchong:feature/tts-app

Conversation

@williamchong
Copy link
Copy Markdown
Member

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TTSAudioPlayer interface + events contract and a typed window.ReactNativeWebView bridge surface.
  • Refactors useTextToSpeech to use a pluggable player (useNativeAudioPlayer vs useWebAudioPlayer) 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.

Comment thread composables/use-text-to-speech.ts Outdated
Comment on lines +40 to +43
const player: TTSAudioPlayer = isNativeBridge.value
? useNativeAudioPlayer()
: useWebAudioPlayer()

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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),
}

Copilot uses AI. Check for mistakes.
Comment thread composables/use-native-audio-bridge.ts Outdated
Comment on lines +2 to +5
const { isApp } = useAppDetection()

const isNativeBridge = computed(() =>
import.meta.client && isApp.value && !!window.ReactNativeWebView,
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Suggested change
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,

Copilot uses AI. Check for mistakes.

const element = segments[index]
if (!element) return

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
const trackChangedHandler = handlers.trackChanged
if (trackChangedHandler) {
trackChangedHandler(currentIndex, element)
}

Copilot uses AI. Check for mistakes.
const tracks = options.segments.map((segment, i) => ({
index: i,
url: new URL(options.getAudioSrc(segment), origin).href,
title: segment.text.substring(0, 50),
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
title: segment.text.substring(0, 50),
title: `${options.metadata.bookTitle} - Track ${i + 1}`,

Copilot uses AI. Check for mistakes.
@williamchong williamchong force-pushed the feature/tts-app branch 2 times, most recently from 6774f78 to 6993ad4 Compare February 20, 2026 13:38
@williamchong williamchong changed the title ✨ Add native audio bridge for in-app TTS playback [TAS-5350] ✨ Add native audio bridge for in-app TTS playback Feb 23, 2026
@notion-workspace
Copy link
Copy Markdown

@williamchong williamchong force-pushed the feature/tts-app branch 2 times, most recently from 8050e7b to 03d178c Compare February 25, 2026 15:55
nwingt added a commit that referenced this pull request Feb 26, 2026
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.
@williamchong williamchong force-pushed the feature/tts-app branch 2 times, most recently from 9e7a034 to 8fb9a47 Compare February 26, 2026 10:21
nwingt added a commit that referenced this pull request Feb 28, 2026
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
nwingt added a commit that referenced this pull request Feb 28, 2026
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
@williamchong williamchong requested a review from Copilot March 1, 2026 14:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

Comment on lines +47 to +56
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(),
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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(),

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +71
function resume(): boolean {
postToNative({ type: 'resume' })
return true
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +46
// 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()
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated no new comments.

AuroraHuang22 pushed a commit that referenced this pull request Mar 2, 2026
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
@williamchong williamchong marked this pull request as ready for review March 2, 2026 06:07
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.
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.
@williamchong williamchong merged commit cf1eca9 into likecoin:develop Mar 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants