Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
34a637d
feat: add premium upgrade checklist to settings
Copilot Apr 10, 2026
c69cb2a
refactor: memoize settings upgrade diagnostics
Copilot Apr 10, 2026
23c1dfd
refactor: simplify settings upgrade status updates
Copilot Apr 10, 2026
911c57c
style: polish settings upgrade checklist logic
Copilot Apr 10, 2026
8af3827
fix: finalize settings upgrade checklist polish
Copilot Apr 10, 2026
aaf5126
feat: add quick-start onboarding and home upgrade cards
Copilot Apr 10, 2026
96be48b
fix: handle demo article sync fallback
Copilot Apr 10, 2026
3e5b481
refactor: polish demo onboarding flow
Copilot Apr 10, 2026
0af34e2
fix: refresh home diagnostics status
Copilot Apr 10, 2026
2836b01
feat: add playback health guidance
Copilot Apr 11, 2026
55e8c85
refactor: share playback diagnostics helpers
Copilot Apr 11, 2026
03e12eb
feat: add playback recovery actions
Copilot Apr 11, 2026
68473ea
feat: show recent playback logs in player
Copilot Apr 11, 2026
7eb1afd
refactor: share playback start setup
Copilot Apr 11, 2026
3de2e1f
refactor: tidy playback session helper
Copilot Apr 11, 2026
7f423e6
docs: clarify playback session startup
Copilot Apr 11, 2026
6d5bcdc
security: avoid persisting api keys
Copilot Apr 11, 2026
13a3702
docs: note in-memory api key tradeoff
Copilot Apr 11, 2026
80076b1
i18n: translate settings diagnostics strings
Copilot Apr 11, 2026
e227c84
fix: polish diagnostics translation keys
Copilot Apr 11, 2026
6dd8939
refactor: address follow-up review cleanup
Copilot Apr 11, 2026
76f6794
docs: clarify async follow-up intent
Copilot Apr 11, 2026
5625285
chore: polish follow-up review notes
Copilot Apr 11, 2026
8376d99
docs: polish wording follow-ups
Copilot Apr 11, 2026
f559cbd
fix: resolve blocking lint errors
Copilot Apr 11, 2026
ee42668
fix: harden diagnostics audio probe
Copilot Apr 11, 2026
4a2eedd
fix: clean up remaining hook warnings
Copilot Apr 11, 2026
14668e1
fix: harden playback hook follow-ups
Copilot Apr 11, 2026
5f999fc
fix: document playback voice restart timing
Copilot Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions src/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { BookOpen, Plus, Play, Sparkles, Rocket, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/hooks/useLanguage';

const ONBOARDING_KEY = 'onboarding-completed';
import { completeOnboarding } from '@/lib/onboarding';

const steps = [
{ icon: BookOpen, titleKey: 'onboardingWelcomeTitle' as const, descKey: 'onboardingWelcomeDesc' as const },
Expand All @@ -14,16 +13,18 @@ const steps = [
{ icon: Rocket, titleKey: 'onboardingStartTitle' as const, descKey: 'onboardingStartDesc' as const },
];

export function shouldShowOnboarding(): boolean {
return !localStorage.getItem(ONBOARDING_KEY);
}

export function OnboardingTour({ onComplete }: { onComplete: () => void }) {
export function OnboardingTour({ onComplete, onTryDemo }: { onComplete: () => void; onTryDemo?: () => void }) {
const [step, setStep] = useState(0);
const { t } = useLanguage();

const finish = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
completeOnboarding();
onComplete();
};

const finishAndTryDemo = () => {
completeOnboarding();
onTryDemo?.();
onComplete();
};

Expand Down Expand Up @@ -91,9 +92,16 @@ export function OnboardingTour({ onComplete }: { onComplete: () => void }) {
</Button>
)}
{isLast ? (
<Button size="sm" onClick={finish} className="gap-1 px-5 h-8">
{t('onboardingDone')}
</Button>
<div className="flex items-center gap-2">
{onTryDemo && (
<Button size="sm" variant="outline" onClick={finishAndTryDemo} className="gap-1 h-8">
{t('onboardingTryDemo')}
</Button>
)}
<Button size="sm" onClick={finish} className="gap-1 px-5 h-8">
{t('onboardingDone')}
</Button>
</div>
) : (
<Button size="sm" onClick={() => setStep(step + 1)} className="gap-1 h-8">
{t('onboardingNext')}
Expand All @@ -105,4 +113,4 @@ export function OnboardingTour({ onComplete }: { onComplete: () => void }) {
</motion.div>
</div>
);
}
}
2 changes: 1 addition & 1 deletion src/components/ui/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const Command = React.forwardRef<
));
Command.displayName = CommandPrimitive.displayName;

