Major feature update: Enhanced RSS, voice fixes, dark mode, and UI improvements

## New Features
- Article Summary Mode: AI-generated 30-second summaries with complexity analysis
- Reading Stats Dashboard: Track articles read, listening time, and streaks
- Bookmark/Resume: Auto-save progress when pausing, resume from where you left off
- Audio Export: Export articles as downloadable WAV files
- RSS Feed Manager: Subscribe to feeds with real-time validation and 31+ recommendations
- Smart Speed: Auto-adjust playback based on article complexity
- Voice Moods: Quick presets for different listening scenarios

## RSS Enhancements
- Expanded recommendations from 8 to 31 sources across 5 categories:
  * General News (9 sources)
  * Technology (8 sources)
  * Business & Finance (5 sources)
  * Science & Research (5 sources)
  * International News (4 sources)
- Real-time URL validation with visual feedback
- Detailed error messages for different failure scenarios
- Always-visible categorized recommendations
- Auto-loading articles when feeds are added

## Bug Fixes
- Fixed voice selection: Selected voice now consistently applies to playback
- Implemented voice generation counter to prevent voice mixing between paragraphs
- Fixed speed control to snap to clean 0.5 increments (1.0, 1.5, 2.0, etc.)
- Fixed dark mode toggle by configuring Tailwind CDN for class-based dark mode
- Removed vibe visualizer animation

## UI/UX Improvements
- Redesigned voice selector with prominent voice panel and preview functionality
- Added voice cards with emojis and descriptions
- Enhanced feature toolbar with quick access to all new features
- Improved reader view with better typography and reading modes
- Added ambient reading modes (clean, sepia, night light)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Tony0410
2025-11-28 00:42:14 +00:00
parent 3169438f7e
commit 9025d1b8f1
16 changed files with 2198 additions and 128 deletions

View File

