UI improvements: Reader controls and TTS player

- Add archive/delete/external-link buttons to Reader header
- Create floating TTS player bar with progress slider
- Add 15s skip forward/back controls
- Show current time and duration during playback
- Add busy_timeout to SQLite for concurrent access during build
- Checkpoint WAL after migrations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-18 01:34:39 +00:00
parent 8f059b8690
commit 9dafda88a9
6 changed files with 209 additions and 56 deletions

View File

@@ -208,16 +208,26 @@ export default function Home() {
onToggleFavorite={() => onToggleFavorite={() =>
handleToggleFavorite(selectedArticle.id, !selectedArticle.isFavorite) handleToggleFavorite(selectedArticle.id, !selectedArticle.isFavorite)
} }
onToggleArchive={() => {
handleToggleArchive(selectedArticle.id, !selectedArticle.isArchived);
}}
onDelete={() => {
handleDelete(selectedArticle.id);
}}
onOpenSettings={() => setIsSettingsOpen(true)} onOpenSettings={() => setIsSettingsOpen(true)}
> >
<TTSControls <TTSControls
isPlaying={tts.isPlaying} isPlaying={tts.isPlaying}
isPaused={tts.isPaused} isPaused={tts.isPaused}
isLoading={tts.isLoading} isLoading={tts.isLoading}
currentTime={tts.currentTime}
duration={tts.duration}
progress={tts.progress}
onPlay={tts.play} onPlay={tts.play}
onPause={tts.pause} onPause={tts.pause}
onResume={tts.resume} onResume={tts.resume}
onStop={tts.stop} onStop={tts.stop}
onSeek={tts.seek}
/> />
</Reader> </Reader>
<SettingsPanel <SettingsPanel

View File

@@ -1,13 +1,15 @@
"use client"; "use client";
import { Article, ReaderSettings } from "@/lib/types"; import { Article, ReaderSettings } from "@/lib/types";
import { ArrowLeft, Star, Settings } from "lucide-react"; import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink } from "lucide-react";
interface ReaderProps { interface ReaderProps {
article: Article; article: Article;
settings: ReaderSettings; settings: ReaderSettings;
onBack: () => void; onBack: () => void;
onToggleFavorite: () => void; onToggleFavorite: () => void;
onToggleArchive: () => void;
onDelete: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
children?: React.ReactNode; // TTS controls slot children?: React.ReactNode; // TTS controls slot
} }
@@ -17,6 +19,8 @@ export function Reader({
settings, settings,
onBack, onBack,
onToggleFavorite, onToggleFavorite,
onToggleArchive,
onDelete,
onOpenSettings, onOpenSettings,
children, children,
}: ReaderProps) { }: ReaderProps) {
@@ -39,8 +43,9 @@ export function Reader({
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
<span className="hidden sm:inline">Back</span> <span className="hidden sm:inline">Back</span>
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{children} {children}
<div className="w-px h-6 bg-[var(--border)] mx-1" />
<button <button
onClick={onToggleFavorite} onClick={onToggleFavorite}
className={`p-2 rounded transition-colors ${ className={`p-2 rounded transition-colors ${
@@ -52,6 +57,38 @@ export function Reader({
> >
<Star className="w-5 h-5" fill={article.isFavorite ? "currentColor" : "none"} /> <Star className="w-5 h-5" fill={article.isFavorite ? "currentColor" : "none"} />
</button> </button>
<button
onClick={onToggleArchive}
className={`p-2 rounded transition-colors ${
article.isArchived
? "text-green-500"
: "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
title={article.isArchived ? "Unarchive" : "Archive"}
>
<Archive className="w-5 h-5" />
</button>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
title="Open original"
>
<ExternalLink className="w-5 h-5" />
</a>
<button
onClick={() => {
if (confirm("Delete this article?")) {
onDelete();
}
}}
className="p-2 rounded text-[var(--muted)] hover:text-red-500 transition-colors"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
<div className="w-px h-6 bg-[var(--border)] mx-1" />
<button <button
onClick={onOpenSettings} onClick={onOpenSettings}
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] transition-colors" className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"

View File

@@ -1,79 +1,150 @@
"use client"; "use client";
import { Play, Pause, Square, Loader2, Volume2 } from "lucide-react"; import { Play, Pause, Square, Loader2, Volume2, SkipBack, SkipForward } from "lucide-react";
interface TTSControlsProps { interface TTSControlsProps {
isPlaying: boolean; isPlaying: boolean;
isPaused: boolean; isPaused: boolean;
isLoading: boolean; isLoading: boolean;
currentTime: number;
duration: number;
progress: number;
onPlay: () => void; onPlay: () => void;
onPause: () => void; onPause: () => void;
onResume: () => void; onResume: () => void;
onStop: () => 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({ export function TTSControls({
isPlaying, isPlaying,
isPaused, isPaused,
isLoading, isLoading,
currentTime,
duration,
progress,
onPlay, onPlay,
onPause, onPause,
onResume, onResume,
onStop, onStop,
onSeek,
}: TTSControlsProps) { }: TTSControlsProps) {
const showPlayer = isPlaying || isPaused || isLoading;
// Simple header button when not playing
if (!showPlayer) {
return (
<button
onClick={onPlay}
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
title="Read aloud"
>
<Volume2 className="w-5 h-5" />
</button>
);
}
// Floating player bar when playing
return ( return (
<div className="flex items-center gap-1"> <>
{isLoading ? ( {/* Header button to show it's active */}
<button <button
disabled className="p-2 rounded text-[var(--accent)]"
className="p-2 rounded text-[var(--muted)]" title="Audio playing"
title="Loading..." >
> <Volume2 className="w-5 h-5" />
<Loader2 className="w-5 h-5 animate-spin" /> </button>
</button>
) : isPlaying && !isPaused ? ( {/* Floating player bar */}
<> <div className="fixed bottom-0 left-0 right-0 bg-[var(--surface)] border-t border-[var(--border)] px-4 py-3 z-50">
<button <div className="max-w-3xl mx-auto">
onClick={onPause} {/* Progress bar */}
className="p-2 rounded text-[var(--accent)] hover:bg-[var(--accent)]/10 transition-colors" <div className="flex items-center gap-3 mb-2">
title="Pause" <span className="text-xs text-[var(--muted)] w-10 text-right">
> {formatTime(currentTime)}
<Pause className="w-5 h-5" /> </span>
</button> <div className="flex-1 relative h-1 bg-[var(--border)] rounded-full overflow-hidden">
<button <div
onClick={onStop} className="absolute left-0 top-0 h-full bg-[var(--accent)] transition-all duration-200"
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors" style={{ width: `${progress}%` }}
title="Stop" />
> <input
<Square className="w-5 h-5" /> type="range"
</button> min="0"
</> max={duration || 100}
) : isPaused ? ( value={currentTime}
<> onChange={(e) => onSeek(parseFloat(e.target.value))}
<button className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onClick={onResume} disabled={!duration}
className="p-2 rounded text-[var(--accent)] hover:bg-[var(--accent)]/10 transition-colors" />
title="Resume" </div>
> <span className="text-xs text-[var(--muted)] w-10">
<Play className="w-5 h-5" /> {formatTime(duration)}
</button> </span>
<button </div>
onClick={onStop}
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors" {/* Controls */}
title="Stop" <div className="flex items-center justify-center gap-2">
> <button
<Square className="w-5 h-5" /> onClick={() => onSeek(Math.max(0, currentTime - 15))}
</button> className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--background)] transition-colors"
</> title="Back 15s"
) : ( disabled={!duration}
<button >
onClick={onPlay} <SkipBack className="w-5 h-5" />
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors" </button>
title="Read aloud"
> {isLoading ? (
<Volume2 className="w-5 h-5" /> <button
</button> disabled
)} className="p-3 rounded-full bg-[var(--accent)] text-white"
</div> >
<Loader2 className="w-6 h-6 animate-spin" />
</button>
) : isPaused ? (
<button
onClick={onResume}
className="p-3 rounded-full bg-[var(--accent)] text-white hover:opacity-90 transition-opacity"
title="Resume"
>
<Play className="w-6 h-6" />
</button>
) : (
<button
onClick={onPause}
className="p-3 rounded-full bg-[var(--accent)] text-white hover:opacity-90 transition-opacity"
title="Pause"
>
<Pause className="w-6 h-6" />
</button>
)}
<button
onClick={() => 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}
>
<SkipForward className="w-5 h-5" />
</button>
<button
onClick={onStop}
className="p-2 rounded text-[var(--muted)] hover:text-red-500 hover:bg-red-500/10 transition-colors ml-4"
title="Stop"
>
<Square className="w-5 h-5" />
</button>
</div>
</div>
</div>
</>
); );
} }

View File

@@ -13,10 +13,14 @@ interface UseTTSReturn {
isPaused: boolean; isPaused: boolean;
isLoading: boolean; isLoading: boolean;
currentPosition: number; currentPosition: number;
currentTime: number;
duration: number;
progress: number; // 0-100
play: () => void; play: () => void;
pause: () => void; pause: () => void;
resume: () => void; resume: () => void;
stop: () => void; stop: () => void;
seek: (time: number) => void;
voices: SpeechSynthesisVoice[]; voices: SpeechSynthesisVoice[];
} }
@@ -25,6 +29,8 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentPosition, setCurrentPosition] = useState(0); const [currentPosition, setCurrentPosition] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]); const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null); const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
@@ -140,6 +146,14 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
const audio = new Audio(url); const audio = new Audio(url);
audio.playbackRate = 1; // Speed is already applied by Kokoro audio.playbackRate = 1; // Speed is already applied by Kokoro
audio.onloadedmetadata = () => {
setDuration(audio.duration);
};
audio.ontimeupdate = () => {
setCurrentTime(audio.currentTime);
};
audio.onplay = () => { audio.onplay = () => {
setIsPlaying(true); setIsPlaying(true);
setIsPaused(false); setIsPaused(false);
@@ -150,6 +164,7 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
setIsPlaying(false); setIsPlaying(false);
setIsPaused(false); setIsPaused(false);
setCurrentPosition(0); setCurrentPosition(0);
setCurrentTime(0);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
@@ -208,17 +223,32 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
setIsPlaying(false); setIsPlaying(false);
setIsPaused(false); setIsPaused(false);
setCurrentPosition(0); setCurrentPosition(0);
setCurrentTime(0);
setDuration(0);
}, [settings.engine]); }, [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 { return {
isPlaying, isPlaying,
isPaused, isPaused,
isLoading, isLoading,
currentPosition, currentPosition,
currentTime,
duration,
progress,
play, play,
pause, pause,
resume, resume,
stop, stop,
seek,
voices, voices,
}; };
} }

View File

@@ -7,6 +7,7 @@ const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), "data", "re
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL"); sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");
export const db = drizzle(sqlite, { schema }); export const db = drizzle(sqlite, { schema });
export { schema }; export { schema };

View File

@@ -13,10 +13,14 @@ if (!fs.existsSync(dataDir)) {
} }
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");
const db = drizzle(sqlite); const db = drizzle(sqlite);
console.log("Running migrations..."); console.log("Running migrations...");
migrate(db, { migrationsFolder: "./drizzle" }); migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations complete!"); console.log("Migrations complete!");
// Checkpoint WAL to ensure clean state for build
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.close(); sqlite.close();