import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; import '../utils/voice_message_parser.dart'; import '../services/voice_codec_service.dart'; import '../services/voice_player_service.dart'; /// Reassembly state for one voice session. class VoiceSession { final String sessionId; final VoicePacketMode mode; final int total; final List packets; // indexed by packet.index DateTime? firstPacketAt; DateTime? lastPacketAt; VoiceSession({ required this.sessionId, required this.mode, required this.total, }) : packets = List.filled(total, null); int get receivedCount => packets.where((p) => p != null).length; bool get isComplete => receivedCount == total; Duration? estimateRemaining() { if (isComplete) return Duration.zero; if (firstPacketAt == null || lastPacketAt == null) return null; if (receivedCount < 2) return null; final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds; if (elapsedMs <= 0) return null; final avgMsPerPacket = elapsedMs / (receivedCount - 1); final remaining = total - receivedCount; if (remaining <= 0) return Duration.zero; return Duration(milliseconds: (avgMsPerPacket * remaining).round()); } /// Total estimated audio duration in seconds (sum of all received packets). double get estimatedDurationSeconds { var ms = 0; for (final p in packets) { if (p != null) ms += p.durationMs; } return ms / 1000.0; } } /// Manages incoming voice packet sessions and coordinates playback. class VoiceProvider with ChangeNotifier { static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1'; static const int maxDirectPayloadHops = 3; final VoiceCodecService _codec; final VoicePlayerService _player; late final StreamSubscription _playerEventsSub; /// Active sessions keyed by sessionId. final Map _sessions = {}; /// Currently playing session ID, or null. String? _playingSessionId; /// Hook for sending a raw voice payload to a destination contact path. Future Function({ required Uint8List contactPath, required int contactPathLen, required Uint8List payload, })? sendRawPacketCallback; Future Function({ required String sessionId, required int index, Duration timeout, })? waitForFragmentAckCallback; final Map _outgoingSessions = {}; VoiceProvider({ required VoiceCodecService codec, required VoicePlayerService player, }) : _codec = codec, _player = player { _playerEventsSub = _player.events.listen((_) { if (_playingSessionId != null && !_player.isPlaying && _player.duration.inMilliseconds > 0 && _player.position >= _player.duration) { _playingSessionId = null; } notifyListeners(); }); _restorePersistedVoiceData(); } // ── Session accessors ──────────────────────────────────────────────────── VoiceSession? session(String sessionId) => _sessions[sessionId]; bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false; bool isPlaying(String sessionId) => _playingSessionId == sessionId; Duration get playbackPosition => _player.position; Duration get playbackDuration => _player.duration; double playbackProgress(String sessionId) { if (_playingSessionId != sessionId) return 0.0; final totalMs = _player.duration.inMilliseconds; if (totalMs <= 0) return 0.0; final posMs = _player.position.inMilliseconds.clamp(0, totalMs); return posMs / totalMs; } bool hasOutgoingSession(String sessionId) => _outgoingSessions.containsKey(sessionId); Duration? estimateRemainingTransferTime(String sessionId) => _sessions[sessionId]?.estimateRemaining(); List missingPacketIndices(String sessionId) { final session = _sessions[sessionId]; if (session == null) return const []; final missing = []; for (var i = 0; i < session.total; i++) { if (session.packets[i] == null) missing.add(i); } return missing; } // ── Packet reception ───────────────────────────────────────────────────── /// Add an incoming [packet] to its session. Creates the session on first packet. /// Returns true if the session just became complete. bool addPacket(VoicePacket packet) { _sessions.putIfAbsent( packet.sessionId, () => VoiceSession( sessionId: packet.sessionId, mode: packet.mode, total: packet.total, ), ); final session = _sessions[packet.sessionId]!; if (packet.index < session.total) { final wasMissing = session.packets[packet.index] == null; session.packets[packet.index] = packet; if (wasMissing) { final now = DateTime.now(); session.firstPacketAt ??= now; session.lastPacketAt = now; } } final justComplete = session.isComplete; _persistVoiceData(); notifyListeners(); return justComplete; } /// Cache encoded packets for deferred voice serving. void cacheOutgoingSession(String sessionId, List packets) { if (packets.isEmpty) return; _outgoingSessions[sessionId] = _OutgoingVoiceSession( sessionId: sessionId, packets: List.from(packets), ); _persistVoiceData(); } /// Stream a cached voice session to a requester over raw direct packets. Future serveSessionTo({ required String sessionId, required Contact requester, Set? requestedIndices, }) async { final cached = _outgoingSessions[sessionId]; if (cached == null) { debugPrint( '⚠️ [VoiceProvider] No cached outgoing session for $sessionId', ); return false; } if (sendRawPacketCallback == null) { debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set'); return false; } if (requester.outPathLen < 0) { debugPrint( '⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path', ); return false; } if (requester.outPathLen > maxDirectPayloadHops) { debugPrint( '⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', ); return false; } for (final packet in cached.packets) { if (requestedIndices != null && !requestedIndices.contains(packet.index)) { continue; } try { final ackFuture = waitForFragmentAckCallback?.call( sessionId: sessionId, index: packet.index, timeout: const Duration(seconds: 8), ); await sendRawPacketCallback!( contactPath: requester.outPath, contactPathLen: requester.outPathLen, payload: packet.encodeBinary(), ); if (ackFuture != null) { final acked = await ackFuture; if (!acked) { debugPrint( '⚠️ [VoiceProvider] ACK timeout for $sessionId#${packet.index}', ); return false; } } } catch (e, st) { debugPrint( '❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st', ); return false; } } return true; } // ── Playback ───────────────────────────────────────────────────────────── /// Decode and play the voice session with [sessionId]. /// Plays whatever packets are available (handles partial reception gracefully). Future play(String sessionId) async { final session = _sessions[sessionId]; if (session == null) { debugPrint( '❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}', ); return; } debugPrint( '🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}', ); try { final pcm = await _codec.decodePackets(session.packets, session.mode); debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples'); _playingSessionId = sessionId; notifyListeners(); await _player.play(pcm); } catch (e, st) { debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); if (_playingSessionId == sessionId) { _playingSessionId = null; notifyListeners(); } } } Future stop() async { await _player.stop(); _playingSessionId = null; notifyListeners(); } Future clearStoredVoiceData() async { _sessions.clear(); _outgoingSessions.clear(); _playingSessionId = null; notifyListeners(); try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_voiceSessionsStorageKey); } catch (e) { debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e'); } } Future _persistVoiceData() async { try { final prefs = await SharedPreferences.getInstance(); final payload = { 'incoming': _sessions.values .map( (session) => { 'sessionId': session.sessionId, 'modeId': session.mode.id, 'total': session.total, 'packets': session.packets.map((p) => p?.encodeText()).toList(), }, ) .toList(), 'outgoing': _outgoingSessions.values .map( (session) => { 'sessionId': session.sessionId, 'packets': session.packets.map((p) => p.encodeText()).toList(), }, ) .toList(), }; await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload)); } catch (e) { debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e'); } } Future _restorePersistedVoiceData() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_voiceSessionsStorageKey); if (raw == null || raw.isEmpty) return; final parsed = jsonDecode(raw) as Map; final incoming = parsed['incoming'] as List? ?? const []; for (final item in incoming) { final map = item as Map; final sessionId = map['sessionId'] as String?; final modeId = map['modeId'] as int?; final total = map['total'] as int?; if (sessionId == null || modeId == null || total == null || total <= 0) { continue; } final mode = VoicePacketMode.fromId(modeId); final session = VoiceSession( sessionId: sessionId, mode: mode, total: total, ); final packets = map['packets'] as List? ?? const []; for (var i = 0; i < packets.length && i < session.total; i++) { final encoded = packets[i] as String?; if (encoded == null || encoded.isEmpty) continue; final packet = VoicePacket.tryParseText(encoded); if (packet != null && packet.index < session.total) { session.packets[packet.index] = packet; } } _sessions[sessionId] = session; } final outgoing = parsed['outgoing'] as List? ?? const []; for (final item in outgoing) { final map = item as Map; final sessionId = map['sessionId'] as String?; if (sessionId == null || sessionId.isEmpty) continue; final packetsRaw = map['packets'] as List? ?? const []; final packets = []; for (final encoded in packetsRaw) { final packet = VoicePacket.tryParseText((encoded ?? '') as String); if (packet != null) packets.add(packet); } if (packets.isNotEmpty) { _outgoingSessions[sessionId] = _OutgoingVoiceSession( sessionId: sessionId, packets: packets, ); } } notifyListeners(); debugPrint( '🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions', ); } catch (e) { debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e'); } } @override void dispose() { _playerEventsSub.cancel(); _player.dispose(); super.dispose(); } } class _OutgoingVoiceSession { final String sessionId; final List packets; const _OutgoingVoiceSession({required this.sessionId, required this.packets}); }