@@ -0,0 +1,135 @@
import { Article, AudioSegment } from '../types';
import { SAMPLE_RATE } from '../constants';
/**
* Combines multiple audio segments into a single downloadable file.
* Since browser-based MP3 encoding is complex, we export as WAV format
* which is widely supported and maintains quality.
*/
export const exportArticleAudio = async (
article: Article,
onProgress?: (progress: number) => void
): Promise<Blob> => {
const audioSegments = article.segments.filter(seg => seg.audioUrl);
if (audioSegments.length === 0) {
throw new Error('No audio segments available for export');
}
// Fetch all audio data
const audioBuffers: ArrayBuffer[] = [];
let totalLength = 0;
for (let i = 0; i < audioSegments.length; i++) {
const segment = audioSegments[i];
if (!segment.audioUrl) continue;
try {
const response = await fetch(segment.audioUrl);
const arrayBuffer = await response.arrayBuffer();
audioBuffers.push(arrayBuffer);
// Parse WAV to get PCM data length (skip 44-byte header)
totalLength += arrayBuffer.byteLength - 44;
if (onProgress) {
onProgress(Math.round(((i + 1) / audioSegments.length) * 100));
}
} catch (e) {
console.warn(`Failed to fetch segment ${i}:`, e);
}
}
if (audioBuffers.length === 0) {
throw new Error('Failed to fetch any audio segments');
}
// Combine PCM data from all WAV files
const combinedPcm = new Uint8Array(totalLength);
let offset = 0;
for (const buffer of audioBuffers) {
// Skip WAV header (44 bytes) and copy PCM data
const pcmData = new Uint8Array(buffer, 44);
combinedPcm.set(pcmData, offset);
offset += pcmData.length;
}
// Create new WAV file with combined data
return createWavFile(combinedPcm, SAMPLE_RATE);
};
/**
* Creates a WAV file from PCM data
*/
const createWavFile = (pcmData: Uint8Array, sampleRate: number): Blob => {
const numChannels = 1;
const bitsPerSample = 16;
const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
const blockAlign = numChannels * (bitsPerSample / 8);
const dataSize = pcmData.length;
const fileSize = 44 + dataSize;
const buffer = new ArrayBuffer(fileSize);
const view = new DataView(buffer);
// RIFF header
writeString(view, 0, 'RIFF');
view.setUint32(4, fileSize - 8, true);
writeString(view, 8, 'WAVE');
// fmt chunk
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // chunk size
view.setUint16(20, 1, true); // PCM format
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
// data chunk
writeString(view, 36, 'data');
view.setUint32(40, dataSize, true);
// PCM data
const outputArray = new Uint8Array(buffer);
outputArray.set(pcmData, 44);
return new Blob([buffer], { type: 'audio/wav' });
};
const writeString = (view: DataView, offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
/**
* Triggers download of the audio file
*/
export const downloadAudio = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.wav') ? filename : `${filename}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
/**
* Export and download article audio with progress tracking
*/
export const exportAndDownloadArticle = async (
article: Article,
onProgress?: (progress: number) => void
): Promise<void> => {
const blob = await exportArticleAudio(article, onProgress);
const safeTitle = article.title
.replace(/[^a-zA-Z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 50);
downloadAudio(blob, `${safeTitle}_newscaster.wav`);
};

View File

@@ -211,6 +211,87 @@ export const extractArticleContent = async (url: string): Promise<{ title: strin
}
};
/**
* Generates a 30-second summary of an article.
*/
export const generateArticleSummary = async (text: string, title: string): Promise<string> => {
const ai = getAiClient();
const prompt = `
You are a professional news summarizer. Create a brief, engaging summary of the following article.
TITLE: ${title}
ARTICLE TEXT:
${text.substring(0, 8000)} ${text.length > 8000 ? '...[truncated]' : ''}
RULES:
1. Create a summary that can be read aloud in about 30 seconds (approximately 80-100 words)
2. Start with the most important/newsworthy point
3. Include 2-3 key facts or takeaways
4. Use clear, conversational language suitable for audio
5. Do NOT use bullet points or formatting - write in flowing sentences
6. End with a brief mention of why this matters
Output ONLY the summary text, nothing else.
`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
config: {
temperature: 0.3,
}
});
return response.text?.trim() || "Unable to generate summary.";
};
/**
* Analyzes text complexity for Smart Speed feature.
*/
export const analyzeTextComplexity = (text: string): {
complexity: 'simple' | 'moderate' | 'complex';
wordCount: number;
estimatedReadTime: number;
} => {
const words = text.split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length;
// Calculate average word length
const avgWordLength = words.reduce((sum, w) => sum + w.length, 0) / wordCount;
// Count sentences (rough estimate)
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
const avgSentenceLength = wordCount / Math.max(1, sentences);
// Count complex indicators
const complexWords = words.filter(w => w.length > 10).length;
const complexWordRatio = complexWords / wordCount;
// Score complexity (0-100)
let score = 0;
score += Math.min(30, avgWordLength * 4); // Longer words = more complex
score += Math.min(30, avgSentenceLength * 1.5); // Longer sentences = more complex
score += Math.min(40, complexWordRatio * 200); // More complex words = more complex
// Determine complexity level
let complexity: 'simple' | 'moderate' | 'complex';
if (score < 35) {
complexity = 'simple';
} else if (score < 55) {
complexity = 'moderate';
} else {
complexity = 'complex';
}
// Estimate read time (words per minute varies by complexity)
const wpm = complexity === 'simple' ? 180 : complexity === 'moderate' ? 150 : 130;
const estimatedReadTime = Math.ceil(wordCount / wpm);
return { complexity, wordCount, estimatedReadTime };
};
/**
* Generates speech audio from text.
*/

184
services/rssService.ts Normal file
View File

