Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/Reader/Reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2999,6 +2999,10 @@ export const Reader: React.FC<ReaderProps> = ({ book, onBack }) => {
}}
bookId={book.id}
chapterId={currentChapter.id}
bookTitle={book.title}
bookAuthor={book.author}
chapterTitle={currentChapter.title}
coverImage={book.cover}
autoPlayChapterId={ttsAutoPlayChapterId}
onChapterEnd={handleTTSChapterEnd}
compact={false}
Expand Down
1 change: 1 addition & 0 deletions src/components/Reader/TTSPlayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ vi.mock('../../core/tts/player', () => ({
pause: vi.fn(),
play: vi.fn(),
hasAudioForSentence: vi.fn(() => false),
armMediaSession: vi.fn(() => Promise.resolve()),
},
}));

Expand Down
75 changes: 73 additions & 2 deletions src/components/Reader/TTSPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../../core/tts';
import { ttsPlayer } from '../../core/tts/player';
import { persistListeningHandoff } from '../../core/exchange/handoff';
import { useTTSMediaSession } from '../../core/tts/useMediaSession';

// Configuration
const DEFAULT_BUFFER_AHEAD = 5;
Expand Down Expand Up @@ -103,6 +104,11 @@ interface TTSPlayerProps {
/** Book and chapter IDs for position tracking */
bookId?: string;
chapterId?: string;
/** Displayed in the OS media session (lock screen, notification, headset) */
bookTitle?: string;
bookAuthor?: string;
chapterTitle?: string;
coverImage?: string;
/** Chapter ID requested by the reader for automatic continuation */
autoPlayChapterId?: string | null;
/** Called when the final sentence in this chapter has finished */
Expand All @@ -122,6 +128,10 @@ export const TTSPlayer: React.FC<TTSPlayerProps> = ({
onPositionCommit,
bookId,
chapterId,
bookTitle,
bookAuthor,
chapterTitle,
coverImage,
autoPlayChapterId = null,
onChapterEnd,
compact = false,
Expand Down Expand Up @@ -356,7 +366,15 @@ export const TTSPlayer: React.FC<TTSPlayerProps> = ({
ttsPlayer.dispose();
};
}, [commitCurrentPosition]);


// Register with the OS media session as soon as the panel opens, so the system
// Play button is already there and ready before the user taps play in-app.
useEffect(() => {
void ttsPlayer.armMediaSession().catch(() => {
// Non-fatal: the in-app play button still starts playback normally.
});
}, []);

// Generate audio starting from a sentence index
const generateFrom = useCallback(async (fromSentenceIndex: number, sentenceCount: number) => {
if (isGeneratingRef.current || sentences.length === 0) return;
Expand Down Expand Up @@ -428,7 +446,39 @@ export const TTSPlayer: React.FC<TTSPlayerProps> = ({
await ttsPlayer.play(sentenceIndex, 1);
void generateFrom(sentenceIndex, startupBufferSize);
}, [generateFrom, handleInit, safeBufferAhead, sentences]);


// Move the reading position forward/backward by a number of words, e.g. from
// OS media session seek buttons. Restarts audio from the new sentence if playing.
const skipWords = useCallback((deltaWords: number) => {
if (words.length === 0 || sentences.length === 0) return;

const readerWordIndex = getCurrentWordIndex?.() ?? currentWordIndex;
const targetIndex = Math.max(0, Math.min(words.length - 1, readerWordIndex + deltaWords));
if (targetIndex === readerWordIndex) return;

const wasActive = playbackState === 'playing' || playbackState === 'generating' || playbackState === 'preparing';
if (wasActive) ttsPlayer.pause();

onPositionCommit?.(targetIndex);

if (wasActive) {
// startFromSentence() below sets ttsStore.currentWordIndex itself. Leaving it
// untouched while paused keeps it pointing at the old position, so the next
// resume (handleToggle) notices the reader has moved and reseeks instead of
// continuing from ttsPlayer's stale internal position.
const sentenceIndex = findNearestSentenceIndex(sentences, targetIndex);
if (sentenceIndex >= 0) {
ttsPlayer.clearQueue();
void startFromSentence(sentenceIndex).catch((err) => {
console.error('[TTS UI] Skip failed:', err);
const message = err instanceof Error ? err.message : 'Audio playback failed.';
useTTSStore.getState().setError(message);
ttsPlayer.stop();
});
}
}
}, [words.length, sentences, getCurrentWordIndex, currentWordIndex, playbackState, onPositionCommit, startFromSentence]);

// Set up player callbacks
useEffect(() => {
ttsPlayer.setOptions({
Expand Down Expand Up @@ -579,6 +629,27 @@ export const TTSPlayer: React.FC<TTSPlayerProps> = ({
}, []);
const currentVoice = getVoice(effectiveVoice);

const handlePlayAction = useCallback(() => {
if (playbackState === 'playing') return;
void handleToggle();
}, [playbackState, handleToggle]);

const handlePauseAction = useCallback(() => {
ttsPlayer.pause();
}, []);

useTTSMediaSession({
playbackState,
title: bookTitle || chapterTitle || currentVoice?.name || 'Listening',
artist: bookAuthor || currentVoice?.name,
album: chapterTitle && chapterTitle !== bookTitle ? chapterTitle : undefined,
artwork: coverImage,
onPlay: handlePlayAction,
onPause: handlePauseAction,
onStop: handleStop,
onSkip: skipWords,
});

// Button content
const getButtonContent = () => {
if (isLoading) return <LoadingSpinner />;
Expand Down
92 changes: 87 additions & 5 deletions src/core/tts/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,49 @@ interface ScheduledAudio {
ended: boolean;
}

// Chromium and Firefox both appear to exclude a raw AudioContext graph — even piped
// through an <audio> element via a live MediaStreamDestination — from the OS-level
// media session bridge (macOS Now Playing, hardware media keys, Bluetooth controls),
// the same way a live WebRTC call is deliberately kept out of it. A genuinely
// file-backed (blob URL) <audio> element sidesteps that. It's played in parallel with
// the real TTS output purely to register the tab as playing real media — its content
// is true digital silence (all-zero samples), not just quiet noise: low-amplitude
// noise was audible as static/hum on some devices (notably iOS Safari), and a random
// noise loop has no zero-crossing at the seam, causing an audible click every loop.
const DECOY_AUDIO_DURATION_SECONDS = 2;
const DECOY_AUDIO_SAMPLE_RATE = 8000;

let decoyAudioBlobUrl: string | null = null;

const getDecoyAudioBlobUrl = (): string => {
if (!decoyAudioBlobUrl) {
const sampleCount = DECOY_AUDIO_DURATION_SECONDS * DECOY_AUDIO_SAMPLE_RATE;
const samples = new Float32Array(sampleCount);
decoyAudioBlobUrl = URL.createObjectURL(audioToWavBlob(samples, DECOY_AUDIO_SAMPLE_RATE));
}
return decoyAudioBlobUrl;
};

const createDecoyAudioElement = (): HTMLAudioElement | null => {
if (typeof document === 'undefined') return null;

try {
const audioEl = document.createElement('audio');
audioEl.src = getDecoyAudioBlobUrl();
audioEl.loop = true;
audioEl.style.display = 'none';
document.body.appendChild(audioEl);
return audioEl;
} catch (error) {
console.error('[TTS Player] Failed to create decoy audio element:', error);
return null;
}
};

class TTSAudioPlayer {
private audioContext: AudioContext | null = null;
private gainNode: GainNode | null = null;
private decoyAudioElement: HTMLAudioElement | null = null;
private currentSource: AudioBufferSourceNode | null = null;
private scheduledSources: Map<number, ScheduledAudio> = new Map();

Expand Down Expand Up @@ -147,15 +187,16 @@ class TTSAudioPlayer {
this.audioContext = new AudioContextConstructor();
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
this.decoyAudioElement = createDecoyAudioElement();
}

if (this.audioContext.state === 'suspended' && typeof this.audioContext.resume === 'function') {
await this.audioContext.resume();
}

return this.audioContext;
}

setOptions(options: AudioPlayerOptions): void {
this.options = options;
}
Expand Down Expand Up @@ -250,6 +291,38 @@ class TTSAudioPlayer {
checkBuffer(): void {
this.options.onBufferLow?.(this.currentSentenceIndex);
}

// jsdom (used in tests) doesn't implement HTMLMediaElement.play(), returning
// undefined instead of a Promise — real browsers always return one.
private playDecoyQuietly(): void {
const result = this.decoyAudioElement?.play();
result?.catch?.(() => {
// OS media-key routing won't register without this, but TTS keeps playing regardless.
});
}

/**
* Registers the tab with the OS media session before the user presses play, so the
* OS-level Play button is already there and ready to kick off real playback via the
* MediaSession 'play' action handler. Chromium appears to derive its own play/pause
* indicator from whether the decoy element is actually playing, not just from the
* mediaSession.playbackState property — so it's played just long enough to register,
* then immediately paused again, ending in a state that honestly reflects that
* nothing is playing yet.
*/
async armMediaSession(): Promise<void> {
await this.ensureContext();
const decoy = this.decoyAudioElement;
if (!decoy) return;

try {
await decoy.play();
} catch {
// Will be retried once play() below runs from an actual user-gesture-driven click.
}
decoy.pause();
decoy.currentTime = 0;
}

hasAudioForSentence(sentenceIndex: number): boolean {
return this.audioQueue.has(sentenceIndex);
Expand Down Expand Up @@ -279,7 +352,9 @@ class TTSAudioPlayer {
this.isPlaying = true;
const hasAudio = this.audioQueue.has(this.currentSentenceIndex);
logTTSPlayerDebug(`[TTS Player] Starting playback from sentence ${this.currentSentenceIndex} (hasAudio: ${hasAudio}, queueSize: ${this.audioQueue.size})`);


this.playDecoyQuietly();

this.playCurrentSentence();
}

Expand Down Expand Up @@ -598,7 +673,8 @@ class TTSAudioPlayer {
}
this.isPlaying = false;
this.stopWordTracking();

this.decoyAudioElement?.pause();

if (this.currentSource) {
try {
this.currentSource.stop();
Expand Down Expand Up @@ -663,6 +739,12 @@ class TTSAudioPlayer {
this.stop();
this.clearQueue();

if (this.decoyAudioElement) {
this.decoyAudioElement.pause();
this.decoyAudioElement.remove();
this.decoyAudioElement = null;
}

if (this.audioContext) {
this.audioContext.close().catch(console.error);
this.audioContext = null;
Expand Down
85 changes: 85 additions & 0 deletions src/core/tts/useMediaSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Wires TTS playback into the OS-level media session (lock screen, notification
* shade, Bluetooth headset buttons, hardware media keys) via the browser's
* Media Session API.
*/

import { useEffect } from 'react';
import type { TTSPlaybackState } from '../store/tts';

const MEDIA_SESSION_SKIP_WORDS = 10;

export interface TTSMediaSessionOptions {
playbackState: TTSPlaybackState;
title: string;
artist?: string;
album?: string;
artwork?: string;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onSkip: (deltaWords: number) => void;
}

const hasMediaSession = (): boolean =>
typeof navigator !== 'undefined' && 'mediaSession' in navigator;

const artworkMimeType = (dataUrl: string): string =>
/^data:([^;,]+)/.exec(dataUrl)?.[1] || 'image/jpeg';

const FALLBACK_ARTWORK = { src: '/icon-512.png', sizes: '512x512', type: 'image/png' };

export function useTTSMediaSession(options: TTSMediaSessionOptions): void {
const { playbackState, title, artist, album, artwork, onPlay, onPause, onStop, onSkip } = options;

useEffect(() => {
if (!hasMediaSession()) return;

const session = navigator.mediaSession;
session.setActionHandler('play', onPlay);
session.setActionHandler('pause', onPause);
session.setActionHandler('stop', onStop);
session.setActionHandler('seekbackward', () => onSkip(-MEDIA_SESSION_SKIP_WORDS));
session.setActionHandler('seekforward', () => onSkip(MEDIA_SESSION_SKIP_WORDS));
session.setActionHandler('previoustrack', () => onSkip(-MEDIA_SESSION_SKIP_WORDS));
session.setActionHandler('nexttrack', () => onSkip(MEDIA_SESSION_SKIP_WORDS));

return () => {
session.setActionHandler('play', null);
session.setActionHandler('pause', null);
session.setActionHandler('stop', null);
session.setActionHandler('seekbackward', null);
session.setActionHandler('seekforward', null);
session.setActionHandler('previoustrack', null);
session.setActionHandler('nexttrack', null);
};
}, [onPlay, onPause, onStop, onSkip]);

// While this hook is mounted (the Audio panel is open) the OS should always see a
// ready session — 'idle' still shows a pressable Play button, not "nothing playing".
useEffect(() => {
if (!hasMediaSession()) return;
navigator.mediaSession.playbackState = playbackState === 'playing' ? 'playing' : 'paused';
}, [playbackState]);

useEffect(() => {
if (!hasMediaSession() || typeof MediaMetadata === 'undefined') return;

navigator.mediaSession.metadata = new MediaMetadata({
title,
artist: artist ?? '',
album: album ?? '',
artwork: [artwork ? { src: artwork, sizes: '512x512', type: artworkMimeType(artwork) } : FALLBACK_ARTWORK],
});
}, [title, artist, album, artwork]);

// Closing the Audio panel or leaving the book unmounts this hook — the OS session
// must disappear with it, since there's nothing left listening to its buttons.
useEffect(() => {
return () => {
if (!hasMediaSession()) return;
navigator.mediaSession.playbackState = 'none';
navigator.mediaSession.metadata = null;
};
}, []);
}
Loading