managarten/apps-archived/reader/apps/mobile/hooks/useAudio.ts
Till-JS 61d181fbc2 chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:03:59 +01:00

416 lines
10 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Audio } from 'expo-av';
import { AudioService, AudioGenerationProgress } from '~/services/audioService';
import { useTexts } from './useTexts';
import { useStore } from '~/store/store';
import { AudioChunk } from '~/types/database';
export interface AudioState {
isPlaying: boolean;
isLoading: boolean;
currentPosition: number;
duration: number;
currentChunk?: AudioChunk;
sound?: Audio.Sound;
playbackRate: number;
}
export const useAudio = () => {
const { settings, updateSettings } = useStore();
const { updateText } = useTexts();
const [audioState, setAudioState] = useState<AudioState>({
isPlaying: false,
isLoading: false,
currentPosition: 0,
duration: 0,
playbackRate: settings.playbackRate || 1.0,
});
const [generationProgress, setGenerationProgress] = useState<AudioGenerationProgress | null>(
null
);
const [downloadProgress, setDownloadProgress] = useState<{
completed: number;
total: number;
currentChunk: string;
} | null>(null);
const { setCurrentText, setIsPlaying, setCurrentPosition } = useStore();
const audioService = AudioService.getInstance();
// Initialize audio session
useEffect(() => {
const initializeAudio = async () => {
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
shouldDuckAndroid: true,
staysActiveInBackground: true,
playThroughEarpieceAndroid: false,
});
} catch (error) {
console.error('Error initializing audio:', error);
}
};
initializeAudio();
}, []);
// Clean up audio when component unmounts
useEffect(() => {
return () => {
if (audioState.sound) {
audioState.sound.unloadAsync();
}
};
}, [audioState.sound]);
// Generate audio for a text
const generateAudio = useCallback(
async (
textId: string,
content: string,
voice: string = 'de-DE',
speed: number = 1.0,
currentText?: any
) => {
try {
setGenerationProgress({
chunksCompleted: 0,
totalChunks: 1,
currentChunk: 'Starting...',
isComplete: false,
});
// Import migration helper
const { generateVersionId } = await import('~/utils/audioMigration');
const newVersionId = generateVersionId();
const result = await audioService.generateAudioForText(
textId,
content,
voice,
speed,
1000,
setGenerationProgress,
newVersionId
);
if (!result.success) {
throw new Error(result.error);
}
// Get current text to append to audioVersions
if (!currentText) {
throw new Error('Text must be provided to generate audio');
}
// Import migration helper for existing code
const { migrateAudioData } = await import('~/utils/audioMigration');
// Migrate old data if needed
const migratedData = migrateAudioData(currentText.data);
const newAudioVersion = {
id: newVersionId,
chunks: result.chunks || [],
settings: { voice, speed },
totalSize: result.chunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
hasLocalCache: false,
createdAt: new Date().toISOString(),
};
// Append new version to audioVersions
const updatedAudioVersions = [...(migratedData.audioVersions || []), newAudioVersion];
// Update text with new audio version
await updateText(textId, {
data: {
...migratedData,
audioVersions: updatedAudioVersions,
currentAudioVersion: newVersionId,
// Keep legacy audio field for backward compatibility
audio: {
hasLocalCache: false,
chunks: result.chunks || [],
totalSize: newAudioVersion.totalSize,
lastGenerated: newAudioVersion.createdAt,
settings: { voice, speed },
},
},
});
return result;
} catch (error) {
console.error('Error generating audio:', error);
throw error;
} finally {
setGenerationProgress(null);
}
},
[audioService, updateText]
);
// Download audio chunks to local storage
const downloadAudio = useCallback(
async (textId: string, chunks: AudioChunk[]) => {
try {
setDownloadProgress({
completed: 0,
total: chunks.length,
currentChunk: 'Starting download...',
});
const result = await audioService.downloadAudioChunks(textId, chunks, setDownloadProgress);
if (!result.success) {
throw new Error(result.error);
}
// Update text to mark as locally cached
await updateText(textId, {
data: {
audio: {
hasLocalCache: true,
chunks: result.localChunks || chunks,
totalSize: result.localChunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
lastGenerated: new Date().toISOString(),
},
},
});
return result;
} catch (error) {
console.error('Error downloading audio:', error);
throw error;
} finally {
setDownloadProgress(null);
}
},
[audioService, updateText]
);
// Play audio from local cache
const playAudio = useCallback(
async (textId: string, chunks: AudioChunk[], startPosition: number = 0) => {
try {
setAudioState((prev) => ({ ...prev, isLoading: true }));
// Stop current audio if playing
if (audioState.sound) {
audioState.sound.unloadAsync();
}
// Calculate total duration from all chunks
const totalDuration = chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000; // Convert to milliseconds
const result = await audioService.playAudioFromSupabase(textId, chunks, startPosition);
if (!result.sound) {
throw new Error(result.error);
}
const currentChunk = result.chunk;
const allChunks = result.chunks || chunks;
// Set up playback status update
result.sound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded) {
// Calculate the actual position across all chunks
const chunkPosition = status.positionMillis || 0;
const overallPosition = currentChunk
? currentChunk.start + chunkPosition
: chunkPosition;
setAudioState((prev) => ({
...prev,
isPlaying: status.isPlaying,
currentPosition: overallPosition,
duration: totalDuration, // Keep using total duration
}));
// Update global store
setIsPlaying(status.isPlaying);
setCurrentPosition(overallPosition);
}
});
setAudioState((prev) => ({
...prev,
sound: result.sound,
isLoading: false,
isPlaying: true,
duration: totalDuration, // Set total duration of all chunks
currentChunk: currentChunk,
}));
setCurrentText(textId);
// Start playing
await result.sound.playAsync();
// Apply saved playback rate
if (audioState.playbackRate !== 1.0) {
await result.sound.setRateAsync(audioState.playbackRate, true);
}
} catch (error) {
console.error('Error playing audio:', error);
setAudioState((prev) => ({ ...prev, isLoading: false }));
throw error;
}
},
[
audioState.sound,
audioState.playbackRate,
audioService,
setCurrentText,
setIsPlaying,
setCurrentPosition,
]
);
// Pause audio
const pauseAudio = useCallback(async () => {
if (audioState.sound) {
await audioState.sound.pauseAsync();
setAudioState((prev) => ({ ...prev, isPlaying: false }));
setIsPlaying(false);
}
}, [audioState.sound, setIsPlaying]);
// Resume audio
const resumeAudio = useCallback(async () => {
if (audioState.sound) {
await audioState.sound.playAsync();
setAudioState((prev) => ({ ...prev, isPlaying: true }));
setIsPlaying(true);
}
}, [audioState.sound, setIsPlaying]);
// Stop audio
const stopAudio = useCallback(async () => {
if (audioState.sound) {
await audioState.sound.pauseAsync();
await audioState.sound.unloadAsync();
setAudioState((prev) => ({
...prev,
sound: undefined,
isPlaying: false,
currentPosition: 0,
duration: 0,
}));
setCurrentText(null);
setIsPlaying(false);
setCurrentPosition(0);
}
}, [audioState.sound, setCurrentText, setIsPlaying, setCurrentPosition]);
// Seek to position
const seekTo = useCallback(
async (position: number) => {
if (audioState.sound) {
await audioState.sound.setPositionAsync(position);
}
},
[audioState.sound]
);
// Seek forward by seconds
const seekForward = useCallback(
async (seconds: number = 15) => {
if (audioState.sound && audioState.duration > 0) {
const newPosition = Math.min(
audioState.currentPosition + seconds * 1000,
audioState.duration
);
await audioState.sound.setPositionAsync(newPosition);
}
},
[audioState.sound, audioState.currentPosition, audioState.duration]
);
// Seek backward by seconds
const seekBackward = useCallback(
async (seconds: number = 15) => {
if (audioState.sound) {
const newPosition = Math.max(audioState.currentPosition - seconds * 1000, 0);
await audioState.sound.setPositionAsync(newPosition);
}
},
[audioState.sound, audioState.currentPosition]
);
// Set playback speed
const setPlaybackSpeed = useCallback(
async (rate: number) => {
if (audioState.sound) {
try {
await audioState.sound.setRateAsync(rate, true);
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
// Persist to store
updateSettings({ playbackRate: rate });
} catch (error) {
console.error('Error setting playback rate:', error);
}
} else {
// If no sound is playing, just update the state for next playback
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
// Persist to store
updateSettings({ playbackRate: rate });
}
},
[audioState.sound, updateSettings]
);
// Clear audio cache
const clearCache = useCallback(
async (textId: string, chunks: AudioChunk[]) => {
await audioService.clearAudioCache(textId, chunks);
// Update text to mark as not cached
await updateText(textId, {
data: {
audio: {
hasLocalCache: false,
chunks,
totalSize: 0,
},
},
});
},
[audioService, updateText]
);
// Get cache size
const getCacheSize = useCallback(async () => {
return await audioService.getCacheSize();
}, [audioService]);
// Check if audio is cached
const isAudioCached = useCallback(
async (textId: string, chunks: AudioChunk[]) => {
return await audioService.isAudioCached(textId, chunks);
},
[audioService]
);
return {
audioState,
generationProgress,
downloadProgress,
generateAudio,
downloadAudio,
playAudio,
pauseAudio,
resumeAudio,
stopAudio,
seekTo,
seekForward,
seekBackward,
setPlaybackSpeed,
clearCache,
getCacheSize,
isAudioCached,
};
};