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