From 34a637d3f2d972524cbb915b7e2375216b80b162 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:39:17 +0000 Subject: [PATCH 01/29] feat: add premium upgrade checklist to settings Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/1dcb4948-935d-4b46-bf80-4418dfcda69d Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 52 +++++++++++++++++ src/pages/SettingsPage.tsx | 112 ++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 61a9202..aa541b7 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -203,6 +203,32 @@ const translations = { onboardingStartDesc: '準備好了!點擊「新增文章」開始你的聆聽之旅', onboardingNext: '下一步', onboardingDone: '開始使用', + upgradeChecklistTitle: '升級檢查清單', + upgradeChecklistHint: '把免費工具升級成付費級體驗,先把這四個面向補齊。', + upgradeScoreLabel: '就緒分數', + upgradePlaybackTitle: '播放穩定度', + upgradePlaybackSetup: '這台裝置目前不支援瀏覽器語音,需改用支援 TTS 的瀏覽器或裝置。', + upgradePlaybackAttention: '{browser} 最近累積 {count} 筆播放錯誤,建議先查看診斷記錄。', + upgradePlaybackReady: '{browser} / {os} 已具備即時朗讀能力,可直接拿來播放長文。', + upgradeAiTitle: 'AI 語音與摘要', + upgradeAiSetup: '尚未設定 API Key,AI 摘要、自然語音與 MP3 匯出都還沒解鎖。', + upgradeAiAttention: '目前只解鎖摘要能力;若要對齊競品的高品質語音與 MP3 體驗,建議切到 OpenAI。', + upgradeAiReady: '已解鎖 AI 摘要、自然語音與 MP3 能力,具備付費方案主打價值。', + upgradeSyncTitle: '跨裝置同步', + upgradeSyncSetup: '還沒登入帳號,文章、進度與公開分享頁無法跨裝置延續。', + upgradeSyncReady: '已登入,可同步文章、播放進度與公開個人頁,體驗更接近 Matter / Pocket。', + upgradeLibraryTitle: '內容導入便利性', + upgradeLibrarySetup: '資料庫還是空的,建議先匯入第一篇文章驗證整條使用流程。', + upgradeLibraryReady: '目前已累積 {count} 篇文章,具備可持續使用的內容庫基礎。', + upgradeStatusReady: '已就緒', + upgradeStatusAttention: '待優化', + upgradeStatusSetup: '待設定', + upgradeNextActionsTitle: '下一步建議', + upgradeActionAddFirstArticle: '先新增一篇示範文章,確認導入、朗讀、續聽流程都順。', + upgradeActionAddApiKey: '補上 API Key,優先解鎖 AI 摘要、自然語音與 MP3 匯出。', + upgradeActionSwitchToOpenai: '若目標是付費級語音體驗,建議把 AI 供應商切到 OpenAI。', + upgradeActionCreateAccount: '完成註冊 / 登入,補齊跨裝置同步與公開分享能力。', + upgradeActionReviewDiagnostics: '近期有播放錯誤,先查看診斷記錄,找出裝置或語音的穩定性瓶頸。', }, en: { appTitle: 'Voice Reader', @@ -408,6 +434,32 @@ const translations = { onboardingStartDesc: "You're all set! Tap \"Add Article\" to begin your listening journey", onboardingNext: 'Next', onboardingDone: 'Get Started', + upgradeChecklistTitle: 'Upgrade Checklist', + upgradeChecklistHint: 'To turn a free tool into a paid-worthy experience, tighten up these four areas first.', + upgradeScoreLabel: 'Readiness Score', + upgradePlaybackTitle: 'Playback Stability', + upgradePlaybackSetup: 'This device does not currently support browser TTS. Use a browser or device with speech support.', + upgradePlaybackAttention: '{browser} has logged {count} recent playback errors. Review diagnostics before scaling usage.', + upgradePlaybackReady: '{browser} on {os} is ready for instant long-form playback.', + upgradeAiTitle: 'AI Voice & Summary', + upgradeAiSetup: 'No API key yet, so AI summaries, natural voices, and MP3 export are still locked.', + upgradeAiAttention: 'Summary is available, but OpenAI is still needed to match premium voice and MP3 experiences.', + upgradeAiReady: 'AI summary, natural voices, and MP3 export are unlocked—the core premium value is in place.', + upgradeSyncTitle: 'Cross-device Sync', + upgradeSyncSetup: 'No signed-in account yet, so articles, progress, and public sharing cannot continue across devices.', + upgradeSyncReady: 'Signed in. Article sync, progress continuity, and public profile sharing are ready—closer to Matter / Pocket.', + upgradeLibraryTitle: 'Content Ingestion Convenience', + upgradeLibrarySetup: 'Your library is still empty. Import a first article to validate the full product flow.', + upgradeLibraryReady: 'You already have {count} saved articles, so the library foundation is in place.', + upgradeStatusReady: 'Ready', + upgradeStatusAttention: 'Needs polish', + upgradeStatusSetup: 'Needs setup', + upgradeNextActionsTitle: 'Recommended Next Steps', + upgradeActionAddFirstArticle: 'Add a sample article first and confirm import, playback, and resume all feel smooth.', + upgradeActionAddApiKey: 'Add an API key to unlock AI summaries, natural voices, and MP3 export.', + upgradeActionSwitchToOpenai: 'If premium-quality voice is the goal, switch the AI provider to OpenAI.', + upgradeActionCreateAccount: 'Create or log into an account to enable sync and public sharing.', + upgradeActionReviewDiagnostics: 'Recent playback errors exist—review diagnostics to find the current reliability bottleneck.', }, } as const; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b058d9e..f16ce96 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeft, Eye, EyeOff, Key, Cloud, Loader2, LogOut, UserPlus, ChevronDown, ChevronUp, User2, Copy, Check } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -10,6 +11,7 @@ import { getApiKey, setApiKey as saveApiKey, getApiProvider, setApiProvider as saveApiProvider, ApiProvider, + getArticles, } from '@/lib/storage'; import { getSupabaseConfig, setSupabaseConfig, @@ -23,7 +25,7 @@ import { getDiagSummary, getDiagData, clearDiagLogs } from '@/lib/diagnostics'; const SettingsPage = () => { const navigate = useNavigate(); - const { t } = useLanguage(); + const { t, lang } = useLanguage(); // AI settings const [apiKey, setApiKey] = useState(getApiKey()); @@ -50,6 +52,73 @@ const SettingsPage = () => { const [profileDisplayName, setProfileDisplayName] = useState(''); const [profileLoading, setProfileLoading] = useState(false); const [urlCopied, setUrlCopied] = useState(false); + const diagData = getDiagData(); + const diagSummary = getDiagSummary(); + const articleCount = getArticles().length; + const hasApiKey = apiKey.trim().length > 0; + const playbackErrorCount = diagData.logs.filter((log) => log.type === 'tts_error' || log.type === 'tts_stall').length; + + const upgradeItems = [ + { + label: t('upgradePlaybackTitle'), + status: !diagData.device.speechSynthesis ? 'setup' : playbackErrorCount > 0 ? 'attention' : 'ready', + detail: !diagData.device.speechSynthesis + ? t('upgradePlaybackSetup') + : playbackErrorCount > 0 + ? t('upgradePlaybackAttention') + .replace('{count}', String(playbackErrorCount)) + .replace('{browser}', diagData.device.browser || 'Browser') + : t('upgradePlaybackReady') + .replace('{browser}', diagData.device.browser || 'Browser') + .replace('{os}', diagData.device.os || 'Device'), + }, + { + label: t('upgradeAiTitle'), + status: !hasApiKey ? 'setup' : provider === 'openai' ? 'ready' : 'attention', + detail: !hasApiKey + ? t('upgradeAiSetup') + : provider === 'openai' + ? t('upgradeAiReady') + : t('upgradeAiAttention'), + }, + { + label: t('upgradeSyncTitle'), + status: user ? 'ready' : 'setup', + detail: user + ? t('upgradeSyncReady') + : t('upgradeSyncSetup'), + }, + { + label: t('upgradeLibraryTitle'), + status: articleCount > 0 ? 'ready' : 'setup', + detail: articleCount > 0 + ? t('upgradeLibraryReady').replace('{count}', String(articleCount)) + : t('upgradeLibrarySetup'), + }, + ] as const; + + const readinessScore = Math.round( + upgradeItems.reduce((sum, item) => sum + (item.status === 'ready' ? 25 : item.status === 'attention' ? 15 : 0), 0) + ); + + const nextActions = [ + articleCount === 0 ? t('upgradeActionAddFirstArticle') : null, + !hasApiKey ? t('upgradeActionAddApiKey') : provider !== 'openai' ? t('upgradeActionSwitchToOpenai') : null, + !user ? t('upgradeActionCreateAccount') : null, + playbackErrorCount > 0 ? t('upgradeActionReviewDiagnostics') : null, + ].filter(Boolean) as string[]; + + const statusBadgeVariant = { + ready: 'default', + attention: 'secondary', + setup: 'outline', + } as const; + + const statusLabel = { + ready: t('upgradeStatusReady'), + attention: t('upgradeStatusAttention'), + setup: t('upgradeStatusSetup'), + } as const; useEffect(() => { if (isSupabaseConfigured()) { @@ -215,6 +284,45 @@ const SettingsPage = () => {
{/* Account & Sync — TOP SECTION */} + +
+
+

{t('upgradeChecklistTitle')}

+

{t('upgradeChecklistHint')}

+
+
+

{readinessScore}

+

{t('upgradeScoreLabel')}

+
+
+ +
+ {upgradeItems.map((item) => ( +
+
+

{item.label}

+ {statusLabel[item.status]} +
+

{item.detail}

+
+ ))} +
+ + {nextActions.length > 0 && ( +
+

{t('upgradeNextActionsTitle')}

+
    + {nextActions.map((action) => ( +
  • + + {action} +
  • + ))} +
+
+ )} +
+
@@ -454,7 +562,7 @@ const SettingsPage = () => {

{lang === 'zh-TW' ? '裝置診斷' : 'Device Diagnostics'}

-            {getDiagSummary()}
+            {diagSummary}
           
)} {isLast ? ( - +
+ {onTryDemo && ( + + )} + +
) : (
); -} \ No newline at end of file +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index aa541b7..496fd5c 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -203,6 +203,25 @@ const translations = { onboardingStartDesc: '準備好了!點擊「新增文章」開始你的聆聽之旅', onboardingNext: '下一步', onboardingDone: '開始使用', + onboardingTryDemo: '先聽示範', + quickStartTitle: '先用示範文章快速體驗', + quickStartDesc: '不用準備內容,先直接聽一篇示範文章,確認播放、續聽與操作手感。', + tryDemoArticle: '播放示範文章', + importOwnArticle: '匯入自己的文章', + demoArticleCreated: '已建立示範文章,開始體驗朗讀', + homePlaybackCardTitle: '播放相容性', + homePlaybackCardHint: '先確認這台裝置是否適合長文朗讀。', + homePlaybackSetup: '這個瀏覽器目前沒有可用的內建語音,建議改用支援 TTS 的瀏覽器或裝置。', + homePlaybackErrors: '{browser} 最近出現 {count} 筆播放錯誤,建議先到設定頁查看診斷。', + homePlaybackGesture: '這台裝置需要由你手動開始播放;開始後長文仍可正常續聽。', + homePlaybackResumeWorkaround: '{browser} 已啟用長文續播保護,較適合拿來播放長篇文章。', + homePlaybackReady: '{browser} / {os} 已具備穩定的即時朗讀能力。', + homeUpgradeCardTitle: '把體驗升級到付費級', + homeUpgradeCardHint: '把自然語音、MP3、同步與分享補齊,產品價值才會真正拉開。', + homeUpgradeCta: '前往設定', + homeUpgradeSummary: 'AI 摘要', + homeUpgradeVoice: '自然語音與 MP3', + homeUpgradeSync: '雲端同步與分享', upgradeChecklistTitle: '升級檢查清單', upgradeChecklistHint: '把免費工具升級成付費級體驗,先把這四個面向補齊。', upgradeScoreLabel: '就緒分數', @@ -434,6 +453,25 @@ const translations = { onboardingStartDesc: "You're all set! Tap \"Add Article\" to begin your listening journey", onboardingNext: 'Next', onboardingDone: 'Get Started', + onboardingTryDemo: 'Try demo first', + quickStartTitle: 'Start with a demo article', + quickStartDesc: 'No setup needed—listen to a sample first and validate playback, resume, and overall feel.', + tryDemoArticle: 'Play demo article', + importOwnArticle: 'Import my article', + demoArticleCreated: 'Demo article created. Starting playback experience.', + homePlaybackCardTitle: 'Playback compatibility', + homePlaybackCardHint: 'Check whether this device is ready for long-form listening.', + homePlaybackSetup: 'This browser does not currently expose a usable built-in voice. Try a browser or device with TTS support.', + homePlaybackErrors: '{browser} has logged {count} recent playback errors. Review diagnostics in Settings first.', + homePlaybackGesture: 'This device requires you to start playback manually, but long-form listening can continue after that.', + homePlaybackResumeWorkaround: '{browser} is running long-form resume protection, making it better suited for longer articles.', + homePlaybackReady: '{browser} on {os} is ready for stable instant playback.', + homeUpgradeCardTitle: 'Upgrade the experience to paid-worthy', + homeUpgradeCardHint: 'Natural voices, MP3, sync, and sharing are what turn a useful tool into a compelling product.', + homeUpgradeCta: 'Open settings', + homeUpgradeSummary: 'AI summary', + homeUpgradeVoice: 'Natural voice & MP3', + homeUpgradeSync: 'Cloud sync & sharing', upgradeChecklistTitle: 'Upgrade Checklist', upgradeChecklistHint: 'To turn a free tool into a paid-worthy experience, tighten up these four areas first.', upgradeScoreLabel: 'Readiness Score', diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 9ad32a6..5bf94e0 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Plus, Globe, Trash2, BookOpen, Sun, Moon, Download, Upload, Settings, Search, ArrowUpDown, BarChart3, Play, Eye, EyeOff } from 'lucide-react'; +import { Plus, Globe, Trash2, BookOpen, Sun, Moon, Download, Upload, Settings, Search, ArrowUpDown, BarChart3, Play, Eye, EyeOff, Sparkles, AudioLines, Bot, Cloud, ArrowRight } from 'lucide-react'; import { useTheme } from 'next-themes'; import { motion, AnimatePresence } from 'framer-motion'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -19,17 +20,45 @@ import { import { getArticles, deleteArticle, getLastPlayedId, getArticle, exportArticles, importArticles, Article, getReadingStats, + createArticle, saveArticle, getApiKey, getApiProvider, } from '@/lib/storage'; import { useLanguage } from '@/hooks/useLanguage'; import { formatTimeAgo } from '@/lib/i18n'; import { toast } from '@/hooks/use-toast'; import { getUser, toggleArticlePublic } from '@/lib/supabase'; -import { deleteArticleRemote } from '@/lib/auto-sync'; +import { deleteArticleRemote, uploadArticle } from '@/lib/auto-sync'; import { OnboardingTour, shouldShowOnboarding } from '@/components/OnboardingTour'; +import { getDiagData, getTTSLimits } from '@/lib/diagnostics'; import type { User } from '@supabase/supabase-js'; type SortMode = 'recent' | 'created' | 'progress' | 'title'; +function getDemoArticle(lang: 'zh-TW' | 'en') { + if (lang === 'zh-TW') { + return { + title: '3 分鐘體驗語音朗讀器', + content: `歡迎體驗語音朗讀器。 + +這是一篇示範文章,會帶你快速感受貼上文章、開始播放、切換語音與續聽的完整流程。 + +你可以試著播放幾句,接著切換語速、開啟摘要,或稍後回到首頁再從上次進度繼續。 + +如果這個流程順利,下一步就很適合匯入你自己的文章,或到設定頁解鎖 AI 語音、MP3 匯出與雲端同步。`, + }; + } + + return { + title: '3-Minute Voice Reader Demo', + content: `Welcome to Voice Reader. + +This sample article helps you experience the full flow quickly: import, play, switch voices, and resume later. + +Try listening for a few sentences, then adjust speed, generate a summary, or return to the home screen and continue from where you left off. + +If this feels smooth, the next step is to import your own article or unlock AI voices, MP3 export, and sync in Settings.`, + }; +} + const HomePage = () => { const navigate = useNavigate(); const { lang, toggleLanguage, t } = useLanguage(); @@ -45,6 +74,14 @@ const HomePage = () => { const [articlePublicMap, setArticlePublicMap] = useState>({}); const [showOnboarding, setShowOnboarding] = useState(shouldShowOnboarding); const importRef = useRef(null); + const diagData = useMemo(() => getDiagData(), []); + const ttsLimits = useMemo(() => getTTSLimits(diagData.device), [diagData.device]); + const playbackErrorCount = useMemo( + () => diagData.logs.filter((log) => log.type === 'tts_error' || log.type === 'tts_stall').length, + [diagData.logs] + ); + const aiConfigured = getApiKey().trim().length > 0; + const openaiConfigured = aiConfigured && getApiProvider() === 'openai'; useEffect(() => { setArticles(getArticles()); @@ -64,6 +101,17 @@ const HomePage = () => { setDeleteTarget(null); }; + const handleTryDemo = () => { + const demo = getDemoArticle(lang); + const article = createArticle(demo.content, demo.title); + saveArticle(article); + uploadArticle(article); + setArticles(getArticles()); + setLastPlayedArticle(article); + toast({ title: t('demoArticleCreated'), duration: 2000 }); + navigate(`/player/${article.id}`); + }; + const handleTogglePublic = async (articleId: string) => { const current = articlePublicMap[articleId] ?? true; const next = !current; @@ -154,7 +202,7 @@ const HomePage = () => { default: return 0; } }); - }, [articles, searchQuery, sortMode]); + }, [articles, searchQuery, sortMode, selectedTag]); // Reading stats const stats = getReadingStats(); @@ -164,6 +212,30 @@ const HomePage = () => { progress: lang === 'zh-TW' ? '進度' : 'Progress', title: lang === 'zh-TW' ? '標題' : 'Title', }; + const playbackStatus = !diagData.device.speechSynthesis + ? 'setup' + : playbackErrorCount > 0 || ttsLimits.needsUserGesture + ? 'attention' + : 'ready'; + const playbackStatusLabel = playbackStatus === 'ready' + ? t('upgradeStatusReady') + : playbackStatus === 'attention' + ? t('upgradeStatusAttention') + : t('upgradeStatusSetup'); + const playbackMessage = !diagData.device.speechSynthesis + ? t('homePlaybackSetup') + : playbackErrorCount > 0 + ? t('homePlaybackErrors') + .replace('{count}', String(playbackErrorCount)) + .replace('{browser}', diagData.device.browser || 'Browser') + : ttsLimits.needsUserGesture + ? t('homePlaybackGesture') + : ttsLimits.resumeWorkaround + ? t('homePlaybackResumeWorkaround') + .replace('{browser}', diagData.device.browser || 'Browser') + : t('homePlaybackReady') + .replace('{browser}', diagData.device.browser || 'Browser') + .replace('{os}', diagData.device.os || 'Device'); return (
@@ -204,6 +276,73 @@ const HomePage = () => {
+ {articles.length === 0 && ( + +
+

+ + {t('quickStartTitle')} +

+

{t('quickStartDesc')}

+
+
+ + +
+
+ )} + + +
+
+

+ + {t('homePlaybackCardTitle')} +

+

{t('homePlaybackCardHint')}

+
+ + {playbackStatusLabel} + +
+

{playbackMessage}

+
+ + {(!openaiConfigured || !currentUser) && ( + +
+
+

{t('homeUpgradeCardTitle')}

+

{t('homeUpgradeCardHint')}

+
+ +
+
+
+ {t('homeUpgradeSummary')} + {aiConfigured ? t('upgradeStatusReady') : t('upgradeStatusSetup')} +
+
+ {t('homeUpgradeVoice')} + {openaiConfigured ? t('upgradeStatusReady') : t('upgradeStatusSetup')} +
+
+ {t('homeUpgradeSync')} + {currentUser ? t('upgradeStatusReady') : t('upgradeStatusSetup')} +
+
+
+ )} + {/* Reading stats banner */} {articles.length > 0 && (
{ )} {/* Onboarding tour */} - {showOnboarding && setShowOnboarding(false)} />} + {showOnboarding && setShowOnboarding(false)} onTryDemo={handleTryDemo} />} {/* Delete confirmation */} !open && setDeleteTarget(null)}> From 96be48beac8251dbd7d55f65c44f2dac7cc5b894 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:01:01 +0000 Subject: [PATCH 07/29] fix: handle demo article sync fallback Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/49b00265-8844-4adf-ae98-38dd97408ba1 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 2 ++ src/pages/HomePage.tsx | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 496fd5c..a78f2b9 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -209,6 +209,7 @@ const translations = { tryDemoArticle: '播放示範文章', importOwnArticle: '匯入自己的文章', demoArticleCreated: '已建立示範文章,開始體驗朗讀', + demoArticleSyncPending: '示範文章已建立,但雲端同步稍後再試一次。', homePlaybackCardTitle: '播放相容性', homePlaybackCardHint: '先確認這台裝置是否適合長文朗讀。', homePlaybackSetup: '這個瀏覽器目前沒有可用的內建語音,建議改用支援 TTS 的瀏覽器或裝置。', @@ -459,6 +460,7 @@ const translations = { tryDemoArticle: 'Play demo article', importOwnArticle: 'Import my article', demoArticleCreated: 'Demo article created. Starting playback experience.', + demoArticleSyncPending: 'Demo article was created, but cloud sync needs to retry later.', homePlaybackCardTitle: 'Playback compatibility', homePlaybackCardHint: 'Check whether this device is ready for long-form listening.', homePlaybackSetup: 'This browser does not currently expose a usable built-in voice. Try a browser or device with TTS support.', diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 5bf94e0..2899f93 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -105,7 +105,11 @@ const HomePage = () => { const demo = getDemoArticle(lang); const article = createArticle(demo.content, demo.title); saveArticle(article); - uploadArticle(article); + void uploadArticle(article).then((uploaded) => { + if (!uploaded && currentUser) { + toast({ title: t('demoArticleSyncPending'), duration: 2500 }); + } + }); setArticles(getArticles()); setLastPlayedArticle(article); toast({ title: t('demoArticleCreated'), duration: 2000 }); From 3e5b481662e5b5688cdba8b91cbca78e328df86a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:02:52 +0000 Subject: [PATCH 08/29] refactor: polish demo onboarding flow Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/49b00265-8844-4adf-ae98-38dd97408ba1 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/components/OnboardingTour.tsx | 8 +++++++- src/pages/HomePage.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx index d656d04..3f07204 100644 --- a/src/components/OnboardingTour.tsx +++ b/src/components/OnboardingTour.tsx @@ -27,6 +27,12 @@ export function OnboardingTour({ onComplete, onTryDemo }: { onComplete: () => vo onComplete(); }; + const finishAndTryDemo = () => { + localStorage.setItem(ONBOARDING_KEY, 'true'); + onTryDemo?.(); + onComplete(); + }; + const current = steps[step]; const Icon = current.icon; const isLast = step === steps.length - 1; @@ -93,7 +99,7 @@ export function OnboardingTour({ onComplete, onTryDemo }: { onComplete: () => vo {isLast ? (
{onTryDemo && ( - )} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 2899f93..b4245da 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -105,6 +105,7 @@ const HomePage = () => { const demo = getDemoArticle(lang); const article = createArticle(demo.content, demo.title); saveArticle(article); + // Best-effort cloud sync: the local demo article should still open immediately. void uploadArticle(article).then((uploaded) => { if (!uploaded && currentUser) { toast({ title: t('demoArticleSyncPending'), duration: 2500 }); @@ -226,6 +227,11 @@ const HomePage = () => { : playbackStatus === 'attention' ? t('upgradeStatusAttention') : t('upgradeStatusSetup'); + const playbackStatusVariant = { + ready: 'default', + attention: 'secondary', + setup: 'outline', + } as const; const playbackMessage = !diagData.device.speechSynthesis ? t('homePlaybackSetup') : playbackErrorCount > 0 @@ -311,7 +317,7 @@ const HomePage = () => {

{t('homePlaybackCardHint')}

- + {playbackStatusLabel}
From 0af34e25cf268f33249456738c0dd302d0e53a74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:04:06 +0000 Subject: [PATCH 09/29] fix: refresh home diagnostics status Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/49b00265-8844-4adf-ae98-38dd97408ba1 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 2 +- src/pages/HomePage.tsx | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index a78f2b9..e3cb33a 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -218,7 +218,7 @@ const translations = { homePlaybackResumeWorkaround: '{browser} 已啟用長文續播保護,較適合拿來播放長篇文章。', homePlaybackReady: '{browser} / {os} 已具備穩定的即時朗讀能力。', homeUpgradeCardTitle: '把體驗升級到付費級', - homeUpgradeCardHint: '把自然語音、MP3、同步與分享補齊,產品價值才會真正拉開。', + homeUpgradeCardHint: '把自然語音、MP3、同步與分享補齊,這些功能會明顯提升整體體驗。', homeUpgradeCta: '前往設定', homeUpgradeSummary: 'AI 摘要', homeUpgradeVoice: '自然語音與 MP3', diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b4245da..469a6b1 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -74,7 +74,7 @@ const HomePage = () => { const [articlePublicMap, setArticlePublicMap] = useState>({}); const [showOnboarding, setShowOnboarding] = useState(shouldShowOnboarding); const importRef = useRef(null); - const diagData = useMemo(() => getDiagData(), []); + const [diagData, setDiagData] = useState(() => getDiagData()); const ttsLimits = useMemo(() => getTTSLimits(diagData.device), [diagData.device]); const playbackErrorCount = useMemo( () => diagData.logs.filter((log) => log.type === 'tts_error' || log.type === 'tts_stall').length, @@ -93,6 +93,16 @@ const HomePage = () => { getUser().then(setCurrentUser); }, []); + useEffect(() => { + const refreshDiagnostics = () => setDiagData(getDiagData()); + window.addEventListener('focus', refreshDiagnostics); + document.addEventListener('visibilitychange', refreshDiagnostics); + return () => { + window.removeEventListener('focus', refreshDiagnostics); + document.removeEventListener('visibilitychange', refreshDiagnostics); + }; + }, []); + const handleDelete = (id: string) => { deleteArticle(id); deleteArticleRemote(id); // auto-sync: remove from cloud @@ -105,7 +115,7 @@ const HomePage = () => { const demo = getDemoArticle(lang); const article = createArticle(demo.content, demo.title); saveArticle(article); - // Best-effort cloud sync: the local demo article should still open immediately. + // Best-effort cloud sync for signed-in users: the local demo article should still open immediately. void uploadArticle(article).then((uploaded) => { if (!uploaded && currentUser) { toast({ title: t('demoArticleSyncPending'), duration: 2500 }); From 2836b010dc9bdcf872ba960edf1ca3087fc8d8bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 03:57:39 +0000 Subject: [PATCH 10/29] feat: add playback health guidance Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/65032962-304c-4be0-b9b5-2e1b103d4e6c Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/components/OnboardingTour.tsx | 11 +-- src/lib/diagnostics.ts | 52 +++++++++-- src/lib/i18n.ts | 40 +++++++++ src/lib/onboarding.ts | 9 ++ src/pages/HomePage.tsx | 18 ++-- src/pages/PlayerPage.tsx | 140 +++++++++++++++++++++++++++--- src/pages/SettingsPage.tsx | 19 ++-- 7 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 src/lib/onboarding.ts diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx index 3f07204..a45c080 100644 --- a/src/components/OnboardingTour.tsx +++ b/src/components/OnboardingTour.tsx @@ -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 }, @@ -14,21 +13,17 @@ 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, onTryDemo }: { onComplete: () => void; onTryDemo?: () => void }) { const [step, setStep] = useState(0); const { t } = useLanguage(); const finish = () => { - localStorage.setItem(ONBOARDING_KEY, 'true'); + completeOnboarding(); onComplete(); }; const finishAndTryDemo = () => { - localStorage.setItem(ONBOARDING_KEY, 'true'); + completeOnboarding(); onTryDemo?.(); onComplete(); }; diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 17526f7..0150298 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -9,8 +9,13 @@ const DIAG_KEY = 'article-reader-diagnostics'; const MAX_LOGS = 200; const REPORT_BATCH_KEY = 'article-reader-diag-pending'; const REPORT_INTERVAL = 60000; // batch upload every 60s +export const DIAG_UPDATED_EVENT = 'article-reader-diagnostics-updated'; let reportTimer: ReturnType | null = null; +type DiagnosticsAudioWindow = Window & typeof globalThis & { + webkitAudioContext?: typeof AudioContext; +}; + export interface DeviceInfo { os: string; osVersion: string; @@ -30,7 +35,7 @@ export interface DeviceInfo { export interface DiagLog { ts: number; - type: 'tts_error' | 'tts_stall' | 'tts_skip' | 'tts_retry' | 'tts_watchdog' | 'sync_error' | 'info'; + type: 'tts_error' | 'tts_stall' | 'tts_skip' | 'tts_retry' | 'tts_watchdog' | 'tts_watchdog_exhausted' | 'sync_error' | 'info'; message: string; meta?: Record; } @@ -46,7 +51,7 @@ export interface DiagData { function parseOS(ua: string): { os: string; version: string } { if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) { - const m = ua.match(/OS (\d+[_\.]\d+[_\.]?\d*)/); + const m = ua.match(/OS (\d+[_.]\d+[_.]?\d*)/); return { os: 'iOS', version: m ? m[1].replace(/_/g, '.') : 'unknown' }; } if (/Android/.test(ua)) { @@ -106,9 +111,12 @@ export function detectDevice(): DeviceInfo { let audioContext = false; try { - const ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); - audioContext = true; - ctx.close(); + const AudioContextCtor = window.AudioContext || (window as DiagnosticsAudioWindow).webkitAudioContext; + if (AudioContextCtor) { + const ctx = new AudioContextCtor(); + audioContext = true; + void ctx.close(); + } } catch { /* */ } return { @@ -168,6 +176,34 @@ function saveData(data: DiagData) { } catch { /* */ } } +function notifyDiagnosticsUpdated() { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(DIAG_UPDATED_EVENT)); + } +} + +export type PlaybackStatus = 'ready' | 'attention' | 'setup'; + +export function getPlaybackErrorCount(logs: DiagLog[]): number { + return logs.filter((log) => + log.type === 'tts_error' + || log.type === 'tts_stall' + || log.type === 'tts_watchdog' + || log.type === 'tts_watchdog_exhausted' + ).length; +} + +export function getPlaybackSkipCount(logs: DiagLog[]): number { + return logs.filter((log) => log.type === 'tts_skip').length; +} + +export function getPlaybackStatus(device: DeviceInfo, logs: DiagLog[]): PlaybackStatus { + if (!device.speechSynthesis) return 'setup'; + const limits = getTTSLimits(device); + if (getPlaybackErrorCount(logs) > 0 || limits.needsUserGesture) return 'attention'; + return 'ready'; +} + export function diagLog(type: DiagLog['type'], message: string, meta?: Record) { const data = loadData(); data.device = detectDevice(); @@ -178,6 +214,7 @@ export function diagLog(type: DiagLog['type'], message: string, meta?: Record l.type === 'tts_error' || l.type === 'tts_stall').length; - const skipCount = data.logs.filter((l) => l.type === 'tts_skip').length; + const errorCount = getPlaybackErrorCount(data.logs); + const skipCount = getPlaybackSkipCount(data.logs); const limits = getTTSLimits(d); return [ diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index e3cb33a..9fa509c 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -223,6 +223,26 @@ const translations = { homeUpgradeSummary: 'AI 摘要', homeUpgradeVoice: '自然語音與 MP3', homeUpgradeSync: '雲端同步與分享', + playerHealthTitle: '播放健康度', + playerHealthHint: '在開始長文朗讀前,先看目前環境是否穩定、該怎麼恢復。', + playerHealthCurrentEngine: '目前引擎', + playerHealthBrowserVoice: '瀏覽器語音', + playerHealthAiVoice: 'AI 語音', + playerHealthSession: '目前狀態', + playerHealthStatePlaying: '播放中', + playerHealthStatePaused: '可續播', + playerHealthStateReady: '準備播放', + playerHealthNoSpeech: '這個瀏覽器沒有可用的內建語音,建議改用支援 TTS 的瀏覽器,或切到 AI 語音。', + playerHealthRecentIssues: '這台裝置最近累積 {count} 筆播放問題、略過 {skipCount} 句,若長文卡住可先查看設定頁診斷。', + playerHealthGesture: '這台裝置需要你先手動點播放;啟動後再讓長文持續播放會比較穩。', + playerHealthResumeProtection: '{browser} 已啟用長文續播保護,適合拿來持續播放較長內容。', + playerHealthStable: '{browser} / {os} 目前看起來適合長文朗讀。', + playerHealthEnvironment: '{browser} / {os},單句安全長度約 {max} 字。', + playerHealthOpenaiUpgrade: '若你想降低瀏覽器語音的不確定性,建議切到 AI 語音。', + playerHealthOpenaiActive: '目前已使用 AI 語音,穩定度與自然度通常會更好。', + playerHealthBrowserOnly: '目前仍使用瀏覽器語音;若遇到限制,可改換瀏覽器或升級 AI 語音。', + playerHealthSwitchToAi: '切到 AI 語音', + playerHealthOpenSettings: '查看設定與診斷', upgradeChecklistTitle: '升級檢查清單', upgradeChecklistHint: '把免費工具升級成付費級體驗,先把這四個面向補齊。', upgradeScoreLabel: '就緒分數', @@ -474,6 +494,26 @@ const translations = { homeUpgradeSummary: 'AI summary', homeUpgradeVoice: 'Natural voice & MP3', homeUpgradeSync: 'Cloud sync & sharing', + playerHealthTitle: 'Playback health', + playerHealthHint: 'Before a long session, check whether this environment is stable and how to recover if it fails.', + playerHealthCurrentEngine: 'Current engine', + playerHealthBrowserVoice: 'Browser voice', + playerHealthAiVoice: 'AI voice', + playerHealthSession: 'Session state', + playerHealthStatePlaying: 'Playing', + playerHealthStatePaused: 'Ready to resume', + playerHealthStateReady: 'Ready to play', + playerHealthNoSpeech: 'This browser does not expose a usable built-in voice. Switch browsers or move to AI voice.', + playerHealthRecentIssues: 'This device has logged {count} recent playback issues and skipped {skipCount} sentences. If long-form playback stalls, review diagnostics in Settings.', + playerHealthGesture: 'This device needs a manual play gesture first. Once started, longer playback is usually more stable.', + playerHealthResumeProtection: '{browser} is running long-form resume protection, which helps with longer playback.', + playerHealthStable: '{browser} on {os} currently looks suitable for long-form listening.', + playerHealthEnvironment: '{browser} / {os}, with a safe per-utterance target around {max} chars.', + playerHealthOpenaiUpgrade: 'If you want less browser-TTS uncertainty, switch this session to AI voice.', + playerHealthOpenaiActive: 'AI voice is active now, which usually improves both reliability and naturalness.', + playerHealthBrowserOnly: 'This session is still using browser voice. If limits show up, change browsers or unlock AI voice.', + playerHealthSwitchToAi: 'Switch to AI voice', + playerHealthOpenSettings: 'Open settings & diagnostics', upgradeChecklistTitle: 'Upgrade Checklist', upgradeChecklistHint: 'To turn a free tool into a paid-worthy experience, tighten up these four areas first.', upgradeScoreLabel: 'Readiness Score', diff --git a/src/lib/onboarding.ts b/src/lib/onboarding.ts new file mode 100644 index 0000000..9c82141 --- /dev/null +++ b/src/lib/onboarding.ts @@ -0,0 +1,9 @@ +const ONBOARDING_KEY = 'onboarding-completed'; + +export function shouldShowOnboarding(): boolean { + return !localStorage.getItem(ONBOARDING_KEY); +} + +export function completeOnboarding() { + localStorage.setItem(ONBOARDING_KEY, 'true'); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 469a6b1..a732702 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -27,8 +27,9 @@ import { formatTimeAgo } from '@/lib/i18n'; import { toast } from '@/hooks/use-toast'; import { getUser, toggleArticlePublic } from '@/lib/supabase'; import { deleteArticleRemote, uploadArticle } from '@/lib/auto-sync'; -import { OnboardingTour, shouldShowOnboarding } from '@/components/OnboardingTour'; -import { getDiagData, getTTSLimits } from '@/lib/diagnostics'; +import { OnboardingTour } from '@/components/OnboardingTour'; +import { shouldShowOnboarding } from '@/lib/onboarding'; +import { DIAG_UPDATED_EVENT, getDiagData, getPlaybackErrorCount, getPlaybackStatus, getTTSLimits } from '@/lib/diagnostics'; import type { User } from '@supabase/supabase-js'; type SortMode = 'recent' | 'created' | 'progress' | 'title'; @@ -76,10 +77,7 @@ const HomePage = () => { const importRef = useRef(null); const [diagData, setDiagData] = useState(() => getDiagData()); const ttsLimits = useMemo(() => getTTSLimits(diagData.device), [diagData.device]); - const playbackErrorCount = useMemo( - () => diagData.logs.filter((log) => log.type === 'tts_error' || log.type === 'tts_stall').length, - [diagData.logs] - ); + const playbackErrorCount = useMemo(() => getPlaybackErrorCount(diagData.logs), [diagData.logs]); const aiConfigured = getApiKey().trim().length > 0; const openaiConfigured = aiConfigured && getApiProvider() === 'openai'; @@ -95,9 +93,11 @@ const HomePage = () => { useEffect(() => { const refreshDiagnostics = () => setDiagData(getDiagData()); + window.addEventListener(DIAG_UPDATED_EVENT, refreshDiagnostics); window.addEventListener('focus', refreshDiagnostics); document.addEventListener('visibilitychange', refreshDiagnostics); return () => { + window.removeEventListener(DIAG_UPDATED_EVENT, refreshDiagnostics); window.removeEventListener('focus', refreshDiagnostics); document.removeEventListener('visibilitychange', refreshDiagnostics); }; @@ -227,11 +227,7 @@ const HomePage = () => { progress: lang === 'zh-TW' ? '進度' : 'Progress', title: lang === 'zh-TW' ? '標題' : 'Title', }; - const playbackStatus = !diagData.device.speechSynthesis - ? 'setup' - : playbackErrorCount > 0 || ttsLimits.needsUserGesture - ? 'attention' - : 'ready'; + const playbackStatus = getPlaybackStatus(diagData.device, diagData.logs); const playbackStatusLabel = playbackStatus === 'ready' ? t('upgradeStatusReady') : playbackStatus === 'attention' diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 66fecb8..b1a5b49 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -7,7 +7,9 @@ import { Settings2, Eye, EyeOff, Timer, Palette, Zap, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; import { Slider } from '@/components/ui/slider'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -16,7 +18,7 @@ import { getArticle, getArticles, saveArticle, Article, getFontSize, setFontSize import { useLanguage } from '@/hooks/useLanguage'; import { useTTS } from '@/hooks/useTTS'; import { useWakeLock } from '@/hooks/useWakeLock'; -import { estimateReadingTime, extractHeadings, applyBionicReading, splitIntoSentences } from '@/lib/tts'; +import { estimateReadingTime, extractHeadings, applyBionicReading, splitIntoSentences, OpenAIVoice } from '@/lib/tts'; import { generateSummary, SummaryResult } from '@/lib/ai-summary'; import { exportToMp3, getExportVoices, ExportVoice } from '@/lib/mp3-export'; import { getApiKey, getApiProvider } from '@/lib/storage'; @@ -24,11 +26,20 @@ import { useSwipeGesture } from '@/hooks/useSwipeGesture'; import { toast } from '@/hooks/use-toast'; import { getPublicArticleById } from '@/lib/supabase'; import { uploadArticle } from '@/lib/auto-sync'; +import { DIAG_UPDATED_EVENT, getDiagData, getPlaybackErrorCount, getPlaybackSkipCount, getPlaybackStatus, getTTSLimits } from '@/lib/diagnostics'; const SPEED_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0]; const SLEEP_OPTIONS = [0, 15, 30, 45, 60, 90]; const FONT_MIN = 14; const FONT_MAX = 24; +const MEDIA_SESSION_ACTIONS = ['play', 'pause', 'previoustrack', 'nexttrack'] as const; +const READING_THEMES = [ + { value: 'default', labelKey: 'themeDefault' }, + { value: 'sepia', labelKey: 'themeSepia' }, + { value: 'cream', labelKey: 'themeCream' }, + { value: 'dark', labelKey: 'themeDark' }, + { value: 'amoled', labelKey: 'themeAmoled' }, +] as const satisfies Array<{ value: ReadingTheme; labelKey: 'themeDefault' | 'themeSepia' | 'themeCream' | 'themeDark' | 'themeAmoled' }>; const PlayerPage = () => { const { id } = useParams<{ id: string }>(); @@ -56,9 +67,20 @@ const PlayerPage = () => { const [readingTheme, setReadingThemeState] = useState(() => getReadingTheme()); const [rsvpMode, setRsvpMode] = useState(false); const [isPublicView, setIsPublicView] = useState(false); + const [diagData, setDiagData] = useState(() => getDiagData()); const sleepTimerRef = useRef | null>(null); const paragraphRefs = useRef<(HTMLDivElement | null)[]>([]); const wakeLock = useWakeLock(); + const diagTtsLimits = useMemo(() => getTTSLimits(diagData.device), [diagData.device]); + const playbackErrorCount = useMemo(() => getPlaybackErrorCount(diagData.logs), [diagData.logs]); + const playbackSkipCount = useMemo(() => getPlaybackSkipCount(diagData.logs), [diagData.logs]); + const playbackStatus = useMemo(() => getPlaybackStatus(diagData.device, diagData.logs), [diagData.device, diagData.logs]); + const playbackStatusVariant = { + ready: 'default', + attention: 'secondary', + setup: 'outline', + } as const; + const hasOpenAIAccess = getApiKey().trim().length > 0 && getApiProvider() === 'openai'; useEffect(() => { if (!id) return; @@ -87,7 +109,20 @@ const PlayerPage = () => { } }); } - }, [id]); + }, [id, navigate]); + + useEffect(() => { + const refreshDiagnostics = () => setDiagData(getDiagData()); + refreshDiagnostics(); + window.addEventListener(DIAG_UPDATED_EVENT, refreshDiagnostics); + window.addEventListener('focus', refreshDiagnostics); + document.addEventListener('visibilitychange', refreshDiagnostics); + return () => { + window.removeEventListener(DIAG_UPDATED_EVENT, refreshDiagnostics); + window.removeEventListener('focus', refreshDiagnostics); + document.removeEventListener('visibilitychange', refreshDiagnostics); + }; + }, []); const { isPlaying, paragraphIndex, sentenceIndex, paragraphs, progressPercent, @@ -126,9 +161,7 @@ const PlayerPage = () => { navigator.mediaSession.setActionHandler('previoustrack', () => skipBackward()); navigator.mediaSession.setActionHandler('nexttrack', () => skipForward()); return () => { - ['play', 'pause', 'previoustrack', 'nexttrack'].forEach((a) => - navigator.mediaSession.setActionHandler(a as any, null) - ); + MEDIA_SESSION_ACTIONS.forEach((action) => navigator.mediaSession.setActionHandler(action, null)); }; }, [article, togglePlay, skipForward, skipBackward]); @@ -209,7 +242,8 @@ const PlayerPage = () => { // Bookmarks const toggleBookmark = (idx: number) => { const next = new Set(bookmarks); - next.has(idx) ? next.delete(idx) : next.add(idx); + if (next.has(idx)) next.delete(idx); + else next.add(idx); setBookmarks(next); if (article) { const u = { ...article, bookmarks: Array.from(next) }; saveArticle(u); uploadArticle(u); setArticle(u); } toast({ title: next.has(idx) ? t('bookmarkAdd') : t('bookmarkRemove'), duration: 1500 }); @@ -220,7 +254,8 @@ const PlayerPage = () => { const saveNote = () => { if (editingNote === null || !article) return; const u = { ...notes }; - noteText.trim() ? (u[editingNote] = noteText.trim()) : delete u[editingNote]; + if (noteText.trim()) u[editingNote] = noteText.trim(); + else delete u[editingNote]; setNotes(u); const ua = { ...article, notes: u }; saveArticle(ua); uploadArticle(ua); setArticle(ua); setEditingNote(null); @@ -269,6 +304,36 @@ const PlayerPage = () => { return splitIntoSentences(paragraphs[paragraphIndex]); }, [paragraphs, paragraphIndex]); + const playbackStatusLabel = { + ready: t('upgradeStatusReady'), + attention: t('upgradeStatusAttention'), + setup: t('upgradeStatusSetup'), + } as const; + const playbackSessionLabel = isPlaying + ? t('playerHealthStatePlaying') + : paragraphIndex > 0 || sentenceIndex > 0 + ? t('playerHealthStatePaused') + : t('playerHealthStateReady'); + const playbackCompatibilityMessage = !diagData.device.speechSynthesis + ? t('playerHealthNoSpeech') + : playbackErrorCount > 0 + ? t('playerHealthRecentIssues') + .replace('{count}', String(playbackErrorCount)) + .replace('{skipCount}', String(playbackSkipCount)) + : diagTtsLimits.needsUserGesture + ? t('playerHealthGesture') + : diagTtsLimits.resumeWorkaround + ? t('playerHealthResumeProtection') + .replace('{browser}', diagData.device.browser || 'Browser') + : t('playerHealthStable') + .replace('{browser}', diagData.device.browser || 'Browser') + .replace('{os}', diagData.device.os || 'Device'); + const playbackEngineMessage = engineType === 'openai' + ? t('playerHealthOpenaiActive') + : hasOpenAIAccess + ? t('playerHealthOpenaiUpgrade') + : t('playerHealthBrowserOnly'); + if (!article) return null; return ( @@ -347,6 +412,55 @@ const PlayerPage = () => { +
+ +
+
+

{t('playerHealthTitle')}

+

{t('playerHealthHint')}

+
+ + {playbackStatusLabel[playbackStatus]} + +
+ +
+
+

{t('playerHealthCurrentEngine')}

+

{engineType === 'openai' ? t('playerHealthAiVoice') : t('playerHealthBrowserVoice')}

+
+
+

{t('playerHealthSession')}

+

{playbackSessionLabel}

+
+
+ +

{playbackCompatibilityMessage}

+

+ {t('playerHealthEnvironment') + .replace('{browser}', diagData.device.browser || 'Browser') + .replace('{os}', diagData.device.os || 'Device') + .replace('{max}', String(diagTtsLimits.maxUtteranceLength))} +

+

{playbackEngineMessage}

+ + {!isPublicView && ( +
+ {hasOpenAIAccess && engineType !== 'openai' && ( + + )} + +
+ )} +
+
+ {/* AI Summary */}
{!showSummary ? ( @@ -483,7 +597,7 @@ const PlayerPage = () => { {/* Voice pill */}
{engineType === 'openai' ? ( - changeOpenAIVoice(v as OpenAIVoice)}> {openaiVoices.map((v) => {v})} @@ -533,14 +647,14 @@ const PlayerPage = () => {

{t('readingTheme')}

- {(['default', 'sepia', 'cream', 'dark', 'amoled'] as ReadingTheme[]).map((th) => ( - ))}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6a2c58b..eb3eb07 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -21,7 +21,7 @@ import { import { syncArticles } from '@/lib/sync'; import { toast } from '@/hooks/use-toast'; import type { User } from '@supabase/supabase-js'; -import { getDiagSummary, getDiagData, clearDiagLogs } from '@/lib/diagnostics'; +import { DIAG_UPDATED_EVENT, getDiagSummary, getDiagData, clearDiagLogs, getPlaybackErrorCount, getPlaybackStatus } from '@/lib/diagnostics'; const READY_POINTS = 25; const ATTENTION_POINTS = 15; @@ -67,15 +67,12 @@ const SettingsPage = () => { const [diagSummary, setDiagSummary] = useState(() => getDiagSummary()); const [articleCount, setArticleCount] = useState(0); const hasApiKey = apiKey.trim().length > 0; - const playbackErrorCount = useMemo( - () => diagData.logs.filter((log) => log.type === 'tts_error' || log.type === 'tts_stall').length, - [diagData.logs] - ); + const playbackErrorCount = useMemo(() => getPlaybackErrorCount(diagData.logs), [diagData.logs]); const upgradeItems = useMemo(() => [ { label: t('upgradePlaybackTitle'), - status: !diagData.device.speechSynthesis ? 'setup' : playbackErrorCount > 0 ? 'attention' : 'ready', + status: getPlaybackStatus(diagData.device, diagData.logs), detail: !diagData.device.speechSynthesis ? t('upgradePlaybackSetup') : playbackErrorCount > 0 @@ -156,8 +153,14 @@ const SettingsPage = () => { }, []); useEffect(() => { - setDiagData(getDiagData()); - setDiagSummary(getDiagSummary()); + const refreshDiagnostics = () => { + setDiagData(getDiagData()); + setDiagSummary(getDiagSummary()); + }; + + refreshDiagnostics(); + window.addEventListener(DIAG_UPDATED_EVENT, refreshDiagnostics); + return () => window.removeEventListener(DIAG_UPDATED_EVENT, refreshDiagnostics); }, [diagRefreshKey]); useEffect(() => { From 55e8c8574a65b5676d3655e4f9cd1445f037a171 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 03:59:03 +0000 Subject: [PATCH 11/29] refactor: share playback diagnostics helpers Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/65032962-304c-4be0-b9b5-2e1b103d4e6c Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/diagnostics.ts | 14 ++++++++++++-- src/pages/PlayerPage.tsx | 4 ++-- src/pages/SettingsPage.tsx | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 0150298..edf366e 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -12,10 +12,20 @@ const REPORT_INTERVAL = 60000; // batch upload every 60s export const DIAG_UPDATED_EVENT = 'article-reader-diagnostics-updated'; let reportTimer: ReturnType | null = null; -type DiagnosticsAudioWindow = Window & typeof globalThis & { +type DiagnosticsAudioWindow = Window & { webkitAudioContext?: typeof AudioContext; }; +export type DiagLogType = + | 'tts_error' + | 'tts_stall' + | 'tts_skip' + | 'tts_retry' + | 'tts_watchdog' + | 'tts_watchdog_exhausted' + | 'sync_error' + | 'info'; + export interface DeviceInfo { os: string; osVersion: string; @@ -35,7 +45,7 @@ export interface DeviceInfo { export interface DiagLog { ts: number; - type: 'tts_error' | 'tts_stall' | 'tts_skip' | 'tts_retry' | 'tts_watchdog' | 'tts_watchdog_exhausted' | 'sync_error' | 'info'; + type: DiagLogType; message: string; meta?: Record; } diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index b1a5b49..96d000a 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -304,11 +304,11 @@ const PlayerPage = () => { return splitIntoSentences(paragraphs[paragraphIndex]); }, [paragraphs, paragraphIndex]); - const playbackStatusLabel = { + const playbackStatusLabel = useMemo(() => ({ ready: t('upgradeStatusReady'), attention: t('upgradeStatusAttention'), setup: t('upgradeStatusSetup'), - } as const; + } as const), [t]); const playbackSessionLabel = isPlaying ? t('playerHealthStatePlaying') : paragraphIndex > 0 || sentenceIndex > 0 diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index eb3eb07..1d3d99e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -106,7 +106,7 @@ const SettingsPage = () => { ? t('upgradeLibraryReady').replace('{count}', String(articleCount)) : t('upgradeLibrarySetup'), }, - ] as const, [articleCount, diagData, playbackErrorCount, provider, t, user, hasApiKey]); + ] as const, [articleCount, diagData.device, diagData.logs, playbackErrorCount, provider, t, user, hasApiKey]); const readinessScore = useMemo( () => Math.round( From 03e12eb90a02d1ed116c504fdd7f2ffcd3018eae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:30:54 +0000 Subject: [PATCH 12/29] feat: add playback recovery actions Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/f2bc025c-dc76-45be-a91b-404ee5f37a5b Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 40 +++++++++++++++++++++++++++++++++++++++ src/lib/i18n.ts | 14 ++++++++++++++ src/pages/PlayerPage.tsx | 41 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index b1f8e9c..401b865 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -300,6 +300,44 @@ export function useTTS(article: Article | null) { else play(); }, [isPlaying, play, pause]); + const replayCurrentSentence = useCallback(() => { + retryCountRef.current = 0; + getEngine().stop(); + if (!playingRef.current) { + setIsPlaying(true); + playingRef.current = true; + if (playStartTimeRef.current === 0) { + playStartTimeRef.current = Date.now(); + } + } + setSentenceIndex(sentenceIndex); + saveProgress(paragraphIndex, sentenceIndex); + speakSentence(paragraphIndex, sentenceIndex); + }, [paragraphIndex, sentenceIndex, saveProgress, speakSentence, getEngine]); + + 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(); @@ -452,6 +490,8 @@ export function useTTS(article: Article | null) { speed, changeSpeed, togglePlay, + replayCurrentSentence, + skipCurrentSentence, skipForward, skipBackward, seekToParagraph, diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 9fa509c..d749a60 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -241,6 +241,13 @@ const translations = { playerHealthOpenaiUpgrade: '若你想降低瀏覽器語音的不確定性,建議切到 AI 語音。', playerHealthOpenaiActive: '目前已使用 AI 語音,穩定度與自然度通常會更好。', playerHealthBrowserOnly: '目前仍使用瀏覽器語音;若遇到限制,可改換瀏覽器或升級 AI 語音。', + playerHealthRecoveryTitle: '快速恢復', + playerHealthReplaySentence: '重播這一句', + playerHealthSkipSentence: '跳過這一句', + playerHealthClearLogs: '清除播放記錄', + playerHealthReplayStarted: '已從目前句子重新開始播放', + playerHealthSkipDone: '已跳過目前句子', + playerHealthLogsCleared: '已清除播放記錄', playerHealthSwitchToAi: '切到 AI 語音', playerHealthOpenSettings: '查看設定與診斷', upgradeChecklistTitle: '升級檢查清單', @@ -512,6 +519,13 @@ const translations = { playerHealthOpenaiUpgrade: 'If you want less browser-TTS uncertainty, switch this session to AI voice.', playerHealthOpenaiActive: 'AI voice is active now, which usually improves both reliability and naturalness.', playerHealthBrowserOnly: 'This session is still using browser voice. If limits show up, change browsers or unlock AI voice.', + playerHealthRecoveryTitle: 'Quick recovery', + playerHealthReplaySentence: 'Replay this sentence', + playerHealthSkipSentence: 'Skip this sentence', + playerHealthClearLogs: 'Clear playback logs', + playerHealthReplayStarted: 'Playback restarted from the current sentence.', + playerHealthSkipDone: 'Skipped the current sentence.', + playerHealthLogsCleared: 'Playback logs cleared.', playerHealthSwitchToAi: 'Switch to AI voice', playerHealthOpenSettings: 'Open settings & diagnostics', upgradeChecklistTitle: 'Upgrade Checklist', diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 96d000a..60609b2 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -26,7 +26,7 @@ import { useSwipeGesture } from '@/hooks/useSwipeGesture'; import { toast } from '@/hooks/use-toast'; import { getPublicArticleById } from '@/lib/supabase'; import { uploadArticle } from '@/lib/auto-sync'; -import { DIAG_UPDATED_EVENT, getDiagData, getPlaybackErrorCount, getPlaybackSkipCount, getPlaybackStatus, getTTSLimits } from '@/lib/diagnostics'; +import { clearDiagLogs, DIAG_UPDATED_EVENT, getDiagData, getPlaybackErrorCount, getPlaybackSkipCount, getPlaybackStatus, getTTSLimits } from '@/lib/diagnostics'; const SPEED_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0]; const SLEEP_OPTIONS = [0, 15, 30, 45, 60, 90]; @@ -127,7 +127,7 @@ const PlayerPage = () => { const { isPlaying, paragraphIndex, sentenceIndex, paragraphs, progressPercent, voices, selectedVoice, setSelectedVoice, speed, changeSpeed, - togglePlay, skipForward, skipBackward, seekToParagraph, pause, + togglePlay, replayCurrentSentence, skipCurrentSentence, skipForward, skipBackward, seekToParagraph, pause, engineType, switchEngine, openaiVoice, changeOpenAIVoice, openaiVoices, setOnFinished, } = useTTS(article); @@ -333,6 +333,22 @@ const PlayerPage = () => { : hasOpenAIAccess ? t('playerHealthOpenaiUpgrade') : t('playerHealthBrowserOnly'); + const showRecoveryActions = !isPublicView && (playbackErrorCount > 0 || paragraphIndex > 0 || sentenceIndex > 0 || isPlaying); + + const handleReplaySentence = () => { + replayCurrentSentence(); + toast({ title: t('playerHealthReplayStarted'), duration: 1500 }); + }; + + const handleSkipSentence = () => { + skipCurrentSentence(); + toast({ title: t('playerHealthSkipDone'), duration: 1500 }); + }; + + const handleClearDiagnostics = () => { + clearDiagLogs(); + toast({ title: t('playerHealthLogsCleared'), duration: 1500 }); + }; if (!article) return null; @@ -444,6 +460,27 @@ const PlayerPage = () => {

{playbackEngineMessage}

+ {showRecoveryActions && ( +
+

{t('playerHealthRecoveryTitle')}

+
+ + + {playbackErrorCount > 0 && ( + + )} +
+
+ )} + {!isPublicView && (
{hasOpenAIAccess && engineType !== 'openai' && ( From 68473ea2fc3e94a16182f2379589f0ed5920b8ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:40:05 +0000 Subject: [PATCH 13/29] feat: show recent playback logs in player Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/c91b2f47-0672-4060-a7dc-2b389251a273 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 12 +++++++++++ src/pages/PlayerPage.tsx | 45 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index d749a60..8d7997d 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -241,6 +241,12 @@ const translations = { playerHealthOpenaiUpgrade: '若你想降低瀏覽器語音的不確定性,建議切到 AI 語音。', playerHealthOpenaiActive: '目前已使用 AI 語音,穩定度與自然度通常會更好。', playerHealthBrowserOnly: '目前仍使用瀏覽器語音;若遇到限制,可改換瀏覽器或升級 AI 語音。', + playerHealthRecentLogs: '最近播放記錄', + playerHealthNoRecentLogs: '目前沒有最近的播放錯誤或恢復記錄。', + playerHealthLogError: '播放錯誤', + playerHealthLogRetry: '重新嘗試', + playerHealthLogSkip: '略過句子', + playerHealthLogWatchdog: '卡住保護', playerHealthRecoveryTitle: '快速恢復', playerHealthReplaySentence: '重播這一句', playerHealthSkipSentence: '跳過這一句', @@ -519,6 +525,12 @@ const translations = { playerHealthOpenaiUpgrade: 'If you want less browser-TTS uncertainty, switch this session to AI voice.', playerHealthOpenaiActive: 'AI voice is active now, which usually improves both reliability and naturalness.', playerHealthBrowserOnly: 'This session is still using browser voice. If limits show up, change browsers or unlock AI voice.', + playerHealthRecentLogs: 'Recent playback logs', + playerHealthNoRecentLogs: 'No recent playback failures or recovery events yet.', + playerHealthLogError: 'Playback issue', + playerHealthLogRetry: 'Retry', + playerHealthLogSkip: 'Sentence skipped', + playerHealthLogWatchdog: 'Stall protection', playerHealthRecoveryTitle: 'Quick recovery', playerHealthReplaySentence: 'Replay this sentence', playerHealthSkipSentence: 'Skip this sentence', diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 60609b2..3ed7e85 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -44,7 +44,7 @@ const READING_THEMES = [ const PlayerPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { t } = useLanguage(); + const { lang, t } = useLanguage(); const [article, setArticle] = useState
(null); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editTitle, setEditTitle] = useState(''); @@ -309,6 +309,13 @@ const PlayerPage = () => { attention: t('upgradeStatusAttention'), setup: t('upgradeStatusSetup'), } as const), [t]); + const recentPlaybackLogs = useMemo( + () => diagData.logs + .filter((log) => log.type.startsWith('tts')) + .slice(-3) + .reverse(), + [diagData.logs] + ); const playbackSessionLabel = isPlaying ? t('playerHealthStatePlaying') : paragraphIndex > 0 || sentenceIndex > 0 @@ -334,6 +341,23 @@ const PlayerPage = () => { ? t('playerHealthOpenaiUpgrade') : t('playerHealthBrowserOnly'); const showRecoveryActions = !isPublicView && (playbackErrorCount > 0 || paragraphIndex > 0 || sentenceIndex > 0 || isPlaying); + const getPlaybackLogLabel = (type: string) => { + switch (type) { + case 'tts_retry': + return t('playerHealthLogRetry'); + case 'tts_skip': + return t('playerHealthLogSkip'); + case 'tts_watchdog': + case 'tts_watchdog_exhausted': + return t('playerHealthLogWatchdog'); + default: + return t('playerHealthLogError'); + } + }; + const formatPlaybackLogTime = (ts: number) => new Date(ts).toLocaleTimeString( + lang === 'zh-TW' ? 'zh-TW' : 'en-US', + { hour: '2-digit', minute: '2-digit' } + ); const handleReplaySentence = () => { replayCurrentSentence(); @@ -460,6 +484,25 @@ const PlayerPage = () => {

{playbackEngineMessage}

+
+

{t('playerHealthRecentLogs')}

+ {recentPlaybackLogs.length === 0 ? ( +

{t('playerHealthNoRecentLogs')}

+ ) : ( +
+ {recentPlaybackLogs.map((log) => ( +
+
+

{getPlaybackLogLabel(log.type)}

+

{formatPlaybackLogTime(log.ts)}

+
+

{log.message}

+
+ ))} +
+ )} +
+ {showRecoveryActions && (

{t('playerHealthRecoveryTitle')}

From 7eb1afddd991b04b7b1c318eb0b719112a1681c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:46:43 +0000 Subject: [PATCH 14/29] refactor: share playback start setup Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/074af2d7-d310-4ac6-a809-5228b278432c Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index 401b865..3cfc9ed 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -275,12 +275,9 @@ export function useTTS(article: Article | null) { ); const play = useCallback(() => { - setIsPlaying(true); - playingRef.current = true; - retryCountRef.current = 0; - playStartTimeRef.current = Date.now(); + startPlaybackSession(true); speakSentence(paragraphIndex, sentenceIndex); - }, [paragraphIndex, sentenceIndex, speakSentence]); + }, [paragraphIndex, sentenceIndex, speakSentence, startPlaybackSession]); const pause = useCallback(() => { setIsPlaying(false); @@ -300,20 +297,24 @@ export function useTTS(article: Article | null) { else play(); }, [isPlaying, play, pause]); - const replayCurrentSentence = useCallback(() => { + const startPlaybackSession = useCallback((restartTimer = false) => { retryCountRef.current = 0; - getEngine().stop(); if (!playingRef.current) { setIsPlaying(true); playingRef.current = true; - if (playStartTimeRef.current === 0) { - playStartTimeRef.current = Date.now(); - } } + if (restartTimer || playStartTimeRef.current === 0) { + playStartTimeRef.current = Date.now(); + } + }, []); + + const replayCurrentSentence = useCallback(() => { + getEngine().stop(); + startPlaybackSession(); setSentenceIndex(sentenceIndex); saveProgress(paragraphIndex, sentenceIndex); speakSentence(paragraphIndex, sentenceIndex); - }, [paragraphIndex, sentenceIndex, saveProgress, speakSentence, getEngine]); + }, [paragraphIndex, sentenceIndex, saveProgress, speakSentence, getEngine, startPlaybackSession]); const skipCurrentSentence = useCallback(() => { const sentences = paragraphIndex < paragraphs.length From 3de2e1f5d97cc803f96985d3e94f6c20cff6c3bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:48:13 +0000 Subject: [PATCH 15/29] refactor: tidy playback session helper Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/074af2d7-d310-4ac6-a809-5228b278432c Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index 3cfc9ed..b55c776 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -274,6 +274,17 @@ export function useTTS(article: Article | null) { [speed, selectedVoice, saveProgress, getEngine, prefetchNext] ); + const startPlaybackSession = useCallback((restartTimer = false) => { + retryCountRef.current = 0; + 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); @@ -297,21 +308,9 @@ export function useTTS(article: Article | null) { else play(); }, [isPlaying, play, pause]); - const startPlaybackSession = useCallback((restartTimer = false) => { - retryCountRef.current = 0; - if (!playingRef.current) { - setIsPlaying(true); - playingRef.current = true; - } - if (restartTimer || playStartTimeRef.current === 0) { - playStartTimeRef.current = Date.now(); - } - }, []); - const replayCurrentSentence = useCallback(() => { getEngine().stop(); startPlaybackSession(); - setSentenceIndex(sentenceIndex); saveProgress(paragraphIndex, sentenceIndex); speakSentence(paragraphIndex, sentenceIndex); }, [paragraphIndex, sentenceIndex, saveProgress, speakSentence, getEngine, startPlaybackSession]); From 7f423e69f6214f25cb03e9af9e4ad14e53e52931 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:52:23 +0000 Subject: [PATCH 16/29] docs: clarify playback session startup Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/c6121c02-dd55-4b3a-af87-2b0eb0de9cd3 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index b55c776..4c30fc6 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -274,6 +274,11 @@ export function useTTS(article: Article | null) { [speed, selectedVoice, saveProgress, getEngine, prefetchNext] ); + /** + * 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; if (!playingRef.current) { From 6d5bcdc32c91ef734915f42867a16ee8b05c8d5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:04:09 +0000 Subject: [PATCH 17/29] security: avoid persisting api keys Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/d9fef9be-22bf-4adb-8fe2-af52aee494ef Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 4 ++-- src/lib/storage.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 8d7997d..4e96d70 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -82,7 +82,7 @@ const translations = { aiSettings: 'AI 設定', aiSettingsHint: '設定 API Key 以啟用 AI 摘要等進階功能', aiProvider: 'AI 服務商', - apiKeyHint: 'API Key 只會儲存在你的瀏覽器中,不會上傳', + apiKeyHint: 'API Key 只會保留在目前這次開啟的應用程式中,不會上傳,重新整理後需重新輸入', saveSettings: '儲存設定', settingsSaved: '設定已儲存', proFeatures: '進階功能', @@ -366,7 +366,7 @@ const translations = { aiSettings: 'AI Settings', aiSettingsHint: 'Set up API Key to enable AI summary and other features', aiProvider: 'AI Provider', - apiKeyHint: 'API Key is stored locally in your browser only', + apiKeyHint: 'API Key stays only in the current app session, is never uploaded, and must be re-entered after refresh', saveSettings: 'Save Settings', settingsSaved: 'Settings saved', proFeatures: 'Pro Features', diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 993820e..99a438a 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -111,18 +111,18 @@ export function setReadingTheme(theme: ReadingTheme) { localStorage.setItem(READING_THEME_KEY, theme); } -// API Key storage -const API_KEY_KEY = 'article-reader-api-key'; +// API key is intentionally kept in memory only to avoid clear-text browser storage. +let sessionApiKey = ''; const API_PROVIDER_KEY = 'article-reader-api-provider'; export type ApiProvider = 'gemini' | 'openai'; export function getApiKey(): string { - return localStorage.getItem(API_KEY_KEY) || ''; + return sessionApiKey; } export function setApiKey(key: string) { - localStorage.setItem(API_KEY_KEY, key); + sessionApiKey = key; } export function getApiProvider(): ApiProvider { From 13a3702f7daa2d38ef5384bd14b906cd8d4eb3e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:05:16 +0000 Subject: [PATCH 18/29] docs: note in-memory api key tradeoff Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/d9fef9be-22bf-4adb-8fe2-af52aee494ef Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/storage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 99a438a..ebfd23a 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -112,6 +112,7 @@ export function setReadingTheme(theme: ReadingTheme) { } // API key is intentionally kept in memory only to avoid clear-text browser storage. +// This reduces persistence risk, but any in-browser key still depends on preventing XSS. let sessionApiKey = ''; const API_PROVIDER_KEY = 'article-reader-api-provider'; From 80076b123d85898e0b7fa576c6d3da20cb52c8d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:24:07 +0000 Subject: [PATCH 19/29] i18n: translate settings diagnostics strings Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/f885334d-5a4c-4def-bf72-63025bbc18ce Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 10 ++++++++++ src/pages/SettingsPage.tsx | 14 +++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 4e96d70..1b375ef 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -182,6 +182,11 @@ const translations = { publicUrl: '公開網址', copyUrl: '複製網址', urlCopied: '網址已複製', + diagnosticsTitle: '裝置診斷', + diagnosticsRecentLogs: '最近 20 筆記錄', + diagnosticsViewLogs: '查看錯誤記錄', + diagnosticsLogsCleared: '已清除記錄', + diagnosticsNoLogs: '(無錯誤記錄)', publicArticles: '公開文章', noPublicArticles: '目前沒有公開文章', articleVisibility: '文章可見性', @@ -466,6 +471,11 @@ const translations = { publicUrl: 'Public URL', copyUrl: 'Copy URL', urlCopied: 'URL copied', + diagnosticsTitle: 'Device Diagnostics', + diagnosticsRecentLogs: 'Last 20 logs', + diagnosticsViewLogs: 'View Error Logs', + diagnosticsLogsCleared: 'Logs cleared', + diagnosticsNoLogs: '(No error logs)', publicArticles: 'Public Articles', noPublicArticles: 'No public articles yet', articleVisibility: 'Article visibility', diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 1d3d99e..9963479 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -34,7 +34,7 @@ const STATUS_POINTS = { const SettingsPage = () => { const navigate = useNavigate(); - const { t, lang } = useLanguage(); + const { t } = useLanguage(); // AI settings const [apiKey, setApiKey] = useState(getApiKey()); @@ -595,24 +595,24 @@ const SettingsPage = () => { {/* Diagnostics */} -

{lang === 'zh-TW' ? '裝置診斷' : 'Device Diagnostics'}

+

{t('diagnosticsTitle')}

             {diagSummary}
           
From e227c84311974671c7fefd5435cdeacc7ff27a0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:25:15 +0000 Subject: [PATCH 20/29] fix: polish diagnostics translation keys Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/f885334d-5a4c-4def-bf72-63025bbc18ce Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 2 ++ src/lib/storage.ts | 4 ++-- src/pages/SettingsPage.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 1b375ef..8f58d22 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -185,6 +185,7 @@ const translations = { diagnosticsTitle: '裝置診斷', diagnosticsRecentLogs: '最近 20 筆記錄', diagnosticsViewLogs: '查看錯誤記錄', + diagnosticsClearLogs: '清除記錄', diagnosticsLogsCleared: '已清除記錄', diagnosticsNoLogs: '(無錯誤記錄)', publicArticles: '公開文章', @@ -474,6 +475,7 @@ const translations = { diagnosticsTitle: 'Device Diagnostics', diagnosticsRecentLogs: 'Last 20 logs', diagnosticsViewLogs: 'View Error Logs', + diagnosticsClearLogs: 'Clear Logs', diagnosticsLogsCleared: 'Logs cleared', diagnosticsNoLogs: '(No error logs)', publicArticles: 'Public Articles', diff --git a/src/lib/storage.ts b/src/lib/storage.ts index ebfd23a..e58916c 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -111,8 +111,8 @@ export function setReadingTheme(theme: ReadingTheme) { localStorage.setItem(READING_THEME_KEY, theme); } -// API key is intentionally kept in memory only to avoid clear-text browser storage. -// This reduces persistence risk, but any in-browser key still depends on preventing XSS. +// API key is intentionally kept in memory only to avoid clear-text browser persistence. +// This limits the key to the current page session, but does not change XSS exposure. let sessionApiKey = ''; const API_PROVIDER_KEY = 'article-reader-api-provider'; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 9963479..1115174 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -612,7 +612,7 @@ const SettingsPage = () => { setDiagRefreshKey((current) => current + 1); toast({ title: t('diagnosticsLogsCleared'), duration: 1500 }); }}> - {t('playerHealthClearLogs')} + {t('diagnosticsClearLogs')} From 6dd89392c1ec8956fdc5d0f800f10134e9aaf248 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:46:53 +0000 Subject: [PATCH 21/29] refactor: address follow-up review cleanup Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/000ea9f5-99a8-46c7-be9f-0c5b6a0e5aff Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/diagnostics.ts | 2 +- src/pages/HomePage.tsx | 6 ++++-- src/pages/PlayerPage.tsx | 7 ++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index edf366e..b045eae 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -125,7 +125,7 @@ export function detectDevice(): DeviceInfo { if (AudioContextCtor) { const ctx = new AudioContextCtor(); audioContext = true; - void ctx.close(); + void ctx.close().catch(() => {}); } } catch { /* */ } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index a732702..c5dba9d 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -116,11 +116,13 @@ const HomePage = () => { const article = createArticle(demo.content, demo.title); saveArticle(article); // Best-effort cloud sync for signed-in users: the local demo article should still open immediately. - void uploadArticle(article).then((uploaded) => { + const syncDemoArticle = async () => { + const uploaded = await uploadArticle(article); if (!uploaded && currentUser) { toast({ title: t('demoArticleSyncPending'), duration: 2500 }); } - }); + }; + void syncDemoArticle(); setArticles(getArticles()); setLastPlayedArticle(article); toast({ title: t('demoArticleCreated'), duration: 2000 }); diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 3ed7e85..cf48e27 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -354,10 +354,11 @@ const PlayerPage = () => { return t('playerHealthLogError'); } }; - const formatPlaybackLogTime = (ts: number) => new Date(ts).toLocaleTimeString( - lang === 'zh-TW' ? 'zh-TW' : 'en-US', + const playbackLogLocale = lang === 'zh-TW' ? 'zh-TW' : 'en-US'; + const formatPlaybackLogTime = useCallback((ts: number) => new Date(ts).toLocaleTimeString( + playbackLogLocale, { hour: '2-digit', minute: '2-digit' } - ); + ), [playbackLogLocale]); const handleReplaySentence = () => { replayCurrentSentence(); From 76f6794e82665e3010b5d53344420e5479b9c5e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:48:13 +0000 Subject: [PATCH 22/29] docs: clarify async follow-up intent Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/000ea9f5-99a8-46c7-be9f-0c5b6a0e5aff Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/diagnostics.ts | 1 + src/pages/HomePage.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index b045eae..57b3bcb 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -125,6 +125,7 @@ export function detectDevice(): DeviceInfo { if (AudioContextCtor) { const ctx = new AudioContextCtor(); audioContext = true; + // AudioContext close can fail on some browsers during capability probing; safe to ignore here. void ctx.close().catch(() => {}); } } catch { /* */ } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index c5dba9d..b5909e8 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -117,8 +117,9 @@ const HomePage = () => { saveArticle(article); // Best-effort cloud sync for signed-in users: the local demo article should still open immediately. const syncDemoArticle = async () => { + if (!currentUser) return; const uploaded = await uploadArticle(article); - if (!uploaded && currentUser) { + if (!uploaded) { toast({ title: t('demoArticleSyncPending'), duration: 2500 }); } }; From 5625285faa68f8e4c1cc1d5cc5a6586884d92910 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:49:19 +0000 Subject: [PATCH 23/29] chore: polish follow-up review notes Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/000ea9f5-99a8-46c7-be9f-0c5b6a0e5aff Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/diagnostics.ts | 3 ++- src/pages/HomePage.tsx | 1 + src/pages/PlayerPage.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 57b3bcb..2ca228f 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -125,7 +125,8 @@ export function detectDevice(): DeviceInfo { if (AudioContextCtor) { const ctx = new AudioContextCtor(); audioContext = true; - // AudioContext close can fail on some browsers during capability probing; safe to ignore here. + // AudioContext close can fail on some browsers during capability probing; safe to ignore here + // because this is not an active playback session. void ctx.close().catch(() => {}); } } catch { /* */ } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b5909e8..dae25d4 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -124,6 +124,7 @@ const HomePage = () => { } }; void syncDemoArticle(); + // Keep local navigation instant even if the background sync still needs to retry later. setArticles(getArticles()); setLastPlayedArticle(article); toast({ title: t('demoArticleCreated'), duration: 2000 }); diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index cf48e27..13afed2 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -354,7 +354,7 @@ const PlayerPage = () => { return t('playerHealthLogError'); } }; - const playbackLogLocale = lang === 'zh-TW' ? 'zh-TW' : 'en-US'; + const playbackLogLocale = useMemo(() => (lang === 'zh-TW' ? 'zh-TW' : 'en-US'), [lang]); const formatPlaybackLogTime = useCallback((ts: number) => new Date(ts).toLocaleTimeString( playbackLogLocale, { hour: '2-digit', minute: '2-digit' } From 8376d99da9e4ba61c0fc2d7bf518dd323339ffa6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:50:30 +0000 Subject: [PATCH 24/29] docs: polish wording follow-ups Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/000ea9f5-99a8-46c7-be9f-0c5b6a0e5aff Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/i18n.ts | 2 +- src/pages/HomePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 8f58d22..adfd99b 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -372,7 +372,7 @@ const translations = { aiSettings: 'AI Settings', aiSettingsHint: 'Set up API Key to enable AI summary and other features', aiProvider: 'AI Provider', - apiKeyHint: 'API Key stays only in the current app session, is never uploaded, and must be re-entered after refresh', + apiKeyHint: 'API Key stays only in the current app session, is never uploaded, and must be reentered after refresh', saveSettings: 'Save Settings', settingsSaved: 'Settings saved', proFeatures: 'Pro Features', diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index dae25d4..9c3aac0 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -115,7 +115,7 @@ const HomePage = () => { const demo = getDemoArticle(lang); const article = createArticle(demo.content, demo.title); saveArticle(article); - // Best-effort cloud sync for signed-in users: the local demo article should still open immediately. + // Best effort cloud sync for signed-in users: the local demo article should still open immediately. const syncDemoArticle = async () => { if (!currentUser) return; const uploaded = await uploadArticle(article); From f559cbd42f82c15a90c5f8aed0e486d1492934ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:32:51 +0000 Subject: [PATCH 25/29] fix: resolve blocking lint errors Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/bdf39230-a5bf-4328-87ef-3d626bbdc3a9 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/components/ui/command.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/lib/file-parser.ts | 3 ++- src/lib/ocr-parser.ts | 6 ++++-- src/lib/sync.ts | 15 +++++++++++++-- src/lib/tts.ts | 10 +++++----- src/pages/PublicProfilePage.tsx | 18 ++++++++++++++++-- tailwind.config.ts | 3 ++- 8 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 68d5378..ad32c15 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -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 ( diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 4a5643e..18eba7f 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface TextareaProps extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes; const Textarea = React.forwardRef(({ className, ...props }, ref) => { return ( diff --git a/src/lib/file-parser.ts b/src/lib/file-parser.ts index cecb76a..6bfbc78 100644 --- a/src/lib/file-parser.ts +++ b/src/lib/file-parser.ts @@ -1,4 +1,5 @@ import { toast } from '@/hooks/use-toast'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import { t } from './i18n'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -40,7 +41,7 @@ async function parsePDF(file: File): Promise { const page = await pdf.getPage(i); const content = await page.getTextContent(); const pageText = content.items - .map((item: any) => item.str) + .map((item) => ('str' in item ? (item as TextItem).str : '')) .join(''); texts.push(pageText); } diff --git a/src/lib/ocr-parser.ts b/src/lib/ocr-parser.ts index 541a023..03e9a96 100644 --- a/src/lib/ocr-parser.ts +++ b/src/lib/ocr-parser.ts @@ -1,3 +1,5 @@ +type TesseractModule = typeof import('tesseract.js'); + export interface OcrProgress { status: string; progress: number; @@ -11,7 +13,7 @@ export async function parseImageOCR( file: File, onProgress?: (p: OcrProgress) => void ): Promise { - let Tesseract; + let Tesseract: TesseractModule; try { Tesseract = await import('tesseract.js'); } catch (err) { @@ -20,7 +22,7 @@ export async function parseImageOCR( try { const result = await Tesseract.recognize(file, 'chi_tra+eng', { - logger: (m: any) => { + logger: (m: OcrProgress) => { if (onProgress && m.status && typeof m.progress === 'number') { onProgress({ status: m.status, progress: m.progress }); } diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 717d012..2a26448 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -1,6 +1,16 @@ import { getSupabase, getUser } from './supabase'; import { Article, getArticles, saveArticle } from './storage'; +type RemoteArticleRow = ReturnType & { + id: string; + last_played_at?: number | null; + created_at?: number | null; + paragraph_index?: number | null; + sentence_offset?: number | null; + speed?: number | null; + voice_uri?: string | null; +}; + export interface SyncResult { uploaded: number; downloaded: number; @@ -27,7 +37,8 @@ export async function syncArticles(): Promise { if (error) throw error; - const remoteMap = new Map((remote || []).map((a: any) => [a.article_id, a])); + const remoteRows = (remote ?? []) as RemoteArticleRow[]; + const remoteMap = new Map(remoteRows.map((article) => [article.article_id, article])); let uploaded = 0; let downloaded = 0; @@ -89,7 +100,7 @@ function toRemote(article: Article, userId: string) { }; } -function fromRemote(row: any): Article { +function fromRemote(row: RemoteArticleRow): Article { return { id: row.article_id, title: row.title, diff --git a/src/lib/tts.ts b/src/lib/tts.ts index 12766ba..9e80d55 100644 --- a/src/lib/tts.ts +++ b/src/lib/tts.ts @@ -327,11 +327,11 @@ export class OpenAITTS implements TTSEngine { }; await this.audio.play(); - } catch (err: any) { - if (err?.name === 'AbortError') return; // intentional stop + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return; // intentional stop this.cleanup(); this.speaking = false; - const msg = err?.message || 'Unknown error'; + const msg = err instanceof Error ? err.message : 'Unknown error'; if (onError) onError('openai_tts_error', msg); else onEnd(); } @@ -442,12 +442,12 @@ export function cleanText(text: string): string { // Remove common web UI patterns if (/^(share|分享|tweet|like|讚|留言|comment|reply|回覆|subscribe|訂閱|follow|追蹤|more|更多|menu|選單|home|首頁|search|搜尋|login|登入|sign up|註冊|advertisement|廣告|ad|loading|載入中|read more|繼續閱讀|click here|點此|download|下載|print|列印|copy|複製|previous|上一篇|next|下一篇|related|相關)$/i.test(line)) return false; // Remove lines that look like breadcrumbs (Home > Category > ...) - if (/^[\w\u4e00-\u9fff]+(\s*[>›»\/]\s*[\w\u4e00-\u9fff]+){2,}$/.test(line)) return false; + if (/^[\w\u4e00-\u9fff]+(\s*[>›»/]\s*[\w\u4e00-\u9fff]+){2,}$/.test(line)) return false; return true; }); // Collapse 3+ consecutive empty lines into 2 (one blank line between paragraphs) - let result: string[] = []; + const result: string[] = []; let emptyCount = 0; for (const line of lines) { if (line === '') { diff --git a/src/pages/PublicProfilePage.tsx b/src/pages/PublicProfilePage.tsx index 863f43f..ce56b7d 100644 --- a/src/pages/PublicProfilePage.tsx +++ b/src/pages/PublicProfilePage.tsx @@ -7,13 +7,27 @@ import { useLanguage } from '@/hooks/useLanguage'; import { getProfileByUsername, getPublicArticles } from '@/lib/supabase'; import { formatTimeAgo } from '@/lib/i18n'; +interface PublicProfile { + id: string; + username: string | null; + display_name: string | null; +} + +interface PublicArticle { + id?: string; + article_id?: string; + title: string; + word_count?: number | null; + created_at?: string | null; +} + const PublicProfilePage = () => { const { username } = useParams<{ username: string }>(); const navigate = useNavigate(); const { lang, t } = useLanguage(); - const [profile, setProfile] = useState(null); - const [articles, setArticles] = useState([]); + const [profile, setProfile] = useState(null); + const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); diff --git a/tailwind.config.ts b/tailwind.config.ts index a1edb69..789d5e0 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; export default { darkMode: ["class"], @@ -87,5 +88,5 @@ export default { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [tailwindcssAnimate], } satisfies Config; From ee42668d2ae550cd2969270a48e80209b7545ddb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:34:09 +0000 Subject: [PATCH 26/29] fix: harden diagnostics audio probe Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/bdf39230-a5bf-4328-87ef-3d626bbdc3a9 Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/lib/diagnostics.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 2ca228f..f99e314 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -127,7 +127,11 @@ export function detectDevice(): DeviceInfo { audioContext = true; // AudioContext close can fail on some browsers during capability probing; safe to ignore here // because this is not an active playback session. - void ctx.close().catch(() => {}); + try { + void ctx.close().catch(() => {}); + } catch { + // Ignore close failures during diagnostics probing. + } } } catch { /* */ } From 4a2eedd24f4cfb9ddf5c0f5496f646bf29fcc6bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:39:29 +0000 Subject: [PATCH 27/29] fix: clean up remaining hook warnings Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/51656337-4778-4d75-be77-fe6fdd64d38f Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 55 +++++++++++++++++++++++++++--------- src/pages/AddArticlePage.tsx | 2 +- src/pages/PlayerPage.tsx | 18 ++++++++---- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index 4c30fc6..a1c3171 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -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, @@ -46,17 +46,23 @@ export function useTTS(article: Article | null) { const [selectedVoice, setSelectedVoice] = useState(null); const [speed, setSpeed] = useState(() => getGlobalSpeed()); const articleRef = useRef(article); + const selectedVoiceRef = useRef(null); const playingRef = useRef(false); const retryCountRef = useRef(0); const onFinishedRef = useRef<(() => void) | null>(null); const playStartTimeRef = useRef(0); const engineTypeRef = useRef(engineType); + const paragraphIndexRef = useRef(0); + const sentenceIndexRef = useRef(0); // 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; @@ -97,15 +103,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')); @@ -132,7 +150,7 @@ export function useTTS(article: Article | null) { if (v) setSelectedVoice(v); } } - }, [article?.id]); + }, [article]); const saveProgress = useCallback( (pIdx: number, sIdx: number) => { @@ -464,19 +482,28 @@ 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; + + setTimeout(() => { + if (playingRef.current) { + speakSentence(currentParagraphIndex, currentSentenceIndex); + } + }, 100); + }, [selectedVoice, getEngine, speakSentence]); // Cleanup useEffect(() => { + const webTTS = webTTSRef.current; + const openaiTTS = openaiTTSRef.current; + return () => { - webTTSRef.current.stop(); - openaiTTSRef.current?.stop(); + webTTS.stop(); + openaiTTS?.stop(); }; }, []); diff --git a/src/pages/AddArticlePage.tsx b/src/pages/AddArticlePage.tsx index 1ead283..a445797 100644 --- a/src/pages/AddArticlePage.tsx +++ b/src/pages/AddArticlePage.tsx @@ -49,7 +49,7 @@ const AddArticlePage = () => { // Clipboard permission denied — that's fine } })(); - }, [searchParams]); + }, [searchParams, t]); const wordCount = content.length; const readTime = estimateReadingTime(wordCount); diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 13afed2..7d39bb7 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -156,12 +156,20 @@ const PlayerPage = () => { navigator.mediaSession.metadata = new MediaMetadata({ title: article.title, artist: '語音朗讀器', album: `${article.wordCount} 字`, }); - navigator.mediaSession.setActionHandler('play', () => togglePlay()); - navigator.mediaSession.setActionHandler('pause', () => togglePlay()); - navigator.mediaSession.setActionHandler('previoustrack', () => skipBackward()); - navigator.mediaSession.setActionHandler('nexttrack', () => skipForward()); + try { + navigator.mediaSession.setActionHandler('play', () => togglePlay()); + navigator.mediaSession.setActionHandler('pause', () => togglePlay()); + navigator.mediaSession.setActionHandler('previoustrack', () => skipBackward()); + navigator.mediaSession.setActionHandler('nexttrack', () => skipForward()); + } catch { + return; + } return () => { - MEDIA_SESSION_ACTIONS.forEach((action) => navigator.mediaSession.setActionHandler(action, null)); + try { + MEDIA_SESSION_ACTIONS.forEach((action) => navigator.mediaSession.setActionHandler(action, null)); + } catch { + // Ignore unsupported media session cleanup handlers. + } }; }, [article, togglePlay, skipForward, skipBackward]); From 14668e171f814f5888f0412f78732d901b24300d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:40:57 +0000 Subject: [PATCH 28/29] fix: harden playback hook follow-ups Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/51656337-4778-4d75-be77-fe6fdd64d38f Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 9 +++++++-- src/pages/PlayerPage.tsx | 7 ++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index a1c3171..cad38b7 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -54,6 +54,7 @@ export function useTTS(article: Article | null) { 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()); @@ -292,6 +293,10 @@ export function useTTS(article: Article | null) { [speed, selectedVoice, saveProgress, getEngine, prefetchNext] ); + 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 @@ -491,10 +496,10 @@ export function useTTS(article: Article | null) { setTimeout(() => { if (playingRef.current) { - speakSentence(currentParagraphIndex, currentSentenceIndex); + speakSentenceRef.current(currentParagraphIndex, currentSentenceIndex); } }, 100); - }, [selectedVoice, getEngine, speakSentence]); + }, [selectedVoice, getEngine]); // Cleanup useEffect(() => { diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 7d39bb7..bac9230 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -161,14 +161,15 @@ const PlayerPage = () => { navigator.mediaSession.setActionHandler('pause', () => togglePlay()); navigator.mediaSession.setActionHandler('previoustrack', () => skipBackward()); navigator.mediaSession.setActionHandler('nexttrack', () => skipForward()); - } catch { + } catch (error) { + console.warn('[MediaSession] Failed to register handlers', error); return; } return () => { try { MEDIA_SESSION_ACTIONS.forEach((action) => navigator.mediaSession.setActionHandler(action, null)); - } catch { - // Ignore unsupported media session cleanup handlers. + } catch (error) { + console.warn('[MediaSession] Failed to clear handlers', error); } }; }, [article, togglePlay, skipForward, skipBackward]); From 5f999fc84021b9e9b3c8176a572065f730e78c30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:42:04 +0000 Subject: [PATCH 29/29] fix: document playback voice restart timing Agent-Logs-Url: https://github.com/hansai-art/article-voice-reader/sessions/51656337-4778-4d75-be77-fe6fdd64d38f Co-authored-by: hansai-art <132933660+hansai-art@users.noreply.github.com> --- src/hooks/useTTS.ts | 5 ++++- src/pages/PlayerPage.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index cad38b7..065ab54 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -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[] { @@ -494,11 +495,13 @@ export function useTTS(article: Article | null) { 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); } - }, 100); + }, VOICE_CHANGE_DELAY_MS); }, [selectedVoice, getEngine]); // Cleanup diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index bac9230..9cc7a25 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -169,7 +169,7 @@ const PlayerPage = () => { try { MEDIA_SESSION_ACTIONS.forEach((action) => navigator.mediaSession.setActionHandler(action, null)); } catch (error) { - console.warn('[MediaSession] Failed to clear handlers', error); + console.warn('[MediaSession] Failed to clear handlers during cleanup', error); } }; }, [article, togglePlay, skipForward, skipBackward]);