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={() =>
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user