@@ -0,0 +1,184 @@
import { v4 as uuidv4 } from 'uuid';
import { RSSFeed, RSSArticle } from '../types';
import { saveRSSFeed, updateRSSFeedLastFetched } from './storageService';
// CORS proxy for RSS feeds
const RSS_PROXY = (url: string) => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`;
/**
* Parse RSS/Atom feed XML into articles
*/
const parseRSSFeed = (xml: string, feedId: string): RSSArticle[] => {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const articles: RSSArticle[] = [];
// Try RSS 2.0 format
const items = doc.querySelectorAll('item');
if (items.length > 0) {
items.forEach(item => {
const title = item.querySelector('title')?.textContent || '';
const link = item.querySelector('link')?.textContent || '';
const description = item.querySelector('description')?.textContent || '';
const pubDate = item.querySelector('pubDate')?.textContent || '';
if (title && link) {
articles.push({
title: title.trim(),
url: link.trim(),
description: description.replace(/<[^>]*>/g, '').substring(0, 200),
pubDate,
feedId
});
}
});
return articles;
}
// Try Atom format
const entries = doc.querySelectorAll('entry');
entries.forEach(entry => {
const title = entry.querySelector('title')?.textContent || '';
const link = entry.querySelector('link')?.getAttribute('href') || '';
const summary = entry.querySelector('summary')?.textContent || '';
const published = entry.querySelector('published')?.textContent || '';
if (title && link) {
articles.push({
title: title.trim(),
url: link.trim(),
description: summary.replace(/<[^>]*>/g, '').substring(0, 200),
pubDate: published,
feedId
});
}
});
return articles;
};
/**
* Get feed title from XML
*/
const getFeedTitle = (xml: string): string => {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
// RSS 2.0
const channelTitle = doc.querySelector('channel > title')?.textContent;
if (channelTitle) return channelTitle.trim();
// Atom
const feedTitle = doc.querySelector('feed > title')?.textContent;
if (feedTitle) return feedTitle.trim();
return 'Unknown Feed';
};
/**
* Fetch and parse RSS feed
*/
export const fetchRSSFeed = async (feedUrl: string): Promise<{
feed: RSSFeed;
articles: RSSArticle[];
}> => {
const response = await fetch(RSS_PROXY(feedUrl));
if (!response.ok) {
throw new Error(`Failed to fetch feed: ${response.status}`);
}
const xml = await response.text();
const feedId = uuidv4();
const title = getFeedTitle(xml);
const articles = parseRSSFeed(xml, feedId);
const feed: RSSFeed = {
id: feedId,
url: feedUrl,
title,
articleCount: articles.length,
lastFetched: Date.now(),
isActive: true,
addedAt: Date.now()
};
return { feed, articles };
};
/**
* Refresh articles from an existing feed
*/
export const refreshFeed = async (feed: RSSFeed): Promise<RSSArticle[]> => {
const response = await fetch(RSS_PROXY(feed.url));
if (!response.ok) {
throw new Error(`Failed to refresh feed: ${response.status}`);
}
const xml = await response.text();
const articles = parseRSSFeed(xml, feed.id);
updateRSSFeedLastFetched(feed.id);
return articles;
};
/**
* Validate RSS feed URL
*/
export const validateRSSUrl = async (url: string): Promise<boolean> => {
try {
const response = await fetch(RSS_PROXY(url));
if (!response.ok) return false;
const text = await response.text();
// Check if it looks like RSS/Atom
return text.includes('<rss') || text.includes('<feed') || text.includes('<channel');
} catch {
return false;
}
};
// Popular RSS feed suggestions organized by category
export const SUGGESTED_FEEDS = [
// General News
{ name: 'BBC News', url: 'https://feeds.bbci.co.uk/news/rss.xml', category: 'news' },
{ name: 'NPR News', url: 'https://feeds.npr.org/1001/rss.xml', category: 'news' },
{ name: 'Reuters', url: 'https://www.reutersagency.com/feed/', category: 'news' },
{ name: 'The Guardian', url: 'https://www.theguardian.com/world/rss', category: 'news' },
{ name: 'Associated Press', url: 'https://feeds.apnews.com/rss/topnews', category: 'news' },
{ name: 'CNN World', url: 'http://rss.cnn.com/rss/cnn_world.rss', category: 'news' },
{ name: 'Al Jazeera', url: 'https://www.aljazeera.com/xml/rss/all.xml', category: 'news' },
{ name: 'The New York Times', url: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', category: 'news' },
{ name: 'Washington Post', url: 'https://feeds.washingtonpost.com/rss/world', category: 'news' },
// Technology
{ name: 'TechCrunch', url: 'https://techcrunch.com/feed/', category: 'tech' },
{ name: 'Hacker News', url: 'https://hnrss.org/frontpage', category: 'tech' },
{ name: 'Ars Technica', url: 'https://feeds.arstechnica.com/arstechnica/index', category: 'tech' },
{ name: 'The Verge', url: 'https://www.theverge.com/rss/index.xml', category: 'tech' },
{ name: 'Wired', url: 'https://www.wired.com/feed/rss', category: 'tech' },
{ name: 'MIT Technology Review', url: 'https://www.technologyreview.com/feed/', category: 'tech' },
{ name: 'Engadget', url: 'https://www.engadget.com/rss.xml', category: 'tech' },
{ name: 'ZDNet', url: 'https://www.zdnet.com/news/rss.xml', category: 'tech' },
// Business & Finance
{ name: 'Financial Times', url: 'https://www.ft.com/?format=rss', category: 'business' },
{ name: 'Bloomberg', url: 'https://feeds.bloomberg.com/markets/news.rss', category: 'business' },
{ name: 'The Economist', url: 'https://www.economist.com/finance-and-economics/rss.xml', category: 'business' },
{ name: 'Forbes', url: 'https://www.forbes.com/real-time/feed2/', category: 'business' },
{ name: 'MarketWatch', url: 'https://feeds.marketwatch.com/marketwatch/topstories/', category: 'business' },
// Science
{ name: 'Nature News', url: 'https://www.nature.com/nature.rss', category: 'science' },
{ name: 'Scientific American', url: 'https://www.scientificamerican.com/feed/', category: 'science' },
{ name: 'ScienceDaily', url: 'https://www.sciencedaily.com/rss/all.xml', category: 'science' },
{ name: 'Phys.org', url: 'https://phys.org/rss-feed/', category: 'science' },
{ name: 'Space.com', url: 'https://www.space.com/feeds/all', category: 'science' },
// International
{ name: 'The Japan Times', url: 'https://www.japantimes.co.jp/feed/', category: 'international' },
{ name: 'South China Morning Post', url: 'https://www.scmp.com/rss/91/feed', category: 'international' },
{ name: 'Deutsche Welle', url: 'https://rss.dw.com/rdf/rss-en-all', category: 'international' },
{ name: 'France 24', url: 'https://www.france24.com/en/rss', category: 'international' }
];

213
services/storageService.ts Normal file
View File

@@ -0,0 +1,213 @@
import { ReadingStats, Bookmark, RSSFeed, VoiceName } from '../types';
const STORAGE_KEYS = {
STATS: 'newscaster_stats',
BOOKMARKS: 'newscaster_bookmarks',
RSS_FEEDS: 'newscaster_rss_feeds',
LAST_PLAYED: 'newscaster_last_played'
};
// Default stats
const DEFAULT_STATS: ReadingStats = {
totalArticlesRead: 0,
totalMinutesListened: 0,
totalWordsRead: 0,
currentStreak: 0,
longestStreak: 0,
lastReadDate: '',
articlesPerDay: {},
favoriteVoice: VoiceName.Puck,
voiceUsage: {
[VoiceName.Puck]: 0,
[VoiceName.Charon]: 0,
[VoiceName.Kore]: 0,
[VoiceName.Fenrir]: 0,
[VoiceName.Zephyr]: 0,
[VoiceName.Aoede]: 0
}
};
// ==================== STATS ====================
export const getStats = (): ReadingStats => {
try {
const stored = localStorage.getItem(STORAGE_KEYS.STATS);
if (stored) {
return { ...DEFAULT_STATS, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to load stats:', e);
}
return DEFAULT_STATS;
};
export const saveStats = (stats: ReadingStats): void => {
try {
localStorage.setItem(STORAGE_KEYS.STATS, JSON.stringify(stats));
} catch (e) {
console.warn('Failed to save stats:', e);
}
};
export const updateStatsOnArticleComplete = (
wordCount: number,
minutesListened: number,
voice: VoiceName
): ReadingStats => {
const stats = getStats();
const today = new Date().toISOString().split('T')[0];
// Update totals
stats.totalArticlesRead += 1;
stats.totalMinutesListened += minutesListened;
stats.totalWordsRead += wordCount;
// Update daily count
stats.articlesPerDay[today] = (stats.articlesPerDay[today] || 0) + 1;
// Update streak
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
if (stats.lastReadDate === yesterday) {
stats.currentStreak += 1;
} else if (stats.lastReadDate !== today) {
stats.currentStreak = 1;
}
stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
stats.lastReadDate = today;
// Update voice usage
stats.voiceUsage[voice] = (stats.voiceUsage[voice] || 0) + 1;
// Determine favorite voice
let maxUsage = 0;
Object.entries(stats.voiceUsage).forEach(([v, count]) => {
if (count > maxUsage) {
maxUsage = count;
stats.favoriteVoice = v as VoiceName;
}
});
saveStats(stats);
return stats;
};
// ==================== BOOKMARKS ====================
export const getBookmarks = (): Bookmark[] => {
try {
const stored = localStorage.getItem(STORAGE_KEYS.BOOKMARKS);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load bookmarks:', e);
}
return [];
};
export const saveBookmark = (bookmark: Bookmark): void => {
try {
const bookmarks = getBookmarks();
// Remove existing bookmark for same URL
const filtered = bookmarks.filter(b => b.url !== bookmark.url);
filtered.unshift(bookmark); // Add to beginning
// Keep only last 50 bookmarks
const trimmed = filtered.slice(0, 50);
localStorage.setItem(STORAGE_KEYS.BOOKMARKS, JSON.stringify(trimmed));
} catch (e) {
console.warn('Failed to save bookmark:', e);
}
};
export const removeBookmark = (url: string): void => {
try {
const bookmarks = getBookmarks();
const filtered = bookmarks.filter(b => b.url !== url);
localStorage.setItem(STORAGE_KEYS.BOOKMARKS, JSON.stringify(filtered));
} catch (e) {
console.warn('Failed to remove bookmark:', e);
}
};
export const getBookmarkForUrl = (url: string): Bookmark | undefined => {
const bookmarks = getBookmarks();
return bookmarks.find(b => b.url === url);
};
// ==================== RSS FEEDS ====================
export const getRSSFeeds = (): RSSFeed[] => {
try {
const stored = localStorage.getItem(STORAGE_KEYS.RSS_FEEDS);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load RSS feeds:', e);
}
return [];
};
export const saveRSSFeed = (feed: RSSFeed): void => {
try {
const feeds = getRSSFeeds();
const existing = feeds.findIndex(f => f.id === feed.id);
if (existing >= 0) {
feeds[existing] = feed;
} else {
feeds.push(feed);
}
localStorage.setItem(STORAGE_KEYS.RSS_FEEDS, JSON.stringify(feeds));
} catch (e) {
console.warn('Failed to save RSS feed:', e);
}
};
export const removeRSSFeed = (feedId: string): void => {
try {
const feeds = getRSSFeeds();
const filtered = feeds.filter(f => f.id !== feedId);
localStorage.setItem(STORAGE_KEYS.RSS_FEEDS, JSON.stringify(filtered));
} catch (e) {
console.warn('Failed to remove RSS feed:', e);
}
};
export const updateRSSFeedLastFetched = (feedId: string): void => {
try {
const feeds = getRSSFeeds();
const feed = feeds.find(f => f.id === feedId);
if (feed) {
feed.lastFetched = Date.now();
localStorage.setItem(STORAGE_KEYS.RSS_FEEDS, JSON.stringify(feeds));
}
} catch (e) {
console.warn('Failed to update RSS feed:', e);
}
};
// ==================== LAST PLAYED ====================
export const saveLastPlayed = (articleId: string, segmentIndex: number): void => {
try {
localStorage.setItem(STORAGE_KEYS.LAST_PLAYED, JSON.stringify({
articleId,
segmentIndex,
timestamp: Date.now()
}));
} catch (e) {
console.warn('Failed to save last played:', e);
}
};
export const getLastPlayed = (): { articleId: string; segmentIndex: number } | null => {
try {
const stored = localStorage.getItem(STORAGE_KEYS.LAST_PLAYED);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load last played:', e);
}
return null;
};