mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 11:39:39 +02:00
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>
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Platform } from 'react-native';
|
|
import { AudioPlayer, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
|
|
import { NotificationChannel } from '~/features/notifications/types';
|
|
import NotificationService from '~/features/notifications/NotificationService.native';
|
|
import { formatDurationWithUnits, formatDurationFromMs } from '~/utils/formatters';
|
|
import useTimer from '~/hooks/useTimer';
|
|
import { AudioPlayerStatus } from './audioPlayer.types';
|
|
import { useAudioPlaybackStore } from './store/audioPlaybackStore';
|
|
|
|
/**
|
|
* Formatiert eine Zeitangabe in Sekunden als MM:SS
|
|
*/
|
|
export const formatDuration = (seconds: number): string => {
|
|
return formatDurationWithUnits(seconds);
|
|
};
|
|
|
|
/**
|
|
* Hook zur Verwaltung eines Audio-Players
|
|
*/
|
|
export const useAudioPlayer = () => {
|
|
const [player, setPlayer] = useState<AudioPlayer | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [loadError, setLoadError] = useState(false);
|
|
const [status, setStatus] = useState<AudioPlayerStatus>(AudioPlayerStatus.IDLE);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isBuffering, setIsBuffering] = useState(false);
|
|
const audioIdRef = useRef<string | null>(null);
|
|
const { registerAudio, unregisterAudio, pauseAllExcept } = useAudioPlaybackStore();
|
|
|
|
// Verwende die zentralen Timer-Hooks mit externen Zeitaktualisierungen
|
|
const positionTimer = useTimer(0, { useExternalTimeUpdates: true });
|
|
const durationTimer = useTimer(0, { useExternalTimeUpdates: true });
|
|
|
|
const isWebEnvironment = Platform.OS === 'web';
|
|
|
|
const loadSound = useCallback(
|
|
async (uri: string | undefined) => {
|
|
try {
|
|
setStatus(AudioPlayerStatus.LOADING);
|
|
|
|
if (player) {
|
|
// Clear any existing intervals
|
|
if ((player as any)._intervalId) {
|
|
clearInterval((player as any)._intervalId);
|
|
}
|
|
if ((player as any)._checkDurationId) {
|
|
clearInterval((player as any)._checkDurationId);
|
|
}
|
|
await player.pause();
|
|
player.release();
|
|
}
|
|
|
|
if (!uri) {
|
|
setLoadError(true);
|
|
setStatus(AudioPlayerStatus.ERROR);
|
|
setError('Keine URI angegeben');
|
|
return;
|
|
}
|
|
|
|
setLoadError(false);
|
|
await setAudioModeAsync({
|
|
shouldPlayInBackground: true,
|
|
playsInSilentMode: true,
|
|
interruptionMode: 'duckOthers',
|
|
allowsRecording: false,
|
|
});
|
|
|
|
const newPlayer = createAudioPlayer(uri);
|
|
|
|
// Wait a moment for the player to load
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
// Check if player loaded successfully
|
|
if (newPlayer.duration === 0 && !newPlayer.playing) {
|
|
// Try waiting a bit more
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
|
|
setPlayer(newPlayer);
|
|
if (newPlayer.duration !== undefined && newPlayer.duration > 0) {
|
|
durationTimer.setTime(newPlayer.duration);
|
|
}
|
|
|
|
// Wiederholte Überprüfung der Dauer, falls sie nicht sofort verfügbar ist
|
|
let attempts = 0;
|
|
const maxAttempts = 100;
|
|
const checkDuration = setInterval(() => {
|
|
if (newPlayer.duration && newPlayer.duration > 0 && newPlayer.duration !== Infinity) {
|
|
durationTimer.setTime(newPlayer.duration);
|
|
clearInterval(checkDuration);
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkDuration);
|
|
}
|
|
attempts += 1;
|
|
}, 100);
|
|
|
|
// Monitor playback state changes
|
|
const intervalId = setInterval(() => {
|
|
if (newPlayer) {
|
|
positionTimer.updateTime(newPlayer.currentTime);
|
|
setIsPlaying(newPlayer.playing);
|
|
setIsBuffering(false); // expo-audio doesn't expose buffering state
|
|
|
|
if (newPlayer.duration !== undefined && newPlayer.duration > 0) {
|
|
durationTimer.setTime(newPlayer.duration);
|
|
}
|
|
|
|
// Status aktualisieren
|
|
if (newPlayer.playing) {
|
|
setStatus(AudioPlayerStatus.PLAYING);
|
|
} else if (newPlayer.currentTime === 0 && !newPlayer.playing) {
|
|
setStatus(AudioPlayerStatus.STOPPED);
|
|
} else if (positionTimer.timer > 0) {
|
|
setStatus(AudioPlayerStatus.PAUSED);
|
|
}
|
|
}
|
|
}, 100);
|
|
|
|
// Store interval IDs for cleanup
|
|
(newPlayer as any)._intervalId = intervalId;
|
|
(newPlayer as any)._checkDurationId = checkDuration;
|
|
|
|
setStatus(AudioPlayerStatus.PAUSED);
|
|
} catch (error) {
|
|
console.error('Fehler beim Laden der Audio-Datei:', error);
|
|
setLoadError(true);
|
|
setPlayer(null);
|
|
setStatus(AudioPlayerStatus.ERROR);
|
|
setError(error instanceof Error ? error.message : 'Unbekannter Fehler');
|
|
}
|
|
},
|
|
[player]
|
|
);
|
|
|
|
const pause = useCallback(async () => {
|
|
try {
|
|
if (!player) return;
|
|
|
|
if (player.playing) {
|
|
await player.pause();
|
|
setStatus(AudioPlayerStatus.PAUSED);
|
|
|
|
if (audioIdRef.current) {
|
|
unregisterAudio(audioIdRef.current);
|
|
audioIdRef.current = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fehler beim Pausieren:', error);
|
|
}
|
|
}, [player, unregisterAudio]);
|
|
|
|
const playPause = useCallback(async () => {
|
|
try {
|
|
if (!player) return;
|
|
|
|
if (player.playing) {
|
|
// Pausieren
|
|
await pause();
|
|
|
|
// Benachrichtigung aktualisieren (nur für native Plattformen)
|
|
if (!isWebEnvironment) {
|
|
await NotificationService.showNotification(
|
|
'Audio-Wiedergabe pausiert',
|
|
'Tippe, um zur Wiedergabe zurückzukehren',
|
|
NotificationChannel.AUDIO_PLAYBACK,
|
|
true
|
|
);
|
|
}
|
|
} else {
|
|
// Wenn Audio zu Ende ist (Position am Ende), von vorne starten
|
|
if (player.currentTime >= player.duration && player.duration > 0) {
|
|
player.seekTo(0);
|
|
positionTimer.updateTime(0);
|
|
}
|
|
|
|
// Generate audio ID if not exists
|
|
if (!audioIdRef.current) {
|
|
audioIdRef.current = `audio-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
// Pause all other audio before playing this one
|
|
await pauseAllExcept(audioIdRef.current);
|
|
|
|
// Abspielen
|
|
await player.play();
|
|
setStatus(AudioPlayerStatus.PLAYING);
|
|
|
|
// Register in global store
|
|
registerAudio(audioIdRef.current, player, pause);
|
|
|
|
// Benachrichtigung anzeigen (nur für native Plattformen)
|
|
if (!isWebEnvironment) {
|
|
await NotificationService.showNotification(
|
|
'Audio-Wiedergabe läuft',
|
|
'Tippe, um zur Wiedergabe zurückzukehren',
|
|
NotificationChannel.AUDIO_PLAYBACK,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fehler beim Play/Pause:', error);
|
|
setLoadError(true);
|
|
setStatus(AudioPlayerStatus.ERROR);
|
|
setError(error instanceof Error ? error.message : 'Unbekannter Fehler beim Abspielen');
|
|
}
|
|
}, [player, isWebEnvironment, positionTimer, pause, registerAudio, pauseAllExcept]);
|
|
|
|
const stop = useCallback(async () => {
|
|
try {
|
|
if (!player) return;
|
|
|
|
await player.pause();
|
|
player.seekTo(0);
|
|
setStatus(AudioPlayerStatus.STOPPED);
|
|
positionTimer.updateTime(0);
|
|
|
|
// Unregister from global store
|
|
if (audioIdRef.current) {
|
|
unregisterAudio(audioIdRef.current);
|
|
audioIdRef.current = null;
|
|
}
|
|
|
|
// Benachrichtigung entfernen (nur für native Plattformen)
|
|
if (!isWebEnvironment) {
|
|
await NotificationService.stopForegroundService();
|
|
}
|
|
} catch (error) {
|
|
console.error('Fehler beim Stop:', error);
|
|
setLoadError(true);
|
|
setStatus(AudioPlayerStatus.ERROR);
|
|
setError(error instanceof Error ? error.message : 'Unbekannter Fehler beim Stoppen');
|
|
}
|
|
}, [player, isWebEnvironment, unregisterAudio]);
|
|
|
|
const seekAndPlay = useCallback(
|
|
async (positionMillis: number) => {
|
|
try {
|
|
if (!player) {
|
|
console.error('Kein Player geladen');
|
|
return;
|
|
}
|
|
|
|
const maxPosition = (player.duration || 0) * 1000;
|
|
const clampedPosition = Math.min(Math.max(0, positionMillis), maxPosition);
|
|
player.seekTo(clampedPosition / 1000);
|
|
|
|
// Generate audio ID if not exists
|
|
if (!audioIdRef.current) {
|
|
audioIdRef.current = `audio-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
// Pause all other audio before playing this one
|
|
await pauseAllExcept(audioIdRef.current);
|
|
|
|
await player.play();
|
|
setIsPlaying(true);
|
|
positionTimer.updateTime(positionMillis / 1000);
|
|
setStatus(AudioPlayerStatus.PLAYING);
|
|
|
|
// Register in global store
|
|
registerAudio(audioIdRef.current, player, pause);
|
|
|
|
// Benachrichtigung aktualisieren (nur für native Plattformen)
|
|
if (!isWebEnvironment) {
|
|
await NotificationService.showNotification(
|
|
'Audio-Wiedergabe läuft',
|
|
'Tippe, um zur Wiedergabe zurückzukehren',
|
|
NotificationChannel.AUDIO_PLAYBACK,
|
|
true
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Fehler beim Scrubben und Abspielen:', error);
|
|
setLoadError(true);
|
|
setStatus(AudioPlayerStatus.ERROR);
|
|
setError(error instanceof Error ? error.message : 'Unbekannter Fehler bei der Navigation');
|
|
}
|
|
},
|
|
[player, isWebEnvironment, pause, registerAudio, pauseAllExcept]
|
|
);
|
|
|
|
const seek = useCallback(
|
|
async (positionMillis: number) => {
|
|
try {
|
|
if (!player) return;
|
|
|
|
const maxPosition = (player.duration || 0) * 1000;
|
|
const clampedPosition = Math.min(Math.max(0, positionMillis), maxPosition);
|
|
player.seekTo(clampedPosition / 1000);
|
|
positionTimer.updateTime(clampedPosition / 1000);
|
|
} catch (error) {
|
|
console.error('Fehler beim Scrubben:', error);
|
|
setLoadError(true);
|
|
}
|
|
},
|
|
[player]
|
|
);
|
|
|
|
const unload = useCallback(async () => {
|
|
try {
|
|
if (!player) return;
|
|
|
|
// Clear update intervals if they exist
|
|
if ((player as any)._intervalId) {
|
|
clearInterval((player as any)._intervalId);
|
|
}
|
|
if ((player as any)._checkDurationId) {
|
|
clearInterval((player as any)._checkDurationId);
|
|
}
|
|
|
|
await player.pause();
|
|
player.release();
|
|
|
|
// Benachrichtigung entfernen (nur für native Plattformen)
|
|
if (!isWebEnvironment) {
|
|
await NotificationService.stopForegroundService();
|
|
}
|
|
|
|
setPlayer(null);
|
|
positionTimer.reset();
|
|
durationTimer.reset();
|
|
setIsPlaying(false);
|
|
setLoadError(false);
|
|
setStatus(AudioPlayerStatus.IDLE);
|
|
setError(null);
|
|
setIsBuffering(false);
|
|
|
|
// Unregister from global store
|
|
if (audioIdRef.current) {
|
|
unregisterAudio(audioIdRef.current);
|
|
audioIdRef.current = null;
|
|
}
|
|
} catch (error) {
|
|
console.error('Fehler beim Unload:', error);
|
|
|
|
// Trotzdem Status zurücksetzen, um dem Nutzer zu ermöglichen, neu zu laden
|
|
setPlayer(null);
|
|
setStatus(AudioPlayerStatus.IDLE);
|
|
|
|
// Ensure unregistration even on error
|
|
if (audioIdRef.current) {
|
|
unregisterAudio(audioIdRef.current);
|
|
audioIdRef.current = null;
|
|
}
|
|
}
|
|
}, [player, isWebEnvironment, unregisterAudio]);
|
|
|
|
// Ressourcen freigeben, wenn die Komponente unmounted wird
|
|
useEffect(() => {
|
|
return () => {
|
|
if (player) {
|
|
// Clear update intervals if they exist
|
|
if ((player as any)._intervalId) {
|
|
clearInterval((player as any)._intervalId);
|
|
}
|
|
if ((player as any)._checkDurationId) {
|
|
clearInterval((player as any)._checkDurationId);
|
|
}
|
|
// Check if pause method exists before calling it (Expo Audio API change)
|
|
if (typeof player.pause === 'function') {
|
|
try {
|
|
player.pause();
|
|
} catch (error) {
|
|
console.error('Error pausing player:', error);
|
|
}
|
|
}
|
|
player.release();
|
|
}
|
|
|
|
// Benachrichtigung entfernen (nur für native Plattformen)
|
|
if (!isWebEnvironment) {
|
|
NotificationService.stopForegroundService().catch(console.error);
|
|
}
|
|
|
|
// Unregister from global store
|
|
if (audioIdRef.current) {
|
|
unregisterAudio(audioIdRef.current);
|
|
audioIdRef.current = null;
|
|
}
|
|
};
|
|
}, [player, isWebEnvironment, unregisterAudio]);
|
|
|
|
return {
|
|
isPlaying,
|
|
duration: durationTimer.timer,
|
|
currentTime: positionTimer.timer,
|
|
status,
|
|
error,
|
|
isBuffering,
|
|
loadError,
|
|
loadSound,
|
|
playPause,
|
|
stop,
|
|
seekAndPlay,
|
|
seek,
|
|
unload,
|
|
formattedPosition: positionTimer.formattedTime,
|
|
formattedDuration: durationTimer.formattedTime,
|
|
percentComplete:
|
|
durationTimer.timer > 0 ? (positionTimer.timer / durationTimer.timer) * 100 : 0,
|
|
};
|
|
};
|
|
|
|
export default useAudioPlayer;
|
|
|
|
// Hilfsfunktion zur Formatierung der Zeit
|
|
export const formatTime = (milliseconds: number): string => {
|
|
return formatDurationFromMs(milliseconds);
|
|
};
|