import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; import 'helpers/raw_session_retransmit.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 = {}; final Set _ignoredIncomingSessions = {}; /// 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; 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(); bool isReceiveCanceled(String sessionId) => _ignoredIncomingSessions.contains(sessionId); 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) { if (_ignoredIncomingSessions.contains(packet.sessionId)) { debugPrint( '⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}', ); return false; } _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; } void cancelIncomingSession(String sessionId) { _ignoredIncomingSessions.add(sessionId); _sessions.remove(sessionId); if (_playingSessionId == sessionId) { unawaited(_player.stop()); _playingSessionId = null; } _persistVoiceData(); notifyListeners(); } void resumeIncomingSession(String sessionId) { if (_ignoredIncomingSessions.remove(sessionId)) { notifyListeners(); } } /// 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; } return serveCachedSessionFragments( providerLabel: 'VoiceProvider', sessionId: sessionId, requester: requester, fragments: cached.packets, maxDirectPayloadHops: maxDirectPayloadHops, indexOf: (packet) => packet.index, encodeBinary: (packet) => packet.encodeBinary(), sendRawPacket: sendRawPacketCallback, requestedIndices: requestedIndices, ); } // ── 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(); _ignoredIncomingSessions.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}); }