From bd029ad9f1618bfd794a86d5c30101c2c8a7fd75 Mon Sep 17 00:00:00 2001 From: Anthony <47945770+Tony0410@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:31:28 +0800 Subject: [PATCH] Add vibey onboarding, queue controls, and ambient reader modes --- App.tsx | 346 ++++++++++++++++++++++++++++++-------- components/QueueItem.tsx | 56 ++++-- components/ReaderView.tsx | 133 ++++++++++----- types.ts | 3 + 4 files changed, 418 insertions(+), 120 deletions(-) diff --git a/App.tsx b/App.tsx index 5a1171f..a85e5e6 100644 --- a/App.tsx +++ b/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2 } from 'lucide-react'; +import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2, Sparkles, Shuffle, History as HistoryIcon, GripVertical, Waves } 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'; @@ -16,8 +16,11 @@ export default function App() { // -- State -- const [inputUrl, setInputUrl] = useState(''); const [queue, setQueue] = useState([]); + const [history, setHistory] = useState([]); const [viewId, setViewId] = useState(null); const [showSettings, setShowSettings] = useState(false); + const [activeList, setActiveList] = useState<'queue' | 'history'>('queue'); + const [draggingId, setDraggingId] = useState(null); const [playerState, setPlayerState] = useState({ isPlaying: false, @@ -31,9 +34,19 @@ export default function App() { fontSize: 'lg', lineHeight: 'relaxed', fontFamily: 'serif', - autoScroll: true + autoScroll: true, + readingTone: 'clean', + pageWidth: 'standard', + zenMode: false }); + const starterPicks = [ + { label: 'Tech trends', url: 'https://news.ycombinator.com' }, + { label: 'Global headlines', url: 'https://www.reuters.com/world/' }, + { label: 'Science daily', url: 'https://www.sciencedaily.com/news/top/science/' }, + { label: 'Longform feature', url: 'https://www.theguardian.com/world/interactive/2024/jan/01' } + ]; + // -- Refs -- const audioRef = useRef(new Audio()); const processingRef = useRef>(new Set()); @@ -148,33 +161,40 @@ export default function App() { })); }, [playerState.currentArticleId]); - const handleAddUrl = async () => { - if (!inputUrl.trim()) return; - + const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean } = {}) => { + const normalizedUrl = targetUrl.trim(); + if (!normalizedUrl) return; + + const existing = queue.find(item => item.url === normalizedUrl); + if (existing) { + setViewId(existing.id); + if (options.autoPlay) playArticle(existing.id); + return; + } + const id = uuidv4(); const newArticle: Article = { id, - url: inputUrl, + url: normalizedUrl, title: 'Fetching info...', text: '', segments: [], currentSegmentIndex: 0, status: PlaybackStatus.LOADING_TEXT }; - + setQueue(prev => [...prev, newArticle]); - setInputUrl(''); - if (!playerState.isPlaying) setViewId(id); + if (!playerState.isPlaying || options.pinView) setViewId(id); try { const { title, text } = await extractArticleContent(newArticle.url); const segments = segmentText(text); - + if (title) { const titleSegment = segmentText(title)[0]; if (titleSegment) segments.unshift(titleSegment); } - + updateArticle(id, { title, text, @@ -188,10 +208,10 @@ export default function App() { processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice); } updateArticle(id, { status: PlaybackStatus.READY }); - + setQueue(prev => { const current = prev.find(a => a.id === id); - if (current && !playerState.isPlaying) { + if (current && (!playerState.isPlaying || options.autoPlay)) { playArticle(id); } return prev; @@ -201,13 +221,28 @@ export default function App() { } } catch (error: any) { - updateArticle(id, { - status: PlaybackStatus.ERROR, - errorMessage: error.message || "Failed to load article" + updateArticle(id, { + status: PlaybackStatus.ERROR, + errorMessage: error.message || "Failed to load article" }); } }; + const handleAddUrl = async () => { + await enqueueArticle(inputUrl, { autoPlay: !playerState.isPlaying, pinView: true }); + setInputUrl(''); + }; + + const handleStarterPick = async (url: string) => { + await enqueueArticle(url, { autoPlay: true, pinView: true }); + setActiveList('queue'); + }; + + const handleRandomStarter = () => { + const pick = starterPicks[Math.floor(Math.random() * starterPicks.length)]; + if (pick) handleStarterPick(pick.url); + }; + // -- Playback Control -- const playArticle = useCallback(async (id: string) => { @@ -248,6 +283,34 @@ export default function App() { }); }, [playerState.currentArticleId]); + const handleReorder = useCallback((sourceId: string, targetId: string) => { + if (sourceId === targetId) return; + setQueue(prev => { + const sourceIndex = prev.findIndex(a => a.id === sourceId); + const targetIndex = prev.findIndex(a => a.id === targetId); + if (sourceIndex === -1 || targetIndex === -1) return prev; + const updated = [...prev]; + const [moved] = updated.splice(sourceIndex, 1); + updated.splice(targetIndex, 0, moved); + return updated; + }); + }, []); + + const handlePlayNext = useCallback((articleId: string) => { + setQueue(prev => { + const sourceIndex = prev.findIndex(a => a.id === articleId); + if (sourceIndex === -1) return prev; + const updated = [...prev]; + const [picked] = updated.splice(sourceIndex, 1); + const currentId = playerState.currentArticleId; + const currentIndex = currentId ? updated.findIndex(a => a.id === currentId) : -1; + const insertIndex = currentIndex === -1 ? 0 : currentIndex + 1; + updated.splice(insertIndex, 0, picked); + return updated; + }); + setActiveList('queue'); + }, [playerState.currentArticleId]); + const handleSegmentSelect = useCallback((articleId: string, index: number) => { setPlayerState(prev => ({ ...prev, @@ -398,6 +461,15 @@ export default function App() { }; }, []); + useEffect(() => { + setHistory(prev => { + const seen = new Set(prev.map(h => h.id)); + const completed = queue.filter(item => item.status === PlaybackStatus.COMPLETED && !seen.has(item.id)); + if (completed.length === 0) return prev; + return [...prev, ...completed]; + }); + }, [queue]); + const handleSpeedChange = (newSpeed: number) => { const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed)); setPlayerState(prev => ({ ...prev, playbackRate: speed })); @@ -508,6 +580,60 @@ export default function App() { ))} + + {/* Ambient Tone */} +
+ Ambient mode +
+ {[ + { key: 'clean', label: 'Clean' }, + { key: 'sepia', label: 'Sepia' }, + { key: 'night', label: 'Night Light' } + ].map(option => ( + + ))} +
+
+ + {/* Page Width */} +
+
+ Page width + {settings.pageWidth === 'cozy' ? 'Cozy' : settings.pageWidth === 'wide' ? 'Wide' : 'Comfort'} +
+ { + const options = ['cozy','standard','wide'] as const; + setSettings(s => ({...s, pageWidth: options[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" + /> +
+ + {/* Zen Mode */} +
+
+

Zen mode

+

Hide chrome for distraction-free reading.

+
+ +
{/* Shortcuts Hint */}
@@ -527,64 +653,135 @@ export default function App() { {/* Left Column: Controls & Queue */}
{/* Input */} -
- setInputUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} - /> - +
+
+ setInputUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} + /> +
+ + +
+
+

Try a curated link below or drop your own URL to get the vibes going.

+
+ +
+ {starterPicks.map((pick) => ( + + ))}
{/* Queue List */}
-

Up Next

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

No articles queued.

+
+ {([['queue','Queue'], ['history','History']] as const).map(([key,label]) => ( + + ))}
- ) : ( - 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, isPlaying: false })); - audioRef.current.src = ''; - } - setQueue(prev => { - const target = prev.find(a => a.id === article.id); - cleanupArticleAudio(target); - return prev.filter(a => a.id !== article.id); - }); - setViewId(prev => prev === article.id ? null : prev); - }} - /> -
- )) - )} + + {activeList === 'queue' ? `${queue.length} queued` : `${history.length} played`} +
+ + {activeList === 'queue' ? ( +
+ {queue.length === 0 ? ( +
+

No articles queued.

+

Tap a vibe above to get started.

+
+ ) : ( + queue.map(article => ( +
setViewId(article.id)} + className="cursor-pointer" + > + playArticle(article.id)} + onPause={pausePlayback} + onPlayNext={() => handlePlayNext(article.id)} + draggable + onDragStart={() => setDraggingId(article.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { e.preventDefault(); if (draggingId) handleReorder(draggingId, article.id); setDraggingId(null); }} + onDragEnd={() => setDraggingId(null)} + onRemove={() => { + if (playerState.currentArticleId === article.id) { + pausePlayback(); + setPlayerState(prev => ({ ...prev, currentArticleId: null, isPlaying: false })); + audioRef.current.src = ''; + } + setQueue(prev => { + const target = prev.find(a => a.id === article.id); + cleanupArticleAudio(target); + return prev.filter(a => a.id !== article.id); + }); + setViewId(prev => prev === article.id ? null : prev); + }} + /> +
+ )) + )} +
+ ) : ( +
+ {history.length === 0 ? ( +
+

No history yet.

+

Finished listens will show up here automatically.

+
+ ) : ( + history.map(item => ( +
+
+ + {item.title || item.url} +
+

{item.url}

+
Completed
+
+ )) + )} +
+ )}
@@ -628,7 +825,7 @@ export default function App() { {/* Progress Bar */}
-
@@ -636,6 +833,19 @@ export default function App() {

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

+
+ +
+ {[0.8, 1.2, 0.9, 1.4].map((height, idx) => ( + + ))} +
+ Vibe visualizer +
) : (
Ready to play
diff --git a/components/QueueItem.tsx b/components/QueueItem.tsx index 14c5597..abaafb7 100644 --- a/components/QueueItem.tsx +++ b/components/QueueItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Article, PlaybackStatus } from '../types'; -import { Play, Pause, Loader2, AlertCircle, FileText } from 'lucide-react'; +import { Play, Pause, Loader2, AlertCircle, FileText, GripVertical, SkipForward } from 'lucide-react'; interface QueueItemProps { article: Article; @@ -10,15 +10,27 @@ interface QueueItemProps { onPlay: () => void; onPause: () => void; onRemove: () => void; + onPlayNext?: () => void; + draggable?: boolean; + onDragStart?: (e: React.DragEvent) => void; + onDragOver?: (e: React.DragEvent) => void; + onDrop?: (e: React.DragEvent) => void; + onDragEnd?: (e: React.DragEvent) => void; } -export const QueueItem: React.FC = ({ - article, - isActive, - isPlaying, - onPlay, +export const QueueItem: React.FC = ({ + article, + isActive, + isPlaying, + onPlay, onPause, - onRemove + onRemove, + onPlayNext, + draggable, + onDragStart, + onDragOver, + onDrop, + onDragEnd }) => { // Check if buffering: active, supposed to be playing, but current segment audio is missing @@ -52,13 +64,24 @@ export const QueueItem: React.FC = ({ const isReady = article.status === PlaybackStatus.READY || article.status === PlaybackStatus.PAUSED || article.status === PlaybackStatus.PLAYING || article.status === PlaybackStatus.COMPLETED; return ( -
+ `} + > +
+ +
+
{getStatusIcon()}
@@ -84,7 +107,16 @@ export const QueueItem: React.FC = ({ {isActive && isPlaying ? : } )} - + )} +
- - {/* Auto Scroll Toggle */} - -
- -
{article.segments.length > 0 ? ( article.segments.map((segment, idx) => { @@ -112,21 +148,38 @@ export const ReaderView: React.FC = ({ article, settings, onTog const isBuffering = isActive && article.status === 'PLAYING' && !segment.audioUrl; return ( -
onSegmentSelect?.(idx)} title="Click to play from here" className={` transition-all duration-200 whitespace-pre-wrap rounded-xl p-3 sm:p-4 -mx-2 sm:-mx-4 border-l-4 mb-2 - ${getLeadingClass()} - ${isActive - ? `bg-blue-50 dark:bg-blue-900/20 border-blue-500 shadow-sm ${isBuffering ? 'animate-pulse opacity-70' : 'text-slate-900 dark:text-white'}` + ${isActive + ? `bg-blue-50 dark:bg-blue-900/20 border-blue-500 shadow-sm ${isBuffering ? 'animate-pulse opacity-70' : 'text-slate-900 dark:text-white'}` : 'text-slate-700 dark:text-slate-300 border-transparent hover:bg-slate-100 dark:hover:bg-slate-800/50 cursor-pointer hover:border-slate-300 dark:hover:border-slate-600' } `} > - {segment.text} +
+

{segment.text}

+
+ + +
+
); }) diff --git a/types.ts b/types.ts index 5746a9d..bbdd566 100644 --- a/types.ts +++ b/types.ts @@ -53,4 +53,7 @@ export interface ReaderSettings { lineHeight: 'normal' | 'relaxed' | 'loose'; fontFamily: 'sans' | 'serif' | 'mono'; autoScroll: boolean; + readingTone: 'clean' | 'sepia' | 'night'; + pageWidth: 'cozy' | 'standard' | 'wide'; + zenMode: boolean; }