mirror of
https://github.com/Tony0410/News-reader-pro.git
synced 2026-05-24 21:31:44 +08:00
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:
135
services/audioExportService.ts
Normal file
135
services/audioExportService.ts
Normal 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`);
|
||||
};
|
||||
@@ -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
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' }
|
||||
];
|
||||
213
services/storageService.ts
Normal file
213
services/storageService.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user