interface CommandDialogProps extends DialogProps {}
type CommandDialogProps = DialogProps;

const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from "react";

import { cn } from "@/lib/utils";

export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
Expand Down
118 changes: 99 additions & 19 deletions src/hooks/useTTS.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
WebSpeechTTS, OpenAITTS, splitIntoParagraphs, splitIntoSentences,
TTSEngine, OpenAIVoice, getOpenAIVoices, detectLanguage,
Expand All @@ -14,6 +14,7 @@ import { uploadProgressDebounced } from '@/lib/auto-sync';
import { detectDevice, getTTSLimits, diagLog } from '@/lib/diagnostics';

const MAX_RETRIES = 2;
const VOICE_CHANGE_DELAY_MS = 100;

/** Filter to zh + en only, sort: zh-TW first, then other zh, then en */
function filterAndSortVoices(voices: SpeechSynthesisVoice[]): SpeechSynthesisVoice[] {
Expand Down Expand Up @@ -46,17 +47,24 @@ export function useTTS(article: Article | null) {
const [selectedVoice, setSelectedVoice] = useState<SpeechSynthesisVoice | null>(null);
const [speed, setSpeed] = useState(() => getGlobalSpeed());
const articleRef = useRef(article);
const selectedVoiceRef = useRef<SpeechSynthesisVoice | null>(null);
const playingRef = useRef(false);
const retryCountRef = useRef(0);
const onFinishedRef = useRef<(() => void) | null>(null);
const playStartTimeRef = useRef<number>(0);
const engineTypeRef = useRef(engineType);
const paragraphIndexRef = useRef(0);
const sentenceIndexRef = useRef(0);
const speakSentenceRef = useRef<(pIdx: number, sIdx: number) => void>(() => {});

// Device-aware TTS limits
const deviceRef = useRef(detectDevice());
const ttsLimits = useRef(getTTSLimits(deviceRef.current));

const paragraphs = article ? splitIntoParagraphs(article.content) : [];
const paragraphs = useMemo(
() => (article ? splitIntoParagraphs(article.content) : []),
[article]
);
const paragraphsRef = useRef(paragraphs);
paragraphsRef.current = paragraphs;

Expand Down Expand Up @@ -97,15 +105,27 @@ export function useTTS(article: Article | null) {
articleRef.current = article;
}, [article]);

useEffect(() => {
selectedVoiceRef.current = selectedVoice;
}, [selectedVoice]);

useEffect(() => {
paragraphIndexRef.current = paragraphIndex;
}, [paragraphIndex]);

useEffect(() => {
sentenceIndexRef.current = sentenceIndex;
}, [sentenceIndex]);

// Load voices (sorted: zh-TW first)
useEffect(() => {
const loadVoices = () => {
const raw = webTTSRef.current.getVoices();
const sorted = filterAndSortVoices(raw);
setVoices(sorted);
if (!selectedVoice && sorted.length > 0) {
if (!selectedVoiceRef.current && sorted.length > 0) {
// Auto-detect language from article content and pick matching voice
const lang = article ? detectLanguage(article.content) : 'zh';
const lang = articleRef.current ? detectLanguage(articleRef.current.content) : 'zh';
let preferred: SpeechSynthesisVoice | undefined;
if (lang === 'en') {
preferred = sorted.find((v) => v.lang.startsWith('en'));
Expand All @@ -132,7 +152,7 @@ export function useTTS(article: Article | null) {
if (v) setSelectedVoice(v);
}
}
}, [article?.id]);
}, [article]);

const saveProgress = useCallback(
(pIdx: number, sIdx: number) => {
Expand Down Expand Up @@ -274,13 +294,30 @@ export function useTTS(article: Article | null) {
[speed, selectedVoice, saveProgress, getEngine, prefetchNext]
);

const play = useCallback(() => {
setIsPlaying(true);
playingRef.current = true;
useEffect(() => {
speakSentenceRef.current = speakSentence;
}, [speakSentence]);

/**
* Normalizes playback startup for both fresh play and sentence replay.
* `restartTimer` is true for a brand-new play request, but false when replaying
* the current sentence so existing listening-time tracking can continue.
*/
const startPlaybackSession = useCallback((restartTimer = false) => {
retryCountRef.current = 0;
playStartTimeRef.current = Date.now();
if (!playingRef.current) {
setIsPlaying(true);
playingRef.current = true;
}
if (restartTimer || playStartTimeRef.current === 0) {
playStartTimeRef.current = Date.now();
}
}, []);

const play = useCallback(() => {
startPlaybackSession(true);
speakSentence(paragraphIndex, sentenceIndex);
}, [paragraphIndex, sentenceIndex, speakSentence]);
}, [paragraphIndex, sentenceIndex, speakSentence, startPlaybackSession]);

const pause = useCallback(() => {
setIsPlaying(false);
Expand All @@ -300,6 +337,36 @@ export function useTTS(article: Article | null) {
else play();
}, [isPlaying, play, pause]);

const replayCurrentSentence = useCallback(() => {
getEngine().stop();
startPlaybackSession();
saveProgress(paragraphIndex, sentenceIndex);
speakSentence(paragraphIndex, sentenceIndex);
}, [paragraphIndex, sentenceIndex, saveProgress, speakSentence, getEngine, startPlaybackSession]);

const skipCurrentSentence = useCallback(() => {
const sentences = paragraphIndex < paragraphs.length
? splitIntoSentences(paragraphs[paragraphIndex], ttsLimits.current.maxUtteranceLength)
: [];
let nextParagraphIndex = paragraphIndex;
let nextSentenceIndex = sentenceIndex + 1;

if (nextSentenceIndex >= sentences.length) {
nextParagraphIndex = paragraphIndex + 1;
nextSentenceIndex = 0;
}

getEngine().stop();
retryCountRef.current = 0;
setParagraphIndex(nextParagraphIndex);
setSentenceIndex(nextSentenceIndex);
saveProgress(nextParagraphIndex, nextSentenceIndex);

if (playingRef.current) {
speakSentence(nextParagraphIndex, nextSentenceIndex);
}
}, [paragraphIndex, sentenceIndex, paragraphs, saveProgress, speakSentence, getEngine]);

const skipForward = useCallback(() => {
const nextP = Math.min(paragraphIndex + 1, paragraphs.length - 1);
getEngine().stop();
Expand Down Expand Up @@ -421,19 +488,30 @@ export function useTTS(article: Article | null) {

// Re-speak when voice changes during playback (speed is handled in changeSpeed)
useEffect(() => {
if (isPlaying && playingRef.current) {
getEngine().stop();
setTimeout(() => {
if (playingRef.current) speakSentence(paragraphIndex, sentenceIndex);
}, 100);
}
}, [selectedVoice]);
if (!playingRef.current) return;

const engine = getEngine();
engine.stop();
const currentParagraphIndex = paragraphIndexRef.current;
const currentSentenceIndex = sentenceIndexRef.current;

// Give the previous utterance a brief moment to fully stop before replaying
// with the newly selected voice.
setTimeout(() => {
if (playingRef.current) {
speakSentenceRef.current(currentParagraphIndex, currentSentenceIndex);
}
}, VOICE_CHANGE_DELAY_MS);
}, [selectedVoice, getEngine]);

// Cleanup
useEffect(() => {
const webTTS = webTTSRef.current;
const openaiTTS = openaiTTSRef.current;

return () => {
webTTSRef.current.stop();
openaiTTSRef.current?.stop();
webTTS.stop();
openaiTTS?.stop();
};
}, []);

Expand All @@ -452,6 +530,8 @@ export function useTTS(article: Article | null) {
speed,
changeSpeed,
togglePlay,
replayCurrentSentence,
skipCurrentSentence,
skipForward,
skipBackward,
seekToParagraph,
Expand Down
Loading