From 8e902fd9c17022c1889d47a2c4dbea2a775a28cc Mon Sep 17 00:00:00 2001 From: Anthony <47945770+Tony0410@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:21:08 +0800 Subject: [PATCH] feat: Introduce reader settings and dark mode support Adds a new `ReaderSettings` type to manage user preferences such as dark mode, font size, line height, font family, and auto-scroll behavior. Implements dark mode styling for various UI components including the `VoiceSelector` and `QueueItem`, enhancing visual consistency. Enhances the `ReaderView` component to respect the `autoScroll` setting and introduces basic text styling options based on the new settings. --- App.tsx | 527 +++++++++++++++++++++-------------- components/QueueItem.tsx | 19 +- components/ReaderView.tsx | 107 +++++-- components/VoiceSelector.tsx | 7 +- types.ts | 8 + 5 files changed, 418 insertions(+), 250 deletions(-) diff --git a/App.tsx b/App.tsx index d2fb53b..8f8a46c 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,9 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Layout } from 'lucide-react'; -import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment } from './types'; -import { AVAILABLE_VOICES, MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; +import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Type, Keyboard } from 'lucide-react'; +import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types'; +import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; import { extractArticleContent, generateSpeechFromText } from './services/geminiService'; import { base64ToUint8Array, createWavBlob } from './services/audioUtils'; import { segmentText } from './services/textUtils'; @@ -15,8 +15,8 @@ export default function App() { // -- State -- const [inputUrl, setInputUrl] = useState(''); const [queue, setQueue] = useState([]); - // Selected article for viewing text (separate from playing) const [viewId, setViewId] = useState(null); + const [showSettings, setShowSettings] = useState(false); const [playerState, setPlayerState] = useState({ isPlaying: false, @@ -25,9 +25,16 @@ export default function App() { selectedVoice: VoiceName.Puck, }); + const [settings, setSettings] = useState({ + isDarkMode: false, + fontSize: 'lg', + lineHeight: 'relaxed', + fontFamily: 'serif', + autoScroll: true + }); + // -- Refs -- const audioRef = useRef(new Audio()); - // Track active processing to prevent duplicate fetch calls const processingRef = useRef>(new Set()); // -- Helpers -- @@ -49,20 +56,15 @@ export default function App() { const updateSegment = (articleId: string, segmentId: string, updates: Partial) => { setQueue(prev => prev.map(article => { if (article.id !== articleId) return article; - const newSegments = article.segments.map(seg => seg.id === segmentId ? { ...seg, ...updates } : seg ); - return { ...article, segments: newSegments }; })); }; // -- Audio Generation Pipeline -- - /** - * Fetches audio for a specific segment. - */ const processSegmentAudio = useCallback(async (articleId: string, segmentId: string, text: string, voice: VoiceName) => { const uniqueKey = `${articleId}-${segmentId}`; if (processingRef.current.has(uniqueKey)) return; @@ -75,7 +77,6 @@ export default function App() { const pcmData = base64ToUint8Array(base64Audio); const wavBlob = createWavBlob(pcmData); const audioUrl = URL.createObjectURL(wavBlob); - updateSegment(articleId, segmentId, { audioUrl, isLoading: false }); } catch (error) { console.error("Segment generation failed", error); @@ -85,18 +86,12 @@ export default function App() { } }, []); - /** - * Manages the buffer. ensure current segment + next N are ready. - * We buffer 5 segments ahead because the first few are very small (fast), - * so we need to be fetching the larger later ones while the small ones play. - */ const manageBuffer = useCallback(async (article: Article) => { const currentIndex = article.currentSegmentIndex; const segmentsToBuffer = article.segments.slice(currentIndex, currentIndex + 5); for (const seg of segmentsToBuffer) { if (!seg.audioUrl && !seg.isLoading && !seg.hasError) { - // No await here - fire in background processSegmentAudio(article.id, seg.id, seg.text, playerState.selectedVoice); } } @@ -124,13 +119,10 @@ export default function App() { try { const { title, text } = await extractArticleContent(newArticle.url); - - // 1. Split text into segments (Progressive: Small -> Large) const segments = segmentText(text); - // Add title as the very first segment (Super fast interaction) if (title) { - const titleSegment = segmentText(title)[0]; // Re-use segment logic for title + const titleSegment = segmentText(title)[0]; if (titleSegment) segments.unshift(titleSegment); } @@ -141,17 +133,13 @@ export default function App() { status: PlaybackStatus.LOADING_AUDIO }); - // 2. Trigger audio for the first batch immediately if (segments.length > 0) { const initialLoadCount = Math.min(segments.length, 5); - for(let i = 0; i < initialLoadCount; i++) { processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice); } - updateArticle(id, { status: PlaybackStatus.READY }); - // Auto-play logic setQueue(prev => { const current = prev.find(a => a.id === id); if (current && !playerState.isPlaying) { @@ -187,23 +175,71 @@ export default function App() { } }, [playerState.currentArticleId]); + const skipSegment = useCallback((direction: 'next' | 'prev') => { + setQueue(prevQueue => { + const currentId = playerState.currentArticleId; + if (!currentId) return prevQueue; + + const article = prevQueue.find(a => a.id === currentId); + if (!article) return prevQueue; + + let newIndex = direction === 'next' + ? article.currentSegmentIndex + 1 + : article.currentSegmentIndex - 1; + + // Boundary checks + if (newIndex < 0) newIndex = 0; + if (newIndex >= article.segments.length) { + // If skipping past end, just stop for now (or go to next article logic) + newIndex = article.segments.length - 1; + } + + return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: newIndex } : a); + }); + }, [playerState.currentArticleId]); + + // -- Keyboard Shortcuts -- + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore shortcuts if typing in input + if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) { + return; + } + + switch (e.code) { + case 'Space': + e.preventDefault(); + if (playerState.isPlaying) pausePlayback(); + else if (playerState.currentArticleId) playArticle(playerState.currentArticleId); + else if (queue.length > 0) playArticle(queue[0].id); + break; + case 'ArrowRight': + skipSegment('next'); + break; + case 'ArrowLeft': + skipSegment('prev'); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [playerState.isPlaying, playerState.currentArticleId, queue, playArticle, pausePlayback, skipSegment]); + // -- Effects -- - // 1. Audio Player Loop useEffect(() => { const article = queue.find(a => a.id === playerState.currentArticleId); if (!article || !playerState.isPlaying) return; const currentSegment = article.segments[article.currentSegmentIndex]; - // If finished all segments if (!currentSegment) { updateArticle(article.id, { status: PlaybackStatus.COMPLETED }); setPlayerState(prev => ({ ...prev, isPlaying: false })); return; } - // Check if audio is ready if (currentSegment.audioUrl) { const audioEl = audioRef.current; const currentSrc = audioEl.getAttribute('data-current-src'); @@ -217,36 +253,27 @@ export default function App() { audioEl.play().catch(e => console.warn("Resume failed", e)); } } else { - // If current segment is missing, ensure it's loading if (!currentSegment.isLoading && !currentSegment.hasError) { processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice); } } - - // Always try to buffer ahead manageBuffer(article); }, [queue, playerState.currentArticleId, playerState.isPlaying, playerState.playbackRate, playerState.selectedVoice, manageBuffer, processSegmentAudio]); - - // 2. Handle 'Ended' event to advance segment useEffect(() => { const audio = audioRef.current; - const handleEnded = () => { const currentId = playerState.currentArticleId; - setQueue(prevQueue => { const article = prevQueue.find(a => a.id === currentId); if (!article) return prevQueue; const nextIndex = article.currentSegmentIndex + 1; - // If we have a next segment, advance index if (nextIndex < article.segments.length) { return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a); } else { - // Article finished const artIndex = prevQueue.findIndex(a => a.id === currentId); if (artIndex !== -1 && artIndex < prevQueue.length - 1) { setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100); @@ -258,12 +285,10 @@ export default function App() { } }); }; - audio.addEventListener('ended', handleEnded); return () => audio.removeEventListener('ended', handleEnded); }, [playerState.currentArticleId, playArticle]); - // 3. Handle Speed Change const handleSpeedChange = (newSpeed: number) => { const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed)); setPlayerState(prev => ({ ...prev, playbackRate: speed })); @@ -272,195 +297,273 @@ export default function App() { } }; - // -- Render -- const currentArticle = getCurrentArticle(); const viewingArticle = getViewingArticle(); return ( -
- {/* Header */} -
-
-
-
- -
-

