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:
Gemini Agent
2026-01-18 01:46:03 +00:00
parent 9dafda88a9
commit 576b0ae6a6
6 changed files with 165 additions and 15 deletions

View File

@@ -10,7 +10,15 @@ services:
environment: environment:
- DATABASE_PATH=/app/data/readlater.db - 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: kokoro:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1 image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1
container_name: kokoro-tts container_name: kokoro-tts

View File

@@ -23,9 +23,28 @@ export function ArticleList({
}: ArticleListProps) { }: ArticleListProps) {
if (articles.length === 0) { if (articles.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted)]"> <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<p>No articles yet</p> <div className="w-24 h-24 mb-6 text-[var(--muted)] opacity-50">
<p className="text-sm mt-2">Add a URL to get started</p> <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> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { Article, ReaderSettings } from "@/lib/types"; import { Article, ReaderSettings } from "@/lib/types";
import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink } from "lucide-react"; import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink } from "lucide-react";
@@ -24,6 +25,21 @@ export function Reader({
onOpenSettings, onOpenSettings,
children, children,
}: ReaderProps) { }: 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 = { const fontClass = {
system: "font-system", system: "font-system",
serif: "font-serif", serif: "font-serif",
@@ -33,8 +49,16 @@ export function Reader({
return ( return (
<div className="min-h-screen"> <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 */}
<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"> <div className="flex items-center justify-between px-4 py-3">
<button <button
onClick={onBack} onClick={onBack}

View File

@@ -191,11 +191,12 @@ export function SettingsPanel({
<h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4"> <h3 className="text-sm font-medium text-[var(--muted)] uppercase tracking-wide mb-4">
Text-to-Speech Engine Text-to-Speech Engine
</h3> </h3>
<div className="flex gap-2"> <div className="grid grid-cols-3 gap-2">
{[ {[
{ value: "browser", label: "Browser" }, { value: "edge", label: "Edge", desc: "Fast, natural" },
{ value: "kokoro", label: "Kokoro" }, { value: "kokoro", label: "Kokoro", desc: "High quality" },
].map(({ value, label }) => ( { value: "browser", label: "Browser", desc: "Built-in" },
].map(({ value, label, desc }) => (
<button <button
key={value} key={value}
onClick={() => onClick={() =>
@@ -204,13 +205,14 @@ export function SettingsPanel({
engine: value as TTSSettings["engine"], 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 ttsSettings.engine === value
? "border-[var(--accent)] bg-[var(--accent)]/10" ? "border-[var(--accent)] bg-[var(--accent)]/10"
: "border-[var(--border)] hover:border-[var(--muted)]" : "border-[var(--border)] hover:border-[var(--muted)]"
}`} }`}
> >
{label} <div className="font-medium">{label}</div>
<div className="text-xs text-[var(--muted)]">{desc}</div>
</button> </button>
))} ))}
</div> </div>
@@ -267,6 +269,30 @@ export function SettingsPanel({
</section> </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 */} {/* Kokoro URL */}
{ttsSettings.engine === "kokoro" && ( {ttsSettings.engine === "kokoro" && (
<section className="mb-8"> <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)]" 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"> <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> </p>
</section> </section>
)} )}

View File

@@ -114,6 +114,75 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
}, [settings.speed, settings.voice, voices]); }, [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 () => { const playKokoro = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -187,10 +256,12 @@ export function useTTS({ settings, text }: UseTTSOptions): UseTTSReturn {
const play = useCallback(() => { const play = useCallback(() => {
if (settings.engine === "browser") { if (settings.engine === "browser") {
playBrowser(); playBrowser();
} else if (settings.engine === "edge") {
playEdge();
} else { } else {
playKokoro(); playKokoro();
} }
}, [settings.engine, playBrowser, playKokoro]); }, [settings.engine, playBrowser, playEdge, playKokoro]);
const pause = useCallback(() => { const pause = useCallback(() => {
if (settings.engine === "browser") { if (settings.engine === "browser") {

View File

@@ -54,9 +54,10 @@ export interface ReaderSettings {
} }
export interface TTSSettings { export interface TTSSettings {
engine: "browser" | "kokoro"; engine: "browser" | "edge" | "kokoro";
speed: number; // 0.5-3.0 speed: number; // 0.5-3.0
voice: string; voice: string;
edgeUrl: string;
kokoroUrl: string; kokoroUrl: string;
} }
@@ -89,8 +90,9 @@ export const defaultReaderSettings: ReaderSettings = {
}; };
export const defaultTTSSettings: TTSSettings = { export const defaultTTSSettings: TTSSettings = {
engine: "browser", engine: "edge",
speed: 1.0, speed: 1.0,
voice: "", voice: "",
edgeUrl: "http://localhost:5050",
kokoroUrl: "http://localhost:8880", kokoroUrl: "http://localhost:8880",
}; };