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:
- 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

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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") {

View File

@@ -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",
};