mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 16:39:39 +02:00
Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
No EOL
5.4 KiB
TypeScript
179 lines
No EOL
5.4 KiB
TypeScript
import { Platform } from 'react-native';
|
|
import { createAudioPlayer, setAudioModeAsync } from 'expo-audio';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
|
|
export interface DurationExtractionResult {
|
|
durationMillis: number;
|
|
durationSeconds: number;
|
|
formatted: string;
|
|
}
|
|
|
|
/**
|
|
* Extracts duration from a media file (audio or video)
|
|
* @param file - The file from DocumentPicker
|
|
* @returns Duration in milliseconds
|
|
* @throws Error if duration cannot be extracted
|
|
*/
|
|
export const extractMediaDuration = async (
|
|
file: DocumentPicker.DocumentPickerAsset
|
|
): Promise<number> => {
|
|
if (Platform.OS === 'web') {
|
|
return extractDurationWeb(file);
|
|
}
|
|
return extractDurationNative(file.uri, file.mimeType);
|
|
};
|
|
|
|
/**
|
|
* Extracts duration with formatted output
|
|
* @param file - The file from DocumentPicker
|
|
* @returns Duration with milliseconds, seconds, and formatted string
|
|
* @throws Error if duration cannot be extracted
|
|
*/
|
|
export const extractMediaDurationWithFormat = async (
|
|
file: DocumentPicker.DocumentPickerAsset
|
|
): Promise<DurationExtractionResult> => {
|
|
const durationMillis = await extractMediaDuration(file);
|
|
const durationSeconds = Math.floor(durationMillis / 1000);
|
|
const formatted = formatDuration(durationMillis);
|
|
|
|
return {
|
|
durationMillis,
|
|
durationSeconds,
|
|
formatted
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Web implementation using HTML5 Audio/Video elements
|
|
*/
|
|
const extractDurationWeb = (file: any): Promise<number> => {
|
|
return new Promise((resolve, reject) => {
|
|
const isVideo = file.type?.startsWith('video/') ||
|
|
file.mimeType?.startsWith('video/');
|
|
const element = document.createElement(isVideo ? 'video' : 'audio');
|
|
|
|
element.preload = 'metadata';
|
|
|
|
const cleanup = () => {
|
|
if (element.src && element.src.startsWith('blob:')) {
|
|
URL.revokeObjectURL(element.src);
|
|
}
|
|
element.src = '';
|
|
element.remove();
|
|
};
|
|
|
|
element.onloadedmetadata = () => {
|
|
if (element.duration && !isNaN(element.duration) && element.duration > 0) {
|
|
const durationMillis = Math.floor(element.duration * 1000);
|
|
cleanup();
|
|
resolve(durationMillis);
|
|
} else {
|
|
cleanup();
|
|
reject(new Error('Invalid or zero duration extracted from media file'));
|
|
}
|
|
};
|
|
|
|
element.onerror = (error) => {
|
|
cleanup();
|
|
reject(new Error(`Failed to load media file: ${error}`));
|
|
};
|
|
|
|
// Create blob URL for the file
|
|
try {
|
|
const blob = file.file || file;
|
|
element.src = URL.createObjectURL(blob);
|
|
} catch (error) {
|
|
cleanup();
|
|
reject(new Error(`Failed to create blob URL: ${error}`));
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Native implementation using expo-audio
|
|
*/
|
|
const extractDurationNative = async (
|
|
uri: string,
|
|
mimeType?: string | null
|
|
): Promise<number> => {
|
|
let player = null;
|
|
|
|
try {
|
|
// Configure audio mode for metadata extraction only
|
|
await setAudioModeAsync({
|
|
allowsRecording: false,
|
|
playsInSilentMode: false,
|
|
shouldPlayInBackground: false,
|
|
interruptionMode: 'mixWithOthers',
|
|
});
|
|
|
|
// Create audio player
|
|
player = createAudioPlayer(uri);
|
|
|
|
// Wait a bit for the player to load metadata
|
|
// expo-audio loads metadata asynchronously
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Get duration (in seconds from expo-audio)
|
|
const durationSeconds = player.duration;
|
|
|
|
if (durationSeconds && durationSeconds > 0) {
|
|
return Math.floor(durationSeconds * 1000); // Convert to milliseconds
|
|
}
|
|
|
|
throw new Error('Could not extract duration from media file');
|
|
} catch (error) {
|
|
console.error('Duration extraction failed:', error);
|
|
throw new Error(`Failed to extract duration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
} finally {
|
|
// Critical: Always clean up the player
|
|
if (player) {
|
|
try {
|
|
player.release();
|
|
} catch (cleanupError) {
|
|
console.warn('Failed to release audio player:', cleanupError);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Formats duration from milliseconds to human-readable string
|
|
* @param milliseconds - Duration in milliseconds
|
|
* @returns Formatted string like "2:34" or "1:02:34"
|
|
*/
|
|
export const formatDuration = (milliseconds: number): string => {
|
|
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
/**
|
|
* Validates if a file is a supported media type
|
|
* @param file - The file from DocumentPicker
|
|
* @returns true if the file is a supported audio or video format
|
|
*/
|
|
export const isSupportedMediaFile = (file: DocumentPicker.DocumentPickerAsset): boolean => {
|
|
const mimeType = file.mimeType?.toLowerCase() || '';
|
|
const name = file.name?.toLowerCase() || '';
|
|
|
|
const supportedMimeTypes = [
|
|
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/m4a',
|
|
'audio/wav', 'audio/x-wav', 'audio/aac', 'audio/ogg',
|
|
'video/mp4', 'video/quicktime', 'video/x-m4v', 'video/mpeg'
|
|
];
|
|
|
|
const supportedExtensions = [
|
|
'.mp3', '.m4a', '.wav', '.aac', '.ogg',
|
|
'.mp4', '.mov', '.m4v', '.mpeg', '.mpg'
|
|
];
|
|
|
|
return supportedMimeTypes.includes(mimeType) ||
|
|
supportedExtensions.some(ext => name.endsWith(ext));
|
|
}; |