diff --git a/src/contexts/HTMLContext.tsx b/src/contexts/HTMLContext.tsx
index f46ef62..0ea5416 100644
--- a/src/contexts/HTMLContext.tsx
+++ b/src/contexts/HTMLContext.tsx
@@ -4,11 +4,12 @@ import {
createContext,
useContext,
useState,
+ useEffect,
ReactNode,
useCallback,
useMemo,
} from 'react';
-import { getHtmlDocument } from '@/lib/dexie';
+import { getHtmlDocument, getLastChapterIndex, setLastDocumentLocation } from '@/lib/dexie';
import { useTTS } from '@/contexts/TTSContext';
import { detectChapters, type Chapter } from '@/lib/chapterDetection';
import type { TTSAudiobookChapter } from '@/types/tts';
@@ -59,6 +60,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
const [currDocData, setCurrDocData] = useState();
const [currDocName, setCurrDocName] = useState();
const [currDocText, setCurrDocText] = useState();
+ const [currDocId, setCurrDocId] = useState();
// Chapter state
const [chapters, setChapters] = useState([]);
@@ -71,6 +73,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
+ setCurrDocId(undefined);
setChapters([]);
setCurrentChapterIndex(0);
stop();
@@ -86,6 +89,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
try {
const doc = await getHtmlDocument(id);
if (doc) {
+ setCurrDocId(id);
setCurrDocName(doc.name);
// Detect chapters in the document (splits large files)
@@ -95,16 +99,23 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
// Document has been split into chapters
console.log(`Document split into ${detectedChapters.length} chapters`);
setChapters(detectedChapters);
- setCurrentChapterIndex(0);
- // Display only the first chapter
- const firstChapter = detectedChapters[0];
- setCurrDocData(firstChapter.text);
- setCurrDocText(firstChapter.text);
- setTTSText(firstChapter.text);
+ // Restore last chapter position or start from beginning
+ const lastChapterIndex = await getLastChapterIndex(id);
+ const startChapterIndex = lastChapterIndex !== null && lastChapterIndex < detectedChapters.length ? lastChapterIndex : 0;
+ setCurrentChapterIndex(startChapterIndex);
+
+ // Display the restored or first chapter
+ const startChapter = detectedChapters[startChapterIndex];
+ setCurrDocData(startChapter.text);
+ setCurrDocText(startChapter.text);
+ setTTSText(startChapter.text);
+
+ console.log(`Restored to chapter ${startChapterIndex + 1} of ${detectedChapters.length}`);
} else {
// Small document, no chapters needed
setChapters([]);
+ setCurrentChapterIndex(0);
setCurrDocData(doc.data);
setCurrDocText(doc.data);
setTTSText(doc.data);
@@ -229,6 +240,15 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
}
}, [chapters.length, registerLocationChangeHandler, handleLocationChange]);
+ // Save chapter position whenever it changes
+ useEffect(() => {
+ if (currDocId && chapters.length > 1) {
+ // Save current chapter index to remember position
+ setLastDocumentLocation(currDocId, String(currentChapterIndex), currentChapterIndex)
+ .catch(error => console.error('Failed to save chapter position:', error));
+ }
+ }, [currDocId, currentChapterIndex, chapters.length]);
+
const totalChapters = chapters.length;
const contextValue = useMemo(() => ({
diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx
index b553bd3..0b5d82a 100644
--- a/src/contexts/TTSContext.tsx
+++ b/src/contexts/TTSContext.tsx
@@ -1466,13 +1466,27 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
currentWordIndex
]);
- // Use media session hook
- useMediaSession({
- togglePlay,
- skipForward,
- skipBackward,
+ // Media Session API for background playback and lock screen controls
+ const { setPlaybackState } = useMediaSession({
+ metadata: {
+ title: id ? `Document ${String(id).slice(0, 8)}...` : 'Text-to-Speech',
+ artist: 'OpenReader WebUI',
+ album: 'Audiobook',
+ },
+ controls: {
+ togglePlay,
+ skipForward,
+ skipBackward,
+ // Chapter navigation will be handled by document contexts if available
+ },
+ enabled: true,
});
+ // Update playback state when isPlaying changes
+ useEffect(() => {
+ setPlaybackState(isPlaying ? 'playing' : 'paused');
+ }, [isPlaying, setPlaybackState]);
+
// Load last location on mount for both EPUB and PDF
useEffect(() => {
if (id) {
diff --git a/src/hooks/audio/useBackgroundState.ts b/src/hooks/audio/useBackgroundState.ts
index 12c608b..7e43af7 100644
--- a/src/hooks/audio/useBackgroundState.ts
+++ b/src/hooks/audio/useBackgroundState.ts
@@ -7,23 +7,23 @@ interface UseBackgroundStateProps {
playAudio: () => void;
}
+/**
+ * Hook to track background state and maintain audio context
+ *
+ * NOTE: This hook does NOT pause audio when backgrounded.
+ * Audio continues playing in background tabs and when screen is off.
+ * This is enabled by the Media Session API for cross-browser support.
+ */
export function useBackgroundState({ activeHowl, isPlaying, playAudio }: UseBackgroundStateProps) {
const [isBackgrounded, setIsBackgrounded] = useState(false);
useEffect(() => {
const handleVisibilityChange = () => {
setIsBackgrounded(document.hidden);
- if (document.hidden) {
- // When backgrounded, pause audio but maintain isPlaying state
- if (activeHowl) {
- activeHowl.pause();
- }
- } else if (isPlaying) {
- // When returning to foreground, resume from current position
- if (activeHowl) {
- activeHowl.play();
- }
- }
+
+ // Do NOT pause when backgrounded - allow continuous playback
+ // Media Session API handles lock screen controls and background playback
+ // Howler.js will continue playing even when page is hidden
};
document.addEventListener('visibilitychange', handleVisibilityChange);
diff --git a/src/hooks/audio/useMediaSession.ts b/src/hooks/audio/useMediaSession.ts
index 698857e..0f75ece 100644
--- a/src/hooks/audio/useMediaSession.ts
+++ b/src/hooks/audio/useMediaSession.ts
@@ -1,30 +1,118 @@
'use client';
-import { useEffect } from 'react';
+import { useEffect, useCallback } from 'react';
-interface MediaControls {
+export interface MediaSessionMetadata {
+ title: string;
+ artist?: string;
+ album?: string;
+ artwork?: Array<{
+ src: string;
+ sizes?: string;
+ type?: string;
+ }>;
+}
+
+export interface MediaSessionControls {
togglePlay: () => void;
skipForward: () => void;
skipBackward: () => void;
+ nextChapter?: () => void;
+ previousChapter?: () => void;
+}
+
+interface UseMediaSessionProps {
+ metadata?: MediaSessionMetadata;
+ controls: MediaSessionControls;
+ enabled?: boolean;
}
/**
- * Custom hook for managing media session controls
- * @param controls Object containing media control functions
+ * Custom hook for managing Media Session API
+ * Enables background playback, lock screen controls, and system media key support
+ *
+ * Features:
+ * - Dynamic metadata (title, artist, artwork)
+ * - Lock screen media controls on mobile
+ * - Background tab playback in Firefox
+ * - System media key support (keyboard, headphones)
+ * - Chapter navigation for audiobooks
*/
-export function useMediaSession(controls: MediaControls) {
- useEffect(() => {
+export function useMediaSession({ metadata, controls, enabled = true }: UseMediaSessionProps) {
+ // Update playback state
+ const setPlaybackState = useCallback((state: MediaSessionPlaybackState) => {
if ('mediaSession' in navigator) {
- navigator.mediaSession.metadata = new MediaMetadata({
- title: 'Text-to-Speech',
- artist: 'OpenReader WebUI',
- album: 'Current Document',
- });
-
- navigator.mediaSession.setActionHandler('play', () => controls.togglePlay());
- navigator.mediaSession.setActionHandler('pause', () => controls.togglePlay());
- navigator.mediaSession.setActionHandler('nexttrack', () => controls.skipForward());
- navigator.mediaSession.setActionHandler('previoustrack', () => controls.skipBackward());
+ try {
+ navigator.mediaSession.playbackState = state;
+ } catch (error) {
+ console.warn('Failed to set playback state:', error);
+ }
}
- }, [controls]);
+ }, []);
+
+ useEffect(() => {
+ if (!enabled || !('mediaSession' in navigator)) {
+ return;
+ }
+
+ // Set metadata
+ try {
+ navigator.mediaSession.metadata = new MediaMetadata({
+ title: metadata?.title || 'Text-to-Speech',
+ artist: metadata?.artist || 'OpenReader WebUI',
+ album: metadata?.album || 'Audiobook',
+ artwork: metadata?.artwork || [
+ {
+ src: '/icon.png',
+ sizes: '512x512',
+ type: 'image/png',
+ },
+ ],
+ });
+ } catch (error) {
+ console.warn('Failed to set media session metadata:', error);
+ }
+
+ // Set action handlers
+ try {
+ navigator.mediaSession.setActionHandler('play', controls.togglePlay);
+ navigator.mediaSession.setActionHandler('pause', controls.togglePlay);
+ navigator.mediaSession.setActionHandler('seekbackward', controls.skipBackward);
+ navigator.mediaSession.setActionHandler('seekforward', controls.skipForward);
+
+ // Chapter navigation (optional)
+ if (controls.nextChapter) {
+ navigator.mediaSession.setActionHandler('nexttrack', controls.nextChapter);
+ } else {
+ navigator.mediaSession.setActionHandler('nexttrack', controls.skipForward);
+ }
+
+ if (controls.previousChapter) {
+ navigator.mediaSession.setActionHandler('previoustrack', controls.previousChapter);
+ } else {
+ navigator.mediaSession.setActionHandler('previoustrack', controls.skipBackward);
+ }
+ } catch (error) {
+ console.warn('Failed to set media session action handlers:', error);
+ }
+
+ // Cleanup on unmount
+ return () => {
+ if ('mediaSession' in navigator) {
+ try {
+ navigator.mediaSession.setActionHandler('play', null);
+ navigator.mediaSession.setActionHandler('pause', null);
+ navigator.mediaSession.setActionHandler('seekbackward', null);
+ navigator.mediaSession.setActionHandler('seekforward', null);
+ navigator.mediaSession.setActionHandler('nexttrack', null);
+ navigator.mediaSession.setActionHandler('previoustrack', null);
+ navigator.mediaSession.metadata = null;
+ } catch (error) {
+ // Ignore cleanup errors
+ }
+ }
+ };
+ }, [metadata, controls, enabled]);
+
+ return { setPlaybackState };
}
diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts
index b573467..94f8f09 100644
--- a/src/lib/dexie.ts
+++ b/src/lib/dexie.ts
@@ -18,6 +18,7 @@ const JOBS_TABLE = 'jobs' as const;
export interface LastLocationRow {
docId: string;
location: string;
+ chapterIndex?: number; // For HTML/text documents with chapters
}
export interface ConfigRow {
@@ -392,9 +393,16 @@ export async function getLastDocumentLocation(docId: string): Promise {
+export async function setLastDocumentLocation(docId: string, location: string, chapterIndex?: number): Promise {
await withDB(async () => {
- await db[LAST_LOCATION_TABLE].put({ docId, location });
+ await db[LAST_LOCATION_TABLE].put({ docId, location, chapterIndex });
+ });
+}
+
+export async function getLastChapterIndex(docId: string): Promise {
+ return withDB(async () => {
+ const row = await db[LAST_LOCATION_TABLE].get(docId);
+ return row?.chapterIndex ?? null;
});
}