mirror of
https://github.com/Tony0410/readlater.git
synced 2026-05-24 22:01:41 +08:00
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:
@@ -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)}
|
||||
>
|
||||
<TTSControls
|
||||
isPlaying={tts.isPlaying}
|
||||
isPaused={tts.isPaused}
|
||||
isLoading={tts.isLoading}
|
||||
currentTime={tts.currentTime}
|
||||
duration={tts.duration}
|
||||
progress={tts.progress}
|
||||
onPlay={tts.play}
|
||||
onPause={tts.pause}
|
||||
onResume={tts.resume}
|
||||
onStop={tts.stop}
|
||||
onSeek={tts.seek}
|
||||
/>
|
||||
</Reader>
|
||||
<SettingsPanel
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
article: Article;
|
||||
settings: ReaderSettings;
|
||||
onBack: () => 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({
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
<div className="w-px h-6 bg-[var(--border)] mx-1" />
|
||||
<button
|
||||
onClick={onToggleFavorite}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
@@ -52,6 +57,38 @@ export function Reader({
|
||||
>
|
||||
<Star className="w-5 h-5" fill={article.isFavorite ? "currentColor" : "none"} />
|
||||
</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
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
|
||||
@@ -1,71 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Play, Pause, Square, Loader2, Volume2 } from "lucide-react";
|
||||
import { Play, Pause, Square, Loader2, Volume2, SkipBack, SkipForward } from "lucide-react";
|
||||
|
||||
interface TTSControlsProps {
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
isLoading: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
progress: number;
|
||||
onPlay: () => 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 (
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<button
|
||||
disabled
|
||||
className="p-2 rounded text-[var(--muted)]"
|
||||
title="Loading..."
|
||||
>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</button>
|
||||
) : isPlaying && !isPaused ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="p-2 rounded text-[var(--accent)] hover:bg-[var(--accent)]/10 transition-colors"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : isPaused ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="p-2 rounded text-[var(--accent)] hover:bg-[var(--accent)]/10 transition-colors"
|
||||
title="Resume"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onPlay}
|
||||
className="p-2 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
|
||||
@@ -73,7 +48,103 @@ export function TTSControls({
|
||||
>
|
||||
<Volume2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Floating player bar when playing
|
||||
return (
|
||||
<>
|
||||
{/* Header button to show it's active */}
|
||||
<button
|
||||
className="p-2 rounded text-[var(--accent)]"
|
||||
title="Audio playing"
|
||||
>
|
||||
<Volume2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* 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">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Progress bar */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xs text-[var(--muted)] w-10 text-right">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<div className="flex-1 relative h-1 bg-[var(--border)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-[var(--accent)] transition-all duration-200"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
onChange={(e) => onSeek(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={!duration}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted)] w-10">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => 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}
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{isLoading ? (
|
||||
<button
|
||||
disabled
|
||||
className="p-3 rounded-full bg-[var(--accent)] text-white"
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SpeechSynthesisVoice[]>([]);
|
||||
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user