Add background playback toggle and fix tab-switch audio overlap
This commit is contained in:
parent
64825b626a
commit
d04ab733d2
7 changed files with 75 additions and 11 deletions
|
|
@ -28,6 +28,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
const {
|
const {
|
||||||
viewType,
|
viewType,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
|
keepPlayingInBackground,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
|
|
@ -316,6 +317,20 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
Automatically skip pages with no text content
|
Automatically skip pages with no text content
|
||||||
</p>
|
</p>
|
||||||
</div>}
|
</div>}
|
||||||
|
{!html && <div className="space-y-1">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepPlayingInBackground}
|
||||||
|
onChange={(e) => updateConfigKey('keepPlayingInBackground', e.target.checked)}
|
||||||
|
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">Keep playing in background</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted pl-6">
|
||||||
|
Continue TTS playback when this tab is not active
|
||||||
|
</p>
|
||||||
|
</div>}
|
||||||
{!html && (
|
{!html && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center space-x-2">
|
<label className="flex items-center space-x-2">
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,16 @@ export function SettingsModal() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
const {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
ttsInstructions,
|
||||||
|
keepPlayingInBackground,
|
||||||
|
updateConfig,
|
||||||
|
updateConfigKey
|
||||||
|
} = useConfig();
|
||||||
const { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
|
const { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||||
|
|
@ -674,6 +683,21 @@ export function SettingsModal() {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel className="space-y-4">
|
<TabPanel className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepPlayingInBackground}
|
||||||
|
onChange={(e) => updateConfigKey('keepPlayingInBackground', e.target.checked)}
|
||||||
|
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">Keep playing in background</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted pl-6">
|
||||||
|
Continue TTS playback when this tab is not active
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Server Library Import</label>
|
<label className="block text-sm font-medium text-foreground">Server Library Import</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ interface ConfigContextType {
|
||||||
audioPlayerSpeed: number;
|
audioPlayerSpeed: number;
|
||||||
voice: string;
|
voice: string;
|
||||||
skipBlank: boolean;
|
skipBlank: boolean;
|
||||||
|
keepPlayingInBackground: boolean;
|
||||||
epubTheme: boolean;
|
epubTheme: boolean;
|
||||||
smartSentenceSplitting: boolean;
|
smartSentenceSplitting: boolean;
|
||||||
headerMargin: number;
|
headerMargin: number;
|
||||||
|
|
@ -162,6 +163,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
audioPlayerSpeed,
|
audioPlayerSpeed,
|
||||||
voice,
|
voice,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
|
keepPlayingInBackground,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
footerMargin,
|
footerMargin,
|
||||||
|
|
@ -258,6 +260,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
audioPlayerSpeed,
|
audioPlayerSpeed,
|
||||||
voice,
|
voice,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
|
keepPlayingInBackground,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsProvider: configTTSProvider,
|
ttsProvider: configTTSProvider,
|
||||||
ttsModel: configTTSModel,
|
ttsModel: configTTSModel,
|
||||||
ttsInstructions: configTTSInstructions,
|
ttsInstructions: configTTSInstructions,
|
||||||
|
keepPlayingInBackground,
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
|
@ -1141,7 +1142,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const isBackgrounded = useBackgroundState({
|
const isBackgrounded = useBackgroundState({
|
||||||
activeHowl,
|
activeHowl,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
playAudio,
|
keepPlayingInBackground,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the current word index during playback using Howler's seek position
|
// Track the current word index during playback using Howler's seek position
|
||||||
|
|
@ -1221,7 +1222,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
if (isProcessing) return; // Don't proceed if processing audio
|
if (isProcessing) return; // Don't proceed if processing audio
|
||||||
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
|
if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play
|
||||||
if (activeHowl) return; // Don't proceed if audio is already playing
|
if (activeHowl) return; // Don't proceed if audio is already playing
|
||||||
if (isBackgrounded) return; // Don't proceed if backgrounded
|
if (isBackgrounded && !keepPlayingInBackground) return; // Don't proceed if background playback is disabled
|
||||||
|
|
||||||
// Start playing current sentence
|
// Start playing current sentence
|
||||||
playAudio();
|
playAudio();
|
||||||
|
|
@ -1242,6 +1243,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
sentences,
|
sentences,
|
||||||
activeHowl,
|
activeHowl,
|
||||||
isBackgrounded,
|
isBackgrounded,
|
||||||
|
keepPlayingInBackground,
|
||||||
playAudio,
|
playAudio,
|
||||||
preloadNextAudio,
|
preloadNextAudio,
|
||||||
abortAudio
|
abortAudio
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,25 @@ import { Howl } from 'howler';
|
||||||
interface UseBackgroundStateProps {
|
interface UseBackgroundStateProps {
|
||||||
activeHowl: Howl | null;
|
activeHowl: Howl | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
playAudio: () => void;
|
keepPlayingInBackground: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackgroundState({ activeHowl, isPlaying, playAudio }: UseBackgroundStateProps) {
|
export function useBackgroundState({ activeHowl, isPlaying, keepPlayingInBackground }: UseBackgroundStateProps) {
|
||||||
const [isBackgrounded, setIsBackgrounded] = useState(false);
|
const [isBackgrounded, setIsBackgrounded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setIsBackgrounded(document.hidden);
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
setIsBackgrounded(document.hidden);
|
setIsBackgrounded(document.hidden);
|
||||||
if (document.hidden) {
|
if (document.hidden && !keepPlayingInBackground) {
|
||||||
// When backgrounded, pause audio but maintain isPlaying state
|
// When backgrounded, pause audio but maintain isPlaying state
|
||||||
if (activeHowl) {
|
if (activeHowl?.playing()) {
|
||||||
activeHowl.pause();
|
activeHowl.pause();
|
||||||
}
|
}
|
||||||
} else if (isPlaying) {
|
} else if (isPlaying) {
|
||||||
// When returning to foreground, resume from current position
|
// When returning to foreground, resume from current position
|
||||||
if (activeHowl) {
|
if (activeHowl && !activeHowl.playing()) {
|
||||||
activeHowl.play();
|
activeHowl.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +32,22 @@ export function useBackgroundState({ activeHowl, isPlaying, playAudio }: UseBack
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [isPlaying, activeHowl, playAudio]);
|
}, [isPlaying, activeHowl, keepPlayingInBackground]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!document.hidden || !activeHowl || !isPlaying) return;
|
||||||
|
|
||||||
|
if (keepPlayingInBackground) {
|
||||||
|
if (!activeHowl.playing()) {
|
||||||
|
activeHowl.play();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeHowl.playing()) {
|
||||||
|
activeHowl.pause();
|
||||||
|
}
|
||||||
|
}, [activeHowl, isPlaying, keepPlayingInBackground]);
|
||||||
|
|
||||||
return isBackgrounded;
|
return isBackgrounded;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,8 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed,
|
audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed,
|
||||||
voice: '',
|
voice: '',
|
||||||
skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank,
|
skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank,
|
||||||
|
keepPlayingInBackground:
|
||||||
|
raw.keepPlayingInBackground === 'false' ? false : APP_CONFIG_DEFAULTS.keepPlayingInBackground,
|
||||||
epubTheme: raw.epubTheme === 'true',
|
epubTheme: raw.epubTheme === 'true',
|
||||||
smartSentenceSplitting:
|
smartSentenceSplitting:
|
||||||
raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting,
|
raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting,
|
||||||
|
|
@ -1008,4 +1010,3 @@ export async function importDocumentsFromLibrary(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface AppConfigValues {
|
||||||
audioPlayerSpeed: number;
|
audioPlayerSpeed: number;
|
||||||
voice: string;
|
voice: string;
|
||||||
skipBlank: boolean;
|
skipBlank: boolean;
|
||||||
|
keepPlayingInBackground: boolean;
|
||||||
epubTheme: boolean;
|
epubTheme: boolean;
|
||||||
headerMargin: number;
|
headerMargin: number;
|
||||||
footerMargin: number;
|
footerMargin: number;
|
||||||
|
|
@ -40,6 +41,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
audioPlayerSpeed: 1,
|
audioPlayerSpeed: 1,
|
||||||
voice: '',
|
voice: '',
|
||||||
skipBlank: true,
|
skipBlank: true,
|
||||||
|
keepPlayingInBackground: true,
|
||||||
epubTheme: false,
|
epubTheme: false,
|
||||||
headerMargin: 0,
|
headerMargin: 0,
|
||||||
footerMargin: 0,
|
footerMargin: 0,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue