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

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;
};