Implement segment selection in ReaderView for user-driven playback control. This change allows users to click on specific segments within an article to jump to and play that segment directly. The Gemini service's HTML parsing has also been simplified by removing redundant selectors and focusing on essential tag removal for more efficient text extraction.
145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
|
|
import React, { useEffect, useRef } from 'react';
|
|
import { Article, ReaderSettings } from '../types';
|
|
import { FileText, MousePointerClick } from 'lucide-react';
|
|
|
|
interface ReaderViewProps {
|
|
article?: Article | null;
|
|
settings?: ReaderSettings;
|
|
onToggleAutoScroll?: () => void;
|
|
onSegmentSelect?: (index: number) => void;
|
|
}
|
|
|
|
export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onToggleAutoScroll, onSegmentSelect }) => {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-scroll to active segment
|
|
useEffect(() => {
|
|
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, 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 (
|
|
<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">
|
|
<FileText className="w-12 h-12 mb-4 opacity-50" />
|
|
<p className="text-lg font-medium">Select an article to read along</p>
|
|
<p className="text-sm">The text will appear here while you listen.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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="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">
|
|
<div className="flex-1 pr-4">
|
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
|
|
{article.title}
|
|
</h2>
|
|
<a
|
|
href={article.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block"
|
|
>
|
|
{new URL(article.url).hostname}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Auto Scroll Toggle */}
|
|
<button
|
|
onClick={onToggleAutoScroll}
|
|
title={s.autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
|
className={`p-2 rounded-lg transition-all ${
|
|
s.autoScroll
|
|
? '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'
|
|
}`}
|
|
>
|
|
<MousePointerClick className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
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()}`}
|
|
>
|
|
{article.segments.length > 0 ? (
|
|
article.segments.map((segment, idx) => {
|
|
const isActive = article.currentSegmentIndex === idx;
|
|
return (
|
|
<div
|
|
key={segment.id}
|
|
id={`segment-${idx}`}
|
|
onClick={() => 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
|
|
? 'text-slate-900 dark:text-white bg-blue-50 dark:bg-blue-900/20 border-blue-500 shadow-sm'
|
|
: '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>
|
|
);
|
|
})
|
|
) : (
|
|
// Loading State skeleton
|
|
<div className="space-y-4 animate-pulse">
|
|
{[1,2,3,4].map(i => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded w-full"></div>
|
|
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded w-full"></div>
|
|
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded w-3/4"></div>
|
|
</div>
|
|
))}
|
|
<p className="text-slate-400 dark:text-slate-600 italic mt-4">Extracting article content...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|