diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs index fb48f16e..e97e777e 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs @@ -2,6 +2,7 @@ const COMMANDS: &[&str] = &[ "auth_with_safari", "auth_with_custom_tab", "copy_uri_to_path", + "use_background_audio", ]; fn main() { diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index bcca8291..40d427c3 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -1,4 +1,6 @@ import AuthenticationServices +import AVFoundation +import MediaPlayer import SwiftRs import Tauri import UIKit @@ -8,9 +10,33 @@ class SafariAuthRequestArgs: Decodable { let authUrl: String } +class UseBackgroundAudioRequestArgs: Decodable { + let enabled: Bool +} + class NativeBridgePlugin: Plugin { private var authSession: ASWebAuthenticationSession? + @objc public func use_background_audio(_ invoke: Invoke) { + do { + let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self) + let enabled = args.enabled + let session = AVAudioSession.sharedInstance() + if enabled { + try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setActive(true) + print("AVAudioSession activated") + } else { + try session.setActive(false) + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + print("AVAudioSession deactivated") + } + invoke.resolve() + } catch { + print("Failed to set up audio session:", error) + } + } + @objc public func auth_with_safari(_ invoke: Invoke) throws { let args = try invoke.parseArgs(SafariAuthRequestArgs.self) let authUrl = URL(string: args.authUrl)! diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/use_background_audio.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/use_background_audio.toml new file mode 100644 index 00000000..f416bf98 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/use_background_audio.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-use-background-audio" +description = "Enables the use_background_audio command without any pre-configured scope." +commands.allow = ["use_background_audio"] + +[[permission]] +identifier = "deny-use-background-audio" +description = "Denies the use_background_audio command without any pre-configured scope." +commands.deny = ["use_background_audio"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md index 7d233fc9..9fc50314 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md @@ -7,6 +7,7 @@ Default permissions for the plugin - `allow-auth-with-safari` - `allow-auth-with-custom-tab` - `allow-copy-uri-to-path` +- `allow-use-background-audio` ## Permission Table @@ -92,6 +93,32 @@ Enables the copy_uri_to_path command without any pre-configured scope. Denies the copy_uri_to_path command without any pre-configured scope. + + + + + + +`native-bridge:allow-use-background-audio` + + + + +Enables the use_background_audio command without any pre-configured scope. + + + + + + + +`native-bridge:deny-use-background-audio` + + + + +Denies the use_background_audio command without any pre-configured scope. + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml index ddc01607..d9ea6498 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path"] +permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path", "allow-use-background-audio"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json index 354ebbef..b8204693 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json @@ -331,10 +331,22 @@ "markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`", + "description": "Enables the use_background_audio command without any pre-configured scope.", + "type": "string", + "const": "allow-use-background-audio", + "markdownDescription": "Enables the use_background_audio command without any pre-configured scope." + }, + { + "description": "Denies the use_background_audio command without any pre-configured scope.", + "type": "string", + "const": "deny-use-background-audio", + "markdownDescription": "Denies the use_background_audio command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs index 7d2ba90a..6fa250f2 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs @@ -27,3 +27,11 @@ pub(crate) async fn copy_uri_to_path( ) -> Result { app.native_bridge().copy_uri_to_path(payload) } + +#[command] +pub(crate) async fn use_background_audio( + app: AppHandle, + payload: UseBackgroundAudioRequest, +) -> Result<()> { + app.native_bridge().use_background_audio(payload) +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs index f2cb47d7..3c112603 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs @@ -25,4 +25,8 @@ impl NativeBridge { pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } + + pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> { + Err(crate::Error::UnsupportedPlatformError) + } } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs index 5df3d2e5..8a364c26 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs @@ -40,6 +40,7 @@ pub fn init() -> TauriPlugin { commands::auth_with_safari, commands::auth_with_custom_tab, commands::copy_uri_to_path, + commands::use_background_audio, ]) .setup(|app, api| { #[cfg(mobile)] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs index 298733a3..18f077b9 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs @@ -47,3 +47,11 @@ impl NativeBridge { .map_err(Into::into) } } + +impl NativeBridge { + pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> { + self.0 + .run_mobile_plugin("use_background_audio", payload) + .map_err(Into::into) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index 15128744..7f32c3e7 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -25,3 +25,9 @@ pub struct CopyURIResponse { pub success: bool, pub error: Option, } + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UseBackgroundAudioRequest { + pub enabled: bool, +} diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 749a63f9..6079e3c2 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -58,9 +58,9 @@ const FoliateViewer: React.FC<{ const { detail } = event as CustomEvent; detail.data = Promise.resolve(detail.data) .then((data) => { - const viewSettings = getViewSettings(bookKey)!; + const viewSettings = getViewSettings(bookKey); if (detail.type === 'text/css') return transformStylesheet(data); - if (detail.type === 'application/xhtml+xml') { + if (viewSettings && detail.type === 'application/xhtml+xml') { const ctx = { bookKey, viewSettings, diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 1d415640..ef3e9b1a 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -11,6 +11,7 @@ import { eventDispatcher } from '@/utils/event'; import { parseSSMLLang } from '@/utils/ssml'; import { getOSPlatform } from '@/utils/misc'; import { throttle } from '@/utils/throttle'; +import { invokeUseBackgroundAudio } from '@/utils/bridge'; import Popup from '@/components/Popup'; import TTSPanel from './TTSPanel'; import TTSIcon from './TTSIcon'; @@ -39,15 +40,32 @@ const TTSControl = () => { const iconRef = useRef(null); const ttsControllerRef = useRef(null); + const unblockerAudioRef = useRef(null); // this enables WebAudio to play even when the mute toggle switch is ON const unblockAudio = () => { - const audio = document.createElement('audio'); - audio.setAttribute('x-webkit-airplay', 'deny'); - audio.preload = 'auto'; - audio.loop = true; - audio.src = SILENCE_DATA; - audio.play(); + if (unblockerAudioRef.current) return; + unblockerAudioRef.current = document.createElement('audio'); + unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny'); + unblockerAudioRef.current.preload = 'auto'; + unblockerAudioRef.current.loop = true; + unblockerAudioRef.current.src = SILENCE_DATA; + unblockerAudioRef.current.play(); + }; + + const releaseUnblockAudio = () => { + if (!unblockerAudioRef.current) return; + try { + unblockerAudioRef.current.pause(); + unblockerAudioRef.current.currentTime = 0; + unblockerAudioRef.current.removeAttribute('src'); + unblockerAudioRef.current.src = ''; + unblockerAudioRef.current.load(); + unblockerAudioRef.current = null; + console.log('Unblock audio released'); + } catch (err) { + console.warn('Error releasing unblock audio:', err); + } }; useEffect(() => { @@ -94,6 +112,9 @@ const TTSControl = () => { setShowIndicator(true); try { + if (appService?.isIOSApp) { + await invokeUseBackgroundAudio({ enabled: true }); + } if (getOSPlatform() === 'ios' || appService?.isIOSApp) { unblockAudio(); } @@ -177,6 +198,12 @@ const TTSControl = () => { setShowPanel(false); setShowIndicator(false); } + if (appService?.isIOSApp) { + await invokeUseBackgroundAudio({ enabled: false }); + } + if (getOSPlatform() === 'ios' || appService?.isIOSApp) { + releaseUnblockAudio(); + } }; // rate range: 0.5 - 3, 1.0 is normal speed diff --git a/apps/readest-app/src/libs/edgeTTS.ts b/apps/readest-app/src/libs/edgeTTS.ts index 4253d40c..44a98499 100644 --- a/apps/readest-app/src/libs/edgeTTS.ts +++ b/apps/readest-app/src/libs/edgeTTS.ts @@ -143,8 +143,7 @@ const hashPayload = (payload: EdgeTTSPayload): string => { export class EdgeSpeechTTS { static voices = genVoiceList(EDGE_TTS_VOICES); - private static audioCache = new LRUCache(200); - private audioContext = new AudioContext(); + private static audioCache = new LRUCache(200); constructor() {} @@ -267,17 +266,16 @@ export class EdgeSpeechTTS { return this.#fetchEdgeSpeechWs(payload); } - async createAudio(payload: EdgeTTSPayload): Promise { + async createAudio(payload: EdgeTTSPayload): Promise { const cacheKey = hashPayload(payload); if (EdgeSpeechTTS.audioCache.has(cacheKey)) { - return EdgeSpeechTTS.audioCache.get(cacheKey)!; + return new Blob([EdgeSpeechTTS.audioCache.get(cacheKey)!], { type: 'audio/mpeg' }); } try { const res = await this.create(payload); const arrayBuffer = await res.arrayBuffer(); - const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer.slice(0)); - EdgeSpeechTTS.audioCache.set(cacheKey, audioBuffer); - return audioBuffer; + EdgeSpeechTTS.audioCache.set(cacheKey, arrayBuffer); + return new Blob([arrayBuffer], { type: 'audio/mpeg' }); } catch (error) { throw error; } diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 2ccb4920..1804290c 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -13,31 +13,19 @@ export class EdgeTTSClient implements TTSClient { #voices: TTSVoice[] = []; #edgeTTS: EdgeSpeechTTS; - static #audioContext: AudioContext | null; - #sourceNode: AudioBufferSourceNode | null = null; + #audioElement: HTMLAudioElement | null = null; #isPlaying = false; #pausedAt = 0; #startedAt = 0; - #audioBuffer: AudioBuffer | null = null; available = true; constructor() { this.#edgeTTS = new EdgeSpeechTTS(); } - async initializeAudioContext() { - if (!EdgeTTSClient.#audioContext) { - EdgeTTSClient.#audioContext = new AudioContext(); - } - if (EdgeTTSClient.#audioContext.state === 'suspended') { - await EdgeTTSClient.#audioContext.resume(); - } - } - async init() { this.#voices = EdgeSpeechTTS.voices; try { - await this.initializeAudioContext(); await this.#edgeTTS.create({ lang: 'en', text: 'test', @@ -115,15 +103,12 @@ export class EdgeTTSClient implements TTSClient { break; } try { - this.#audioBuffer = await this.#edgeTTS.createAudio( - this.getPayload(lang, mark.text, voiceId), - ); - if (!EdgeTTSClient.#audioContext) { - EdgeTTSClient.#audioContext = new AudioContext(); - } - this.#sourceNode = EdgeTTSClient.#audioContext.createBufferSource(); - this.#sourceNode.buffer = this.#audioBuffer; - this.#sourceNode.connect(EdgeTTSClient.#audioContext.destination); + const blob = await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)); + const url = URL.createObjectURL(blob); + this.#audioElement = new Audio(url); + const audio = this.#audioElement; + audio.setAttribute('x-webkit-airplay', 'deny'); + audio.preload = 'auto'; yield { code: 'boundary', @@ -132,36 +117,37 @@ export class EdgeTTSClient implements TTSClient { }; const result = await new Promise((resolve) => { - if (EdgeTTSClient.#audioContext === null || this.#sourceNode === null) { - throw new Error('Audio context or source node is null'); - } - this.#sourceNode.onended = (event: Event) => { - // chunk finished speaking or aborted speaking - if (signal.aborted || event.type === 'stopped') { - resolve({ - code: 'error', - message: 'Aborted', - }); - return; + const cleanUp = () => { + audio.onended = null; + audio.onerror = null; + audio.pause(); + audio.src = ''; + URL.revokeObjectURL(url); + }; + audio.onended = () => { + cleanUp(); + if (signal.aborted) { + resolve({ code: 'error', message: 'Aborted' }); + } else { + resolve({ code: 'end', message: `Chunk finished: ${mark.name}` }); } - resolve({ - code: 'end', - message: `Chunk finished: ${mark.name}`, - }); + }; + audio.onerror = (e) => { + cleanUp(); + console.warn('Audio playback error:', e); + resolve({ code: 'error', message: 'Audio playback error' }); }; if (signal.aborted) { - resolve({ - code: 'error', - message: 'Aborted', - }); + cleanUp(); + resolve({ code: 'error', message: 'Aborted' }); return; } - if (EdgeTTSClient.#audioContext.state === 'suspended') { - EdgeTTSClient.#audioContext.resume(); - } - this.#sourceNode.start(0); this.#isPlaying = true; - this.#startedAt = EdgeTTSClient.#audioContext.currentTime; + audio.play().catch((err) => { + cleanUp(); + console.error('Failed to play audio:', err); + resolve({ code: 'error', message: 'Playback failed: ' + err.message }); + }); }); yield result; } catch (error) { @@ -186,17 +172,17 @@ export class EdgeTTSClient implements TTSClient { } async pause() { - if (!this.#isPlaying || !EdgeTTSClient.#audioContext) return; - this.#pausedAt = EdgeTTSClient.#audioContext.currentTime - this.#startedAt; - await EdgeTTSClient.#audioContext.suspend(); + if (!this.#isPlaying || !this.#audioElement) return; + this.#pausedAt = this.#audioElement.currentTime - this.#startedAt; + await this.#audioElement.pause(); this.#isPlaying = false; } async resume() { - if (this.#isPlaying || !EdgeTTSClient.#audioContext) return; - await EdgeTTSClient.#audioContext.resume(); + if (this.#isPlaying || !this.#audioElement) return; + await this.#audioElement.play(); this.#isPlaying = true; - this.#startedAt = EdgeTTSClient.#audioContext.currentTime - this.#pausedAt; + this.#startedAt = this.#audioElement.currentTime - this.#pausedAt; } async stop() { @@ -207,21 +193,18 @@ export class EdgeTTSClient implements TTSClient { this.#isPlaying = false; this.#pausedAt = 0; this.#startedAt = 0; - if (this.#sourceNode) { - try { - this.#sourceNode.stop(); - if (this.#sourceNode?.onended) { - this.#sourceNode.onended(new Event('stopped')); - } - } catch (err) { - if (!(err instanceof Error) || err.name !== 'InvalidStateError') { - console.log('Error stopping source node:', err); - } + if (this.#audioElement) { + this.#audioElement.pause(); + this.#audioElement.currentTime = 0; + if (this.#audioElement?.onended) { + this.#audioElement.onended(new Event('stopped')); } - this.#sourceNode.disconnect(); - this.#sourceNode = null; + if (this.#audioElement.src?.startsWith('blob:')) { + URL.revokeObjectURL(this.#audioElement.src); + } + this.#audioElement.src = ''; + this.#audioElement = null; } - this.#audioBuffer = null; } async setRate(rate: number) { diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts index c9957cca..f83cc0bb 100644 --- a/apps/readest-app/src/utils/bridge.ts +++ b/apps/readest-app/src/utils/bridge.ts @@ -10,6 +10,10 @@ export interface CopyURIResponse { error?: string; } +export interface UseBackgroundAudioRequest { + enabled: boolean; +} + export async function copyURIToPath(request: CopyURIRequest): Promise { const result = await invoke('plugin:native-bridge|copy_uri_to_path', { payload: request, @@ -17,3 +21,9 @@ export async function copyURIToPath(request: CopyURIRequest): Promise { + await invoke('plugin:native-bridge|use_background_audio', { + payload: request, + }); +}