mirror of
https://github.com/Tony0410/News-reader-pro.git
synced 2026-05-24 21:31:44 +08:00
Add vibey onboarding, queue controls, and ambient reader modes
This commit is contained in:
322
App.tsx
322
App.tsx
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types';
|
||||||
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
|
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
|
||||||
import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
|
import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
|
||||||
@@ -16,8 +16,11 @@ export default function App() {
|
|||||||
// -- State --
|
// -- State --
|
||||||
const [inputUrl, setInputUrl] = useState('');
|
const [inputUrl, setInputUrl] = useState('');
|
||||||
const [queue, setQueue] = useState<Article[]>([]);
|
const [queue, setQueue] = useState<Article[]>([]);
|
||||||
|
const [history, setHistory] = useState<Article[]>([]);
|
||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [activeList, setActiveList] = useState<'queue' | 'history'>('queue');
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [playerState, setPlayerState] = useState<PlayerState>({
|
const [playerState, setPlayerState] = useState<PlayerState>({
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@@ -31,9 +34,19 @@ export default function App() {
|
|||||||
fontSize: 'lg',
|
fontSize: 'lg',
|
||||||
lineHeight: 'relaxed',
|
lineHeight: 'relaxed',
|
||||||
fontFamily: 'serif',
|
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 --
|
// -- Refs --
|
||||||
const audioRef = useRef<HTMLAudioElement>(new Audio());
|
const audioRef = useRef<HTMLAudioElement>(new Audio());
|
||||||
const processingRef = useRef<Set<string>>(new Set());
|
const processingRef = useRef<Set<string>>(new Set());
|
||||||
@@ -148,13 +161,21 @@ export default function App() {
|
|||||||
}));
|
}));
|
||||||
}, [playerState.currentArticleId]);
|
}, [playerState.currentArticleId]);
|
||||||
|
|
||||||
const handleAddUrl = async () => {
|
const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean } = {}) => {
|
||||||
if (!inputUrl.trim()) return;
|
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 id = uuidv4();
|
||||||
const newArticle: Article = {
|
const newArticle: Article = {
|
||||||
id,
|
id,
|
||||||
url: inputUrl,
|
url: normalizedUrl,
|
||||||
title: 'Fetching info...',
|
title: 'Fetching info...',
|
||||||
text: '',
|
text: '',
|
||||||
segments: [],
|
segments: [],
|
||||||
@@ -163,8 +184,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setQueue(prev => [...prev, newArticle]);
|
setQueue(prev => [...prev, newArticle]);
|
||||||
setInputUrl('');
|
if (!playerState.isPlaying || options.pinView) setViewId(id);
|
||||||
if (!playerState.isPlaying) setViewId(id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { title, text } = await extractArticleContent(newArticle.url);
|
const { title, text } = await extractArticleContent(newArticle.url);
|
||||||
@@ -191,7 +211,7 @@ export default function App() {
|
|||||||
|
|
||||||
setQueue(prev => {
|
setQueue(prev => {
|
||||||
const current = prev.find(a => a.id === id);
|
const current = prev.find(a => a.id === id);
|
||||||
if (current && !playerState.isPlaying) {
|
if (current && (!playerState.isPlaying || options.autoPlay)) {
|
||||||
playArticle(id);
|
playArticle(id);
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
@@ -208,6 +228,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 --
|
// -- Playback Control --
|
||||||
|
|
||||||
const playArticle = useCallback(async (id: string) => {
|
const playArticle = useCallback(async (id: string) => {
|
||||||
@@ -248,6 +283,34 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [playerState.currentArticleId]);
|
}, [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) => {
|
const handleSegmentSelect = useCallback((articleId: string, index: number) => {
|
||||||
setPlayerState(prev => ({
|
setPlayerState(prev => ({
|
||||||
...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 handleSpeedChange = (newSpeed: number) => {
|
||||||
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
|
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
|
||||||
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
|
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
|
||||||
@@ -509,6 +581,60 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ambient Tone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-slate-700 dark:text-slate-300 text-sm font-medium block">Ambient mode</span>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
{[
|
||||||
|
{ key: 'clean', label: 'Clean' },
|
||||||
|
{ key: 'sepia', label: 'Sepia' },
|
||||||
|
{ key: 'night', label: 'Night Light' }
|
||||||
|
].map(option => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => setSettings(s => ({...s, readingTone: option.key as any}))}
|
||||||
|
className={`px-3 py-2 rounded-lg border transition-all ${settings.readingTone === option.key ? 'border-blue-500 text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30' : 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Width */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-700 dark:text-slate-300 font-medium">Page width</span>
|
||||||
|
<span className="text-xs text-slate-400">{settings.pageWidth === 'cozy' ? 'Cozy' : settings.pageWidth === 'wide' ? 'Wide' : 'Comfort'}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="1"
|
||||||
|
value={['cozy','standard','wide'].indexOf(settings.pageWidth)}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zen Mode */}
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Zen mode</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Hide chrome for distraction-free reading.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettings(s => ({...s, zenMode: !s.zenMode}))}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold transition-all ${settings.zenMode ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200' : 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'}`}
|
||||||
|
>
|
||||||
|
{settings.zenMode ? 'On' : 'Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Shortcuts Hint */}
|
{/* Shortcuts Hint */}
|
||||||
<div className="pt-4 border-t border-slate-100 dark:border-slate-800">
|
<div className="pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||||
<p className="text-xs text-slate-400 flex items-center gap-2">
|
<p className="text-xs text-slate-400 flex items-center gap-2">
|
||||||
@@ -527,64 +653,135 @@ export default function App() {
|
|||||||
{/* Left Column: Controls & Queue */}
|
{/* Left Column: Controls & Queue */}
|
||||||
<div className="lg:col-span-5 space-y-6">
|
<div className="lg:col-span-5 space-y-6">
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 flex gap-2 items-center pl-4 transition-colors duration-300">
|
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors duration-300 space-y-3">
|
||||||
<input
|
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||||
type="url"
|
<input
|
||||||
placeholder="Paste article URL here..."
|
type="url"
|
||||||
className="flex-grow py-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0"
|
placeholder="Paste article URL here..."
|
||||||
value={inputUrl}
|
className="flex-grow py-3 px-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0 rounded-xl bg-slate-50 dark:bg-slate-800"
|
||||||
onChange={(e) => setInputUrl(e.target.value)}
|
value={inputUrl}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
|
onChange={(e) => setInputUrl(e.target.value)}
|
||||||
/>
|
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
|
||||||
<button
|
/>
|
||||||
onClick={handleAddUrl}
|
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||||
disabled={!inputUrl.trim()}
|
<button
|
||||||
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg"
|
onClick={handleAddUrl}
|
||||||
>
|
disabled={!inputUrl.trim()}
|
||||||
<Plus className="w-4 h-4" />
|
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg"
|
||||||
<span className="hidden sm:inline">Queue</span>
|
>
|
||||||
</button>
|
<Plus className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Queue</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRandomStarter()}
|
||||||
|
className="bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-200 px-4 py-3 rounded-xl font-medium flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Shuffle className="w-4 h-4" /> Surprise me
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 px-3 pb-2">Try a curated link below or drop your own URL to get the vibes going.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{starterPicks.map((pick) => (
|
||||||
|
<button
|
||||||
|
key={pick.url}
|
||||||
|
onClick={() => handleStarterPick(pick.url)}
|
||||||
|
className="text-left bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl p-4 hover:border-blue-200 hover:shadow-sm dark:hover:border-blue-800 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-500" /> {pick.label}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">{pick.url}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Queue List */}
|
{/* Queue List */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Up Next</h2>
|
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-full p-1">
|
||||||
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">{queue.length} articles</span>
|
{([['queue','Queue'], ['history','History']] as const).map(([key,label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveList(key)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full transition-all ${activeList === key ? 'bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-blue-300' : 'text-slate-500 dark:text-slate-400'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">
|
||||||
|
{activeList === 'queue' ? `${queue.length} queued` : `${history.length} played`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{activeList === 'queue' ? (
|
||||||
{queue.length === 0 ? (
|
<div className="space-y-3">
|
||||||
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
{queue.length === 0 ? (
|
||||||
<p>No articles queued.</p>
|
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||||
</div>
|
<p>No articles queued.</p>
|
||||||
|
<p className="text-xs mt-1">Tap a vibe above to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
queue.map(article => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
onClick={() => setViewId(article.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<QueueItem
|
||||||
|
article={article}
|
||||||
|
isActive={article.id === playerState.currentArticleId}
|
||||||
|
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
|
||||||
|
onPlay={() => 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="text-center py-10 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||||
|
<p>No history yet.</p>
|
||||||
|
<p className="text-xs mt-1">Finished listens will show up here automatically.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
queue.map(article => (
|
history.map(item => (
|
||||||
<div key={article.id} onClick={() => setViewId(article.id)} className="cursor-pointer">
|
<div key={item.id} className="p-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl">
|
||||||
<QueueItem
|
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">
|
||||||
article={article}
|
<HistoryIcon className="w-4 h-4 text-slate-400" />
|
||||||
isActive={article.id === playerState.currentArticleId}
|
<span className="truncate">{item.title || item.url}</span>
|
||||||
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
|
</div>
|
||||||
onPlay={() => playArticle(article.id)}
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{item.url}</p>
|
||||||
onPause={pausePlayback}
|
<div className="text-[11px] text-green-600 dark:text-green-400 mt-1">Completed</div>
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -636,6 +833,19 @@ export default function App() {
|
|||||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
|
||||||
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
|
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 justify-center sm:justify-start text-xs text-blue-500 dark:text-blue-300">
|
||||||
|
<Waves className={`w-4 h-4 ${playerState.isPlaying ? 'animate-pulse' : ''}`} />
|
||||||
|
<div className="flex items-end gap-1 h-4">
|
||||||
|
{[0.8, 1.2, 0.9, 1.4].map((height, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`w-1 rounded-full bg-gradient-to-t from-blue-500 to-indigo-400 ${playerState.isPlaying ? 'animate-[bounce_1.4s_infinite]' : ''}`}
|
||||||
|
style={{ height: `${height * 12}px`, opacity: isBuffering ? 0.4 : 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-500 dark:text-slate-400">Vibe visualizer</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div>
|
<div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Article, PlaybackStatus } from '../types';
|
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 {
|
interface QueueItemProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
@@ -10,6 +10,12 @@ interface QueueItemProps {
|
|||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onPlayNext?: () => void;
|
||||||
|
draggable?: boolean;
|
||||||
|
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragOver?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QueueItem: React.FC<QueueItemProps> = ({
|
export const QueueItem: React.FC<QueueItemProps> = ({
|
||||||
@@ -18,7 +24,13 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
onPlay,
|
onPlay,
|
||||||
onPause,
|
onPause,
|
||||||
onRemove
|
onRemove,
|
||||||
|
onPlayNext,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
// Check if buffering: active, supposed to be playing, but current segment audio is missing
|
// Check if buffering: active, supposed to be playing, but current segment audio is missing
|
||||||
@@ -52,13 +64,24 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
|||||||
const isReady = article.status === PlaybackStatus.READY || article.status === PlaybackStatus.PAUSED || article.status === PlaybackStatus.PLAYING || article.status === PlaybackStatus.COMPLETED;
|
const isReady = article.status === PlaybackStatus.READY || article.status === PlaybackStatus.PAUSED || article.status === PlaybackStatus.PLAYING || article.status === PlaybackStatus.COMPLETED;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`
|
<div
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
className={`
|
||||||
relative group flex items-center p-4 rounded-xl border transition-all duration-200
|
relative group flex items-center p-4 rounded-xl border transition-all duration-200
|
||||||
${isActive
|
${isActive
|
||||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 shadow-sm'
|
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 shadow-sm'
|
||||||
: 'bg-white border-slate-100 hover:border-slate-300 dark:bg-slate-800 dark:border-slate-700 dark:hover:border-slate-600'
|
: 'bg-white border-slate-100 hover:border-slate-300 dark:bg-slate-800 dark:border-slate-700 dark:hover:border-slate-600'
|
||||||
}
|
}
|
||||||
`}>
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 mr-2 w-6 flex justify-center text-slate-300 dark:text-slate-600 cursor-grab">
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0 mr-4 w-8 flex justify-center">
|
<div className="flex-shrink-0 mr-4 w-8 flex justify-center">
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +107,15 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
|||||||
{isActive && isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
{isActive && isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onPlayNext && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPlayNext(); }}
|
||||||
|
className="p-2 rounded-full bg-slate-50 hover:bg-amber-50 dark:bg-slate-700 dark:hover:bg-amber-900/40 text-amber-600 dark:text-amber-300 transition-colors"
|
||||||
|
title="Play this article next"
|
||||||
|
>
|
||||||
|
<SkipForward className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation(); // Prevent article selection when removing
|
e.stopPropagation(); // Prevent article selection when removing
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Article, ReaderSettings } from '../types';
|
import { Article, ReaderSettings } from '../types';
|
||||||
import { FileText, MousePointerClick } from 'lucide-react';
|
import { FileText, MousePointerClick, Share2, Quote } from 'lucide-react';
|
||||||
import { getDisplayUrl } from '../utils/url';
|
import { getDisplayUrl } from '../utils/url';
|
||||||
|
|
||||||
interface ReaderViewProps {
|
interface ReaderViewProps {
|
||||||
@@ -30,7 +30,10 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
|||||||
fontSize: 'lg',
|
fontSize: 'lg',
|
||||||
lineHeight: 'relaxed',
|
lineHeight: 'relaxed',
|
||||||
fontFamily: 'serif',
|
fontFamily: 'serif',
|
||||||
autoScroll: true
|
autoScroll: true,
|
||||||
|
readingTone: 'clean',
|
||||||
|
pageWidth: 'standard',
|
||||||
|
zenMode: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFontClass = () => {
|
const getFontClass = () => {
|
||||||
@@ -59,6 +62,37 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getToneClasses = () => {
|
||||||
|
switch (s.readingTone) {
|
||||||
|
case 'sepia':
|
||||||
|
return 'bg-amber-50/60 dark:bg-amber-900/30 border border-amber-100 dark:border-amber-800';
|
||||||
|
case 'night':
|
||||||
|
return 'bg-slate-900 text-slate-100 border border-slate-800';
|
||||||
|
default:
|
||||||
|
return 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWidthClass = () => {
|
||||||
|
switch (s.pageWidth) {
|
||||||
|
case 'cozy':
|
||||||
|
return 'max-w-2xl mx-auto';
|
||||||
|
case 'wide':
|
||||||
|
return 'max-w-6xl mx-auto';
|
||||||
|
default:
|
||||||
|
return 'max-w-4xl mx-auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareMoment = (text: string, index: number) => {
|
||||||
|
const base = typeof window !== 'undefined' ? `${window.location.origin}${window.location.pathname}` : '';
|
||||||
|
const shareUrl = `${base}?segment=${index}`;
|
||||||
|
const payload = `"${text.trim()}"\n${shareUrl}`;
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(payload).catch(() => console.warn('Unable to copy share link'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-slate-400 dark:text-slate-600 p-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 transition-colors duration-300">
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 dark:text-slate-600 p-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 transition-colors duration-300">
|
||||||
@@ -72,39 +106,41 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
|||||||
const displayUrl = getDisplayUrl(article.url);
|
const displayUrl = getDisplayUrl(article.url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden h-[calc(100vh-12rem)] flex flex-col transition-colors duration-300">
|
<div className={`${getToneClasses()} ${getWidthClass()} rounded-2xl shadow-sm overflow-hidden h-[calc(100vh-12rem)] flex flex-col transition-colors duration-300`}>
|
||||||
<div className="p-6 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 flex justify-between items-start">
|
{!s.zenMode && (
|
||||||
<div className="flex-1 pr-4">
|
<div className="p-6 border-b border-slate-100 dark:border-slate-800 bg-transparent sticky top-0 z-10 flex justify-between items-start">
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
|
<div className="flex-1 pr-4">
|
||||||
{article.title}
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
|
||||||
</h2>
|
{article.title}
|
||||||
<a
|
</h2>
|
||||||
href={displayUrl.href}
|
<a
|
||||||
target="_blank"
|
href={displayUrl.href}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block"
|
rel="noopener noreferrer"
|
||||||
>
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block"
|
||||||
{displayUrl.hostname}
|
>
|
||||||
</a>
|
{displayUrl.hostname}
|
||||||
</div>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Auto Scroll Toggle */}
|
{/* Auto Scroll Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleAutoScroll}
|
onClick={onToggleAutoScroll}
|
||||||
title={s.autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
title={s.autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
||||||
className={`p-2 rounded-lg transition-all ${
|
className={`p-2 rounded-lg transition-all ${
|
||||||
s.autoScroll
|
s.autoScroll
|
||||||
? 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400'
|
? 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
: 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'
|
: 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MousePointerClick className="w-5 h-5" />
|
<MousePointerClick className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={`flex-grow overflow-y-auto p-6 sm:p-8 space-y-1 custom-scrollbar bg-white dark:bg-slate-900 transition-colors duration-300 ${getFontClass()} ${getSizeClass()}`}
|
className={`flex-grow overflow-y-auto p-6 sm:p-8 space-y-3 custom-scrollbar transition-colors duration-300 ${getFontClass()} ${getSizeClass()} ${getLeadingClass()} ${s.zenMode ? 'pt-10' : ''}`}
|
||||||
>
|
>
|
||||||
{article.segments.length > 0 ? (
|
{article.segments.length > 0 ? (
|
||||||
article.segments.map((segment, idx) => {
|
article.segments.map((segment, idx) => {
|
||||||
@@ -119,14 +155,31 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
|||||||
title="Click to play from here"
|
title="Click to play from here"
|
||||||
className={`
|
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
|
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
|
${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'}`
|
? `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'
|
: '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}
|
<div className="flex justify-between items-start gap-3">
|
||||||
|
<p className="flex-1 whitespace-pre-wrap">{segment.text}</p>
|
||||||
|
<div className="flex flex-col gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleShareMoment(segment.text, idx); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Copy a shareable moment"
|
||||||
|
>
|
||||||
|
<Share2 className="w-3 h-3" /> Share
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(segment.text); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-800 transition-colors"
|
||||||
|
title="Copy this quote"
|
||||||
|
>
|
||||||
|
<Quote className="w-3 h-3" /> Clip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
3
types.ts
3
types.ts
@@ -53,4 +53,7 @@ export interface ReaderSettings {
|
|||||||
lineHeight: 'normal' | 'relaxed' | 'loose';
|
lineHeight: 'normal' | 'relaxed' | 'loose';
|
||||||
fontFamily: 'sans' | 'serif' | 'mono';
|
fontFamily: 'sans' | 'serif' | 'mono';
|
||||||
autoScroll: boolean;
|
autoScroll: boolean;
|
||||||
|
readingTone: 'clean' | 'sepia' | 'night';
|
||||||
|
pageWidth: 'cozy' | 'standard' | 'wide';
|
||||||
|
zenMode: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user