Merge pull request #1 from Tony0410/codex/track-and-clean-up-audio-urls

Add cleanup for audio segment object URLs
This commit is contained in:
Anthony
2025-11-27 21:19:29 +08:00
committed by GitHub
2 changed files with 65 additions and 13 deletions

50
App.tsx
View File

@@ -6,6 +6,7 @@ import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSe
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
import { extractArticleContent, generateSpeechFromText } from './services/geminiService'; import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
import { base64ToUint8Array, createWavBlob } from './services/audioUtils'; import { base64ToUint8Array, createWavBlob } from './services/audioUtils';
import { createTrackedObjectUrl, revokeAllTrackedObjectUrls, revokeMultipleObjectUrls, revokeTrackedObjectUrl } from './services/objectUrlManager';
import { segmentText } from './services/textUtils'; import { segmentText } from './services/textUtils';
import { QueueItem } from './components/QueueItem'; import { QueueItem } from './components/QueueItem';
import { VoiceSelector } from './components/VoiceSelector'; import { VoiceSelector } from './components/VoiceSelector';
@@ -47,6 +48,11 @@ export default function App() {
return null; return null;
}; };
const cleanupArticleAudio = (article?: Article) => {
if (!article) return;
revokeMultipleObjectUrls(article.segments.map(seg => seg.audioUrl));
};
// -- State Updaters -- // -- State Updaters --
const updateArticle = (id: string, updates: Partial<Article>) => { const updateArticle = (id: string, updates: Partial<Article>) => {
@@ -57,7 +63,12 @@ export default function App() {
setQueue(prev => prev.map(article => { setQueue(prev => prev.map(article => {
if (article.id !== articleId) return article; if (article.id !== articleId) return article;
const newSegments = article.segments.map(seg => const newSegments = article.segments.map(seg =>
seg.id === segmentId ? { ...seg, ...updates } : seg seg.id === segmentId ? (() => {
if ('audioUrl' in updates && seg.audioUrl && updates.audioUrl !== seg.audioUrl) {
revokeTrackedObjectUrl(seg.audioUrl);
}
return { ...seg, ...updates };
})() : seg
); );
return { ...article, segments: newSegments }; return { ...article, segments: newSegments };
})); }));
@@ -76,7 +87,7 @@ export default function App() {
const base64Audio = await generateSpeechFromText(text, voice); const base64Audio = await generateSpeechFromText(text, voice);
const pcmData = base64ToUint8Array(base64Audio); const pcmData = base64ToUint8Array(base64Audio);
const wavBlob = createWavBlob(pcmData); const wavBlob = createWavBlob(pcmData);
const audioUrl = URL.createObjectURL(wavBlob); const audioUrl = createTrackedObjectUrl(wavBlob);
updateSegment(articleId, segmentId, { audioUrl, isLoading: false }); updateSegment(articleId, segmentId, { audioUrl, isLoading: false });
} catch (error) { } catch (error) {
console.error("Segment generation failed", error); console.error("Segment generation failed", error);
@@ -115,6 +126,7 @@ export default function App() {
if (idx <= article.currentSegmentIndex) { if (idx <= article.currentSegmentIndex) {
return seg; return seg;
} }
revokeTrackedObjectUrl(seg.audioUrl);
// Invalidate all future segments // Invalidate all future segments
return { ...seg, audioUrl: undefined, isLoading: false, hasError: false }; return { ...seg, audioUrl: undefined, isLoading: false, hasError: false };
}) })
@@ -123,12 +135,15 @@ export default function App() {
// For inactive articles, invalidate everything // For inactive articles, invalidate everything
return { return {
...article, ...article,
segments: article.segments.map(seg => ({ segments: article.segments.map(seg => {
...seg, revokeTrackedObjectUrl(seg.audioUrl);
audioUrl: undefined, return {
isLoading: false, ...seg,
hasError: false audioUrl: undefined,
})) isLoading: false,
hasError: false
};
})
}; };
})); }));
}, [playerState.currentArticleId]); }, [playerState.currentArticleId]);
@@ -375,6 +390,14 @@ export default function App() {
return () => audio.removeEventListener('ended', handleEnded); return () => audio.removeEventListener('ended', handleEnded);
}, [playerState.currentArticleId, playArticle]); }, [playerState.currentArticleId, playArticle]);
useEffect(() => {
return () => {
audioRef.current.pause();
audioRef.current.src = '';
revokeAllTrackedObjectUrls();
};
}, []);
const handleSpeedChange = (newSpeed: number) => { const handleSpeedChange = (newSpeed: number) => {
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed)); const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
setPlayerState(prev => ({ ...prev, playbackRate: speed })); setPlayerState(prev => ({ ...prev, playbackRate: speed }));
@@ -547,10 +570,15 @@ export default function App() {
onRemove={() => { onRemove={() => {
if (playerState.currentArticleId === article.id) { if (playerState.currentArticleId === article.id) {
pausePlayback(); pausePlayback();
setPlayerState(prev => ({ ...prev, currentArticleId: null })); setPlayerState(prev => ({ ...prev, currentArticleId: null, isPlaying: false }));
audioRef.current.src = '';
} }
setQueue(prev => prev.filter(a => a.id !== article.id)); setQueue(prev => {
if (viewId === article.id) setViewId(null); const target = prev.find(a => a.id === article.id);
cleanupArticleAudio(target);
return prev.filter(a => a.id !== article.id);
});
setViewId(prev => prev === article.id ? null : prev);
}} }}
/> />
</div> </div>

View File

@@ -0,0 +1,24 @@
const trackedUrls = new Set<string>();
export const createTrackedObjectUrl = (blob: Blob): string => {
const url = URL.createObjectURL(blob);
trackedUrls.add(url);
return url;
};
export const revokeTrackedObjectUrl = (url?: string) => {
if (!url) return;
if (trackedUrls.has(url)) {
trackedUrls.delete(url);
}
URL.revokeObjectURL(url);
};
export const revokeMultipleObjectUrls = (urls: (string | undefined)[]) => {
urls.forEach(revokeTrackedObjectUrl);
};
export const revokeAllTrackedObjectUrls = () => {
trackedUrls.forEach(url => URL.revokeObjectURL(url));
trackedUrls.clear();
};