/** * VoicePlayer — play/pause voice message через expo-audio. * * Раздел на две подкомпоненты: * - RealVoicePlayer: useAudioPlayer с настоящим URI * - StubVoicePlayer: отрисовка waveform без player'а (seed-URI) * * Разделение важно: useAudioPlayer не должен получать null/stub-строки — * при падении внутри expo-audio это крашит render всего bubble'а и * (в FlatList) визуально "пропадает" интерфейс чата. * * UI: * [▶/⏸] ▮▮▮▮▮▮▮▮▮▯▯▯▯▯▯▯▯ 0:03 / 0:17 */ import React, { useMemo } from 'react'; import { View, Text, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio'; export interface VoicePlayerProps { uri: string; duration?: number; own?: boolean; } const BAR_COUNT = 22; function formatClock(sec: number): string { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${m}:${String(s).padStart(2, '0')}`; } function isStubUri(u: string): boolean { return u.startsWith('voice-stub://') || u.startsWith('voice-demo://'); } function useBars(uri: string) { return useMemo(() => { const seed = uri.length; return Array.from({ length: BAR_COUNT }, (_, i) => { const h = ((seed * (i + 1) * 7919) % 11) + 4; return h; }); }, [uri]); } // ─── Top-level router ────────────────────────────────────────────── export function VoicePlayer(props: VoicePlayerProps) { // Stub-URI (seed) не передаётся в useAudioPlayer — hook может крашить // на невалидных source'ах. Рендерим статический waveform. if (isStubUri(props.uri)) return ; return ; } // ─── Stub (seed / preview) ───────────────────────────────────────── function StubVoicePlayer({ uri, duration, own }: VoicePlayerProps) { const bars = useBars(uri); const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0'; const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a'; const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b'; return ( {bars.map((h, i) => ( ))} {formatClock(duration ?? 0)} ); } // ─── Real expo-audio player ──────────────────────────────────────── function RealVoicePlayer({ uri, duration, own }: VoicePlayerProps) { const player = useAudioPlayer({ uri }); const status = useAudioPlayerStatus(player); const bars = useBars(uri); const accent = own ? 'rgba(255,255,255,0.92)' : '#1d9bf0'; const subtle = own ? 'rgba(255,255,255,0.35)' : '#3a3a3a'; const textColor = own ? 'rgba(255,255,255,0.85)' : '#8b8b8b'; const playing = !!status.playing; const loading = !!status.isBuffering && !status.isLoaded; const curSec = status.currentTime ?? 0; const totalSec = (status.duration && status.duration > 0) ? status.duration : (duration ?? 0); const playedRatio = totalSec > 0 ? Math.min(1, curSec / totalSec) : 0; const playedBars = Math.round(playedRatio * BAR_COUNT); const toggle = () => { try { if (status.playing) { player.pause(); } else { if (status.duration && curSec >= status.duration - 0.05) { player.seekTo(0); } player.play(); } } catch { /* dbl-tap during load */ } }; return ( {bars.map((h, i) => ( ))} {playing || curSec > 0 ? `${formatClock(Math.floor(curSec))} / ${formatClock(Math.floor(totalSec))}` : formatClock(Math.floor(totalSec))} ); }