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:
184
services/rssService.ts
Normal file
184
services/rssService.ts
Normal 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' }
|
||||
];
|
||||
Reference in New Issue
Block a user