NewsCaster AI

-
- - setPlayerState(prev => ({ ...prev, selectedVoice: v }))} - disabled={playerState.isPlaying} - /> -
-
- - {/* Main Content */} -
+
+
- {/* Left Column: Controls & Queue */} -
- {/* Input */} -
- setInputUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} - /> - -
- - {/* Queue List */} -
-
-

Up Next

- {queue.length} articles + {/* Header */} +
+
+
+
+ +
+

NewsCaster AI

-
- {queue.length === 0 ? ( -
-

No articles queued.

-
- ) : ( - queue.map(article => ( -
setViewId(article.id)} className="cursor-pointer"> - playArticle(article.id)} - onPause={pausePlayback} - onRemove={() => { - if (playerState.currentArticleId === article.id) { - pausePlayback(); - setPlayerState(prev => ({ ...prev, currentArticleId: null })); - } - setQueue(prev => prev.filter(a => a.id !== article.id)); - if (viewId === article.id) setViewId(null); - }} - /> -
- )) - )} +
+ setPlayerState(prev => ({ ...prev, selectedVoice: v }))} + disabled={playerState.isPlaying} + /> +
-
-
- - {/* Right Column: Reader View */} -
- -
- -
- {viewingArticle && ( -
-

Article Reader

-
- -
-
- )} -
-
- - {/* Player Bar */} -
-
- -
- {currentArticle ? ( -
-

{currentArticle.title}

- {/* Progress Bar for current segment */} -
-
-
-

- Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length} -

-
- ) : ( -
Ready to play
- )}
-
-
- -
- - {playerState.playbackRate.toFixed(1)}x - -
+ {/* Settings Menu */} + {showSettings && ( +
+

+ Reader Preferences +

+ +
+ {/* Theme */} +
+ Theme +
+ + +
+
+ + {/* Font Size */} +
+
+ Text Size +
+ { + const sizes = ['sm','base','lg','xl','2xl'] as const; + setSettings(s => ({...s, fontSize: sizes[parseInt(e.target.value)]})); + }} + className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+ Aa + Aa +
+
+ + {/* Font Family */} +
+ Font Family +
+ {['serif', 'sans', 'mono'].map((font) => ( + + ))} +
+
+ + {/* Shortcuts Hint */} +
+

+ + Shortcuts: Space (Play), Arrows (Skip) +

+
+
+
+ )} + + + {/* Main Content */} +
+ + {/* Left Column: Controls & Queue */} +
+ {/* Input */} +
+ setInputUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} + /> + +
+ + {/* Queue List */} +
+
+

