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,
+ });
+}