mirror of
https://github.com/Tony0410/readlater.git
synced 2026-05-24 22:01:41 +08:00
Add reading progress bar, better empty states, and Edge TTS
UI Improvements: - Reading progress bar at top of reader (tracks scroll position) - Better empty state with illustration and helpful tips - Edge TTS as default - fast streaming with Microsoft neural voices TTS Options: - Edge TTS (recommended): Fast, natural sounding, streams immediately - Kokoro: High quality but slower (generates full audio first) - Browser: Built-in fallback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,15 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/readlater.db
|
||||
|
||||
# Kokoro TTS for high-quality text-to-speech
|
||||
# Edge TTS - Fast streaming TTS using Microsoft's neural voices (recommended)
|
||||
edge-tts:
|
||||
image: travisvn/openai-edge-tts:latest
|
||||
container_name: edge-tts
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5050:5050"
|
||||
|
||||
# Kokoro TTS - High-quality but slower (generates full audio before playing)
|
||||
kokoro:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1
|
||||
container_name: kokoro-tts
|
||||
|
||||
@@ -23,9 +23,28 @@ export function ArticleList({
|
||||
}: ArticleListProps) {
|
||||
if (articles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted)]">
|
||||
<p>No articles yet</p>
|
||||
<p className="text-sm mt-2">Add a URL to get started</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="w-24 h-24 mb-6 text-[var(--muted)] opacity-50">
|
||||
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="15" y="10" width="50" height="65" rx="3" />
|
||||
<rect x="35" y="25" width="50" height="65" rx="3" />
|
||||
<line x1="25" y1="25" x2="55" y2="25" />
|
||||
<line x1="25" y1="35" x2="55" y2="35" />
|
||||
<line x1="25" y1="45" x2="45" y2="45" />
|
||||
<line x1="45" y1="40" x2="75" y2="40" />
|
||||
<line x1="45" y1="50" x2="75" y2="50" />
|
||||
<line x1="45" y1="60" x2="65" y2="60" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[var(--foreground)] mb-2">
|
||||
No articles yet
|
||||
</h3>
|
||||
<p className="text-[var(--muted)] text-sm max-w-xs mb-4">
|
||||
Save articles to read later by pasting a URL above or using the bookmarklet.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 text-xs text-[var(--muted)]">
|
||||
<span>💡 Tip: Use the Apple Shortcut to save from your phone</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Article, ReaderSettings } from "@/lib/types";
|
||||
import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink } from "lucide-react";
|
||||
|
||||
@@ -24,6 +25,21 @@ export function Reader({
|
||||
onOpenSettings,
|
||||
children,
|
||||
}: ReaderProps) {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||
setScrollProgress(Math.min(100, Math.max(0, progress)));
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const fontClass = {
|
||||
system: "font-system",
|
||||
serif: "font-serif",
|
||||
@@ -33,8 +49,16 @@ export function Reader({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Reading progress bar */}
|
||||
<div className="fixed top-0 left-0 right-0 h-1 bg-[var(--border)] z-50">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] transition-all duration-150"
|
||||
style={{ width: `${scrollProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-[var(--background)] border-b border-[var(--border)]">
|
||||
<header className="sticky top-1 z-10 bg-[var(--background)] border-b border-[var(--border)]">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
|
||||
@@ -191,11 +191,12 @@ export function SettingsPanel({
|
||||
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
|
||||
Text-to-Speech Engine
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "browser", label: "Browser" },
|
||||
{ value: "kokoro", label: "Kokoro" },
|
||||
].map(({ value, label }) => (
|
||||
{ value: "edge", label: "Edge", desc: "Fast, natural" },
|
||||
{ value: "kokoro", label: "Kokoro", desc: "High quality" },
|
||||
{ value: "browser", label: "Browser", desc: "Built-in" },
|
||||
].map(({ value, label, desc }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
@@ -204,13 +205,14 @@ export function SettingsPanel({
|
||||
engine: value as TTSSettings["engine"],
|
||||
})
|
||||
}
|
||||
className={`flex-1 p-3 rounded-lg border transition-colors ${
|
||||
className={`p-3 rounded-lg border transition-colors text-left ${
|
||||
ttsSettings.engine === value
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/10"
|
||||
: "border-[var(--border)] hover:border-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-xs text-[var(--muted)]">{desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -267,6 +269,30 @@ export function SettingsPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Edge TTS URL */}
|
||||
{ttsSettings.engine === "edge" && (
|
||||
<section className="mb-8">
|
||||
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
|
||||
Edge TTS URL
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={ttsSettings.edgeUrl}
|
||||
onChange={(e) =>
|
||||
onTTSSettingsChange({
|
||||
...ttsSettings,
|
||||
edgeUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="http://localhost:5050"
|
||||
className="w-full p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted)] mt-2">
|
||||
Uses Microsoft neural voices. Fast and natural sounding.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Kokoro URL */}
|
||||
{ttsSettings.engine === "kokoro" && (
|
||||
<section className="mb-8">
|
||||
@@ -286,7 +312,7 @@ export function SettingsPanel({
|
||||
className="w-full p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted)] mt-2">
|
||||
URL of your Kokoro-FastAPI server
|
||||
High quality but slower. Generates full audio before playing.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -114,6 +114,75 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
|
||||
speechSynthesis.speak(utterance);
|
||||
}, [settings.speed, settings.voice, voices]);
|
||||
|
||||
const playEdge = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${settings.edgeUrl}/v1/audio/speech`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "tts-1",
|
||||
input: textRef.current,
|
||||
voice: settings.voice || "en-US-AvaNeural",
|
||||
response_format: "mp3",
|
||||
speed: settings.speed,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Edge TTS API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
|
||||
const audio = new Audio(url);
|
||||
|
||||
audio.onloadedmetadata = () => {
|
||||
setDuration(audio.duration);
|
||||
};
|
||||
|
||||
audio.ontimeupdate = () => {
|
||||
setCurrentTime(audio.currentTime);
|
||||
};
|
||||
|
||||
audio.onplay = () => {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
setCurrentPosition(0);
|
||||
setCurrentTime(0);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
console.error("Audio playback error");
|
||||
setIsPlaying(false);
|
||||
setIsLoading(false);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
audioRef.current = audio;
|
||||
await audio.play();
|
||||
} catch (error) {
|
||||
console.error("Edge TTS error:", error);
|
||||
setIsLoading(false);
|
||||
alert("Failed to connect to Edge TTS. Make sure it's running at " + settings.edgeUrl);
|
||||
}
|
||||
}, [settings.edgeUrl, settings.speed, settings.voice]);
|
||||
|
||||
const playKokoro = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -187,10 +256,12 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
|
||||
const play = useCallback(() => {
|
||||
if (settings.engine === "browser") {
|
||||
playBrowser();
|
||||
} else if (settings.engine === "edge") {
|
||||
playEdge();
|
||||
} else {
|
||||
playKokoro();
|
||||
}
|
||||
}, [settings.engine, playBrowser, playKokoro]);
|
||||
}, [settings.engine, playBrowser, playEdge, playKokoro]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (settings.engine === "browser") {
|
||||
|
||||
@@ -54,9 +54,10 @@ export interface ReaderSettings {
|
||||
}
|
||||
|
||||
export interface TTSSettings {
|
||||
engine: "browser" | "kokoro";
|
||||
engine: "browser" | "edge" | "kokoro";
|
||||
speed: number; // 0.5-3.0
|
||||
voice: string;
|
||||
edgeUrl: string;
|
||||
kokoroUrl: string;
|
||||
}
|
||||
|
||||
@@ -89,8 +90,9 @@ export const defaultReaderSettings: ReaderSettings = {
|
||||
};
|
||||
|
||||
export const defaultTTSSettings: TTSSettings = {
|
||||
engine: "browser",
|
||||
engine: "edge",
|
||||
speed: 1.0,
|
||||
voice: "",
|
||||
edgeUrl: "http://localhost:5050",
|
||||
kokoroUrl: "http://localhost:8880",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user