Up Next

+ {queue.length} articles +
+ +
+ {queue.length === 0 ? ( +
+

No articles queued.

+
+ ) : ( + queue.map(article => ( +
setViewId(article.id)} className="cursor-pointer"> + playArticle(article.id)} + onPause={pausePlayback} + onRemove={() => { + if (playerState.currentArticleId === article.id) { + pausePlayback(); + setPlayerState(prev => ({ ...prev, currentArticleId: null })); + } + setQueue(prev => prev.filter(a => a.id !== article.id)); + if (viewId === article.id) setViewId(null); + }} + /> +
+ )) + )} +
+
+
+ + {/* Right Column: Reader View */} +
+ setSettings(s => ({...s, autoScroll: !s.autoScroll}))} + /> +
+ +
+ {viewingArticle && ( +
+

Article Reader

+
+ setSettings(s => ({...s, autoScroll: !s.autoScroll}))} + /> +
+
+ )} +
+
+ + {/* Player Bar */} +
+
+ +
+ {currentArticle ? ( +
+

{currentArticle.title}

+ {/* Progress Bar */} +
+
+
+

+ Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length} +

+
+ ) : ( +
Ready to play
+ )}
-
- +
+
+ +
+ + {playerState.playbackRate.toFixed(1)}x + +
+
- +
+ - + + + +
diff --git a/components/QueueItem.tsx b/components/QueueItem.tsx index 3650049..3a64afa 100644 --- a/components/QueueItem.tsx +++ b/components/QueueItem.tsx @@ -1,6 +1,7 @@ + import React from 'react'; import { Article, PlaybackStatus } from '../types'; -import { Play, Pause, Loader2, AlertCircle, FileText, Headphones } from 'lucide-react'; +import { Play, Pause, Loader2, AlertCircle, FileText } from 'lucide-react'; interface QueueItemProps { article: Article; @@ -35,7 +36,7 @@ export const QueueItem: React.FC = ({
; default: - return ; + return ; } }; @@ -45,8 +46,8 @@ export const QueueItem: React.FC = ({
@@ -54,10 +55,10 @@ export const QueueItem: React.FC = ({
-

+

{article.title || article.url}

-

+

{article.url}

{article.errorMessage && ( @@ -69,18 +70,18 @@ export const QueueItem: React.FC = ({ {isReady && ( )}
); -}; \ No newline at end of file +}; diff --git a/components/ReaderView.tsx b/components/ReaderView.tsx index 8754714..01fdb71 100644 --- a/components/ReaderView.tsx +++ b/components/ReaderView.tsx @@ -1,28 +1,65 @@ import React, { useEffect, useRef } from 'react'; -import { Article } from '../types'; -import { FileText } from 'lucide-react'; +import { Article, ReaderSettings } from '../types'; +import { FileText, MousePointerClick } from 'lucide-react'; interface ReaderViewProps { article?: Article | null; + settings?: ReaderSettings; + onToggleAutoScroll?: () => void; } -export const ReaderView: React.FC = ({ article }) => { +export const ReaderView: React.FC = ({ article, settings, onToggleAutoScroll }) => { const scrollRef = useRef(null); // Auto-scroll to active segment useEffect(() => { - if (!article || article.status !== 'PLAYING') return; + if (!article || article.status !== 'PLAYING' || settings?.autoScroll === false) return; const activeEl = document.getElementById(`segment-${article.currentSegmentIndex}`); if (activeEl && scrollRef.current) { activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - }, [article?.currentSegmentIndex, article?.status]); + }, [article?.currentSegmentIndex, article?.status, settings?.autoScroll]); + + // Default settings fallback + const s = settings || { + isDarkMode: false, + fontSize: 'lg', + lineHeight: 'relaxed', + fontFamily: 'serif', + autoScroll: true + }; + + const getFontClass = () => { + switch(s.fontFamily) { + case 'sans': return 'font-sans'; + case 'mono': return 'font-mono'; + default: return 'font-serif'; + } + }; + + const getSizeClass = () => { + switch(s.fontSize) { + case 'sm': return 'text-sm'; + case 'base': return 'text-base'; + case 'xl': return 'text-xl'; + case '2xl': return 'text-2xl'; + default: return 'text-lg'; + } + }; + + const getLeadingClass = () => { + switch(s.lineHeight) { + case 'normal': return 'leading-normal'; + case 'loose': return 'leading-loose'; + default: return 'leading-relaxed'; + } + }; if (!article) { return ( -
+

Select an article to read along

The text will appear here while you listen.

@@ -31,22 +68,40 @@ export const ReaderView: React.FC = ({ article }) => { } return ( -
-
-

- {article.title} -

- +
+ + + {/* Auto Scroll Toggle */} +
-
+
{article.segments.length > 0 ? ( article.segments.map((segment, idx) => { const isActive = article.currentSegmentIndex === idx; @@ -54,10 +109,10 @@ export const ReaderView: React.FC = ({ article }) => {
{segment.text} @@ -69,12 +124,12 @@ export const ReaderView: React.FC = ({ article }) => {
{[1,2,3,4].map(i => (
-
-
-
+
+
+
))} -

Extracting article content...

+

Extracting article content...

)}
diff --git a/components/VoiceSelector.tsx b/components/VoiceSelector.tsx index ef9ea33..33933ff 100644 --- a/components/VoiceSelector.tsx +++ b/components/VoiceSelector.tsx @@ -1,3 +1,4 @@ + import React from 'react'; import { VoiceName } from '../types'; import { AVAILABLE_VOICES } from '../constants'; @@ -12,12 +13,12 @@ interface VoiceSelectorProps { export const VoiceSelector: React.FC = ({ selectedVoice, onVoiceChange, disabled }) => { return (
- +
); -}; \ No newline at end of file +}; diff --git a/types.ts b/types.ts index e1fe424..91fc2db 100644 --- a/types.ts +++ b/types.ts @@ -45,3 +45,11 @@ export interface PlayerState { currentArticleId: string | null; selectedVoice: VoiceName; } + +export interface ReaderSettings { + isDarkMode: boolean; + fontSize: 'sm' | 'base' | 'lg' | 'xl' | '2xl'; + lineHeight: 'normal' | 'relaxed' | 'loose'; + fontFamily: 'sans' | 'serif' | 'mono'; + autoScroll: boolean; +}