diff --git a/src/app/page.tsx b/src/app/page.tsx index 0c532ab..7e17c07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -208,16 +208,26 @@ export default function Home() { onToggleFavorite={() => handleToggleFavorite(selectedArticle.id, !selectedArticle.isFavorite) } + onToggleArchive={() => { + handleToggleArchive(selectedArticle.id, !selectedArticle.isArchived); + }} + onDelete={() => { + handleDelete(selectedArticle.id); + }} onOpenSettings={() => setIsSettingsOpen(true)} > void; onToggleFavorite: () => void; + onToggleArchive: () => void; + onDelete: () => void; onOpenSettings: () => void; children?: React.ReactNode; // TTS controls slot } @@ -17,6 +19,8 @@ export function Reader({ settings, onBack, onToggleFavorite, + onToggleArchive, + onDelete, onOpenSettings, children, }: ReaderProps) { @@ -39,8 +43,9 @@ export function Reader({ Back - + {children} + + + + + + + + { + if (confirm("Delete this article?")) { + onDelete(); + } + }} + className="p-2 rounded text-[var(--muted)] hover:text-red-500 transition-colors" + title="Delete" + > + + + void; onPause: () => void; onResume: () => void; onStop: () => void; + onSeek: (time: number) => void; +} + +function formatTime(seconds: number): string { + if (!seconds || !isFinite(seconds)) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; } export function TTSControls({ isPlaying, isPaused, isLoading, + currentTime, + duration, + progress, onPlay, onPause, onResume, onStop, + onSeek, }: TTSControlsProps) { + const showPlayer = isPlaying || isPaused || isLoading; + + // Simple header button when not playing + if (!showPlayer) { + return ( + + + + ); + } + + // Floating player bar when playing return ( - - {isLoading ? ( - - - - ) : isPlaying && !isPaused ? ( - <> - - - - - - - > - ) : isPaused ? ( - <> - - - - - - - > - ) : ( - - - - )} - + <> + {/* Header button to show it's active */} + + + + + {/* Floating player bar */} + + + {/* Progress bar */} + + + {formatTime(currentTime)} + + + + onSeek(parseFloat(e.target.value))} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + disabled={!duration} + /> + + + {formatTime(duration)} + + + + {/* Controls */} + + onSeek(Math.max(0, currentTime - 15))} + className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--background)] transition-colors" + title="Back 15s" + disabled={!duration} + > + + + + {isLoading ? ( + + + + ) : isPaused ? ( + + + + ) : ( + + + + )} + + onSeek(Math.min(duration, currentTime + 15))} + className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--background)] transition-colors" + title="Forward 15s" + disabled={!duration} + > + + + + + + + + + + > ); } diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index 38b6f74..0fa6e22 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -13,10 +13,14 @@ interface UseTTSReturn { isPaused: boolean; isLoading: boolean; currentPosition: number; + currentTime: number; + duration: number; + progress: number; // 0-100 play: () => void; pause: () => void; resume: () => void; stop: () => void; + seek: (time: number) => void; voices: SpeechSynthesisVoice[]; } @@ -25,6 +29,8 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn { const [isPaused, setIsPaused] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentPosition, setCurrentPosition] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); const [voices, setVoices] = useState([]); const utteranceRef = useRef(null); @@ -140,6 +146,14 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn { const audio = new Audio(url); audio.playbackRate = 1; // Speed is already applied by Kokoro + audio.onloadedmetadata = () => { + setDuration(audio.duration); + }; + + audio.ontimeupdate = () => { + setCurrentTime(audio.currentTime); + }; + audio.onplay = () => { setIsPlaying(true); setIsPaused(false); @@ -150,6 +164,7 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn { setIsPlaying(false); setIsPaused(false); setCurrentPosition(0); + setCurrentTime(0); URL.revokeObjectURL(url); }; @@ -208,17 +223,32 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn { setIsPlaying(false); setIsPaused(false); setCurrentPosition(0); + setCurrentTime(0); + setDuration(0); }, [settings.engine]); + const seek = useCallback((time: number) => { + if (audioRef.current) { + audioRef.current.currentTime = time; + setCurrentTime(time); + } + }, []); + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + return { isPlaying, isPaused, isLoading, currentPosition, + currentTime, + duration, + progress, play, pause, resume, stop, + seek, voices, }; } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 9e60b82..8a7df3a 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -7,6 +7,7 @@ const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), "data", "re const sqlite = new Database(dbPath); sqlite.pragma("journal_mode = WAL"); +sqlite.pragma("busy_timeout = 5000"); export const db = drizzle(sqlite, { schema }); export { schema }; diff --git a/src/lib/db/migrate.ts b/src/lib/db/migrate.ts index c492d3d..7c39647 100644 --- a/src/lib/db/migrate.ts +++ b/src/lib/db/migrate.ts @@ -13,10 +13,14 @@ if (!fs.existsSync(dataDir)) { } const sqlite = new Database(dbPath); +sqlite.pragma("journal_mode = WAL"); +sqlite.pragma("busy_timeout = 5000"); const db = drizzle(sqlite); console.log("Running migrations..."); migrate(db, { migrationsFolder: "./drizzle" }); console.log("Migrations complete!"); +// Checkpoint WAL to ensure clean state for build +sqlite.pragma("wal_checkpoint(TRUNCATE)"); sqlite.close();