import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tts/flutter_tts.dart'; enum TtsStatus { idle, playing, paused } const double kTtsBaseSpeechRate = 0.9; double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate; String formatTtsSpeedLabel(double speechRate) { final multiplier = ttsDisplayMultiplier(speechRate); final rounded = multiplier.roundToDouble(); if ((multiplier - rounded).abs() < 0.05) { return '${rounded.toInt()}x'; } return '${multiplier.toStringAsFixed(1)}x'; } class _TtsSegment { const _TtsSegment({ required this.text, required this.paragraphIndex, required this.start, required this.end, }); final String text; final int paragraphIndex; final int start; final int end; Map toMap() => { 'text': text, 'paragraphIndex': paragraphIndex, 'start': start, 'end': end, }; } class TtsVoice { const TtsVoice({required this.name, required this.locale}); final String name; final String locale; String get displayName => '$name ($locale)'; } class TtsState { final TtsStatus status; final int paragraphIndex; // current spoken segment index final int totalParagraphs; // total spoken segments final int activeParagraphIndex; // paragraph index in chapter content final double speed; final String language; final String? voiceName; final List availableVietnameseVoices; final int progressStart; final int progressEnd; final String? contentKey; final int completedCount; final bool backgroundModeEnabled; final bool batteryOptimizationIgnored; final String? pendingAutoStartChapterId; const TtsState({ this.status = TtsStatus.idle, this.paragraphIndex = 0, this.totalParagraphs = 0, this.activeParagraphIndex = -1, this.speed = 0.9, this.language = 'vi-VN', this.voiceName, this.availableVietnameseVoices = const [], this.progressStart = -1, this.progressEnd = -1, this.contentKey, this.completedCount = 0, this.backgroundModeEnabled = true, this.batteryOptimizationIgnored = false, this.pendingAutoStartChapterId, }); TtsState copyWith({ TtsStatus? status, int? paragraphIndex, int? totalParagraphs, int? activeParagraphIndex, double? speed, String? language, String? voiceName, List? availableVietnameseVoices, int? progressStart, int? progressEnd, String? contentKey, bool clearContentKey = false, bool clearVoiceName = false, int? completedCount, bool? backgroundModeEnabled, bool? batteryOptimizationIgnored, String? pendingAutoStartChapterId, bool clearPendingAutoStartChapterId = false, }) => TtsState( status: status ?? this.status, paragraphIndex: paragraphIndex ?? this.paragraphIndex, totalParagraphs: totalParagraphs ?? this.totalParagraphs, activeParagraphIndex: activeParagraphIndex ?? this.activeParagraphIndex, speed: speed ?? this.speed, language: language ?? this.language, voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName), availableVietnameseVoices: availableVietnameseVoices ?? this.availableVietnameseVoices, progressStart: progressStart ?? this.progressStart, progressEnd: progressEnd ?? this.progressEnd, contentKey: clearContentKey ? null : (contentKey ?? this.contentKey), completedCount: completedCount ?? this.completedCount, backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, batteryOptimizationIgnored: batteryOptimizationIgnored ?? this.batteryOptimizationIgnored, pendingAutoStartChapterId: clearPendingAutoStartChapterId ? null : (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId), ); bool get isPlaying => status == TtsStatus.playing; } class TtsNotifier extends StateNotifier { TtsNotifier() : super(const TtsState()) { _initFuture = _init(); } static const MethodChannel _backgroundChannel = MethodChannel( 'reader_app/tts_background', ); static const MethodChannel _mediaChannel = MethodChannel( 'reader_app/tts_media', ); static const EventChannel _mediaEventsChannel = EventChannel( 'reader_app/tts_media_events', ); final FlutterTts _tts = FlutterTts(); List<_TtsSegment> _segments = []; bool _initialized = false; Future? _initFuture; StreamSubscription? _mediaEventsSub; int _playbackGeneration = 0; bool _isInterruptingPlayback = false; int _pendingFallbackIndex = -1; bool _didStartCurrentFallbackUtterance = false; bool _hasPromptedNotificationSettings = false; bool _androidFallbackReady = false; bool get _useNativeAndroidMediaService => Platform.isAndroid; Future _init() async { if (_useNativeAndroidMediaService) { await _initAndroidBridge(); _initialized = true; return; } await _tts.awaitSpeakCompletion(true); await _tts.setSharedInstance(true); if (Platform.isIOS) { await _tts.setIosAudioCategory( IosTextToSpeechAudioCategory.playback, [ IosTextToSpeechAudioCategoryOptions.allowBluetooth, IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, IosTextToSpeechAudioCategoryOptions.mixWithOthers, ], IosTextToSpeechAudioMode.defaultMode, ); } await _configureVietnameseVoiceWithFlutterTts(); await _tts.setSpeechRate(kTtsBaseSpeechRate); await _tts.setVolume(1.0); await _tts.setPitch(1.0); _tts.setStartHandler(() { _didStartCurrentFallbackUtterance = true; final index = _pendingFallbackIndex; if (index >= 0 && index < _segments.length) { final segment = _segments[index]; state = state.copyWith( status: TtsStatus.playing, paragraphIndex: index, activeParagraphIndex: segment.paragraphIndex, progressStart: segment.start, progressEnd: segment.end, ); } else { state = state.copyWith(status: TtsStatus.playing); } unawaited(_syncBackgroundMode()); }); _tts.setCompletionHandler(() { // Fallback playback progression is driven by _playFallbackFromGeneration. }); _tts.setErrorHandler((msg) { if (_isInterruptingPlayback) return; _pendingFallbackIndex = -1; _didStartCurrentFallbackUtterance = false; state = state.copyWith( status: TtsStatus.idle, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); unawaited(_syncBackgroundMode()); }); await _syncBackgroundMode(); _initialized = true; } Future _initAndroidBridge() async { _mediaEventsSub ??= _mediaEventsChannel.receiveBroadcastStream().listen( _handleAndroidMediaEvent, onError: (_) { state = state.copyWith( status: TtsStatus.idle, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); }, ); await _mediaChannel.invokeMethod('initialize', { 'backgroundModeEnabled': state.backgroundModeEnabled, }); final snapshot = await _mediaChannel.invokeMethod('getSnapshot'); _applyAndroidSnapshot(snapshot); await _ensureAndroidMediaNotificationsEnabled(); } Future _ensureAndroidMediaNotificationsEnabled() async { if (!_useNativeAndroidMediaService) return; if (_hasPromptedNotificationSettings) return; final enabled = await _mediaChannel.invokeMethod('areNotificationsEnabled') ?? true; if (enabled) return; _hasPromptedNotificationSettings = true; await _mediaChannel.invokeMethod('openNotificationSettings'); } Future _configureVietnameseVoiceWithFlutterTts() async { final dynamic voicesRaw = await _tts.getVoices; String? selectedName; String selectedLanguage = 'vi-VN'; final List vietnameseVoices = []; if (voicesRaw is List) { final vietnamese = voicesRaw.whereType().where((voice) { final locale = (voice['locale'] ?? voice['language'] ?? '') .toString() .toLowerCase(); return locale.startsWith('vi'); }).toList(); for (final voice in vietnamese) { final name = voice['name']?.toString(); final locale = (voice['locale'] ?? voice['language'])?.toString(); if (name == null || name.isEmpty || locale == null || locale.isEmpty) { continue; } vietnameseVoices.add(TtsVoice(name: name, locale: locale)); } if (vietnamese.isNotEmpty) { final preferred = vietnamese.firstWhere( (voice) => (voice['name'] ?.toString() .toLowerCase() .contains('female') ?? false) || (voice['name'] ?.toString() .toLowerCase() .contains('natural') ?? false), orElse: () => vietnamese.first, ); selectedName = preferred['name']?.toString(); selectedLanguage = (preferred['locale'] ?? preferred['language'] ?? 'vi-VN').toString(); } } await _tts.setLanguage(selectedLanguage); if (selectedName != null) { await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage}); } state = state.copyWith( language: selectedLanguage, voiceName: selectedName, availableVietnameseVoices: vietnameseVoices, ); } Future _ensureAndroidFallbackReady() async { if (_androidFallbackReady) return; await _tts.awaitSpeakCompletion(true); await _tts.setSharedInstance(true); await _configureVietnameseVoiceWithFlutterTts(); await _tts.setSpeechRate(state.speed); await _tts.setVolume(1.0); await _tts.setPitch(1.0); _tts.setStartHandler(() { _didStartCurrentFallbackUtterance = true; final index = _pendingFallbackIndex; if (index >= 0 && index < _segments.length) { final segment = _segments[index]; state = state.copyWith( status: TtsStatus.playing, paragraphIndex: index, activeParagraphIndex: segment.paragraphIndex, progressStart: segment.start, progressEnd: segment.end, ); } else { state = state.copyWith(status: TtsStatus.playing); } }); _tts.setCompletionHandler(() { // Fallback playback progression is driven by _playFallbackFromGeneration. }); _tts.setErrorHandler((_) { if (_isInterruptingPlayback) return; _pendingFallbackIndex = -1; _didStartCurrentFallbackUtterance = false; state = state.copyWith( status: TtsStatus.idle, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); }); _androidFallbackReady = true; } Future _startFallbackReading({ required int validIndex, required _TtsSegment selectedSegment, required String? contentKey, }) async { await _ensureAndroidFallbackReady(); final sessionId = await _interruptFallbackPlayback(); state = state.copyWith( status: TtsStatus.playing, paragraphIndex: validIndex, totalParagraphs: _segments.length, activeParagraphIndex: selectedSegment.paragraphIndex, progressStart: selectedSegment.start, progressEnd: selectedSegment.end, contentKey: contentKey, ); unawaited(_playFallbackFromGeneration(validIndex, sessionId)); } void _handleAndroidMediaEvent(dynamic event) { _applyAndroidSnapshot(event); } void _applyAndroidSnapshot(dynamic snapshot) { if (snapshot is! Map) return; final data = Map.from( snapshot.map((key, value) => MapEntry(key.toString(), value)), ); final statusRaw = data['status']?.toString() ?? 'idle'; final status = switch (statusRaw) { 'playing' => TtsStatus.playing, 'paused' => TtsStatus.paused, _ => TtsStatus.idle, }; final voicesRaw = data['availableVietnameseVoices']; final voices = []; if (voicesRaw is List) { for (final item in voicesRaw) { if (item is! Map) continue; final map = Map.from( item.map((key, value) => MapEntry(key.toString(), value)), ); final name = map['name']?.toString(); final locale = map['locale']?.toString(); if (name == null || name.isEmpty || locale == null || locale.isEmpty) { continue; } voices.add(TtsVoice(name: name, locale: locale)); } } state = state.copyWith( status: status, paragraphIndex: (data['paragraphIndex'] as num?)?.toInt() ?? 0, totalParagraphs: (data['totalParagraphs'] as num?)?.toInt() ?? 0, activeParagraphIndex: (data['activeParagraphIndex'] as num?)?.toInt() ?? -1, progressStart: (data['progressStart'] as num?)?.toInt() ?? -1, progressEnd: (data['progressEnd'] as num?)?.toInt() ?? -1, contentKey: data['contentKey']?.toString(), completedCount: (data['completedCount'] as num?)?.toInt() ?? state.completedCount, language: data['language']?.toString() ?? state.language, voiceName: data['voiceName']?.toString(), availableVietnameseVoices: voices, backgroundModeEnabled: data['backgroundModeEnabled'] as bool? ?? state.backgroundModeEnabled, ); } String _sanitizeForTts(String raw) { if (raw.isEmpty) return raw; // Keep natural sentence flow while removing symbols that are usually read out noisily. final cleaned = raw .replaceAll(RegExp(r'["“”]'), ' ') .replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ') .replaceAll(RegExp(r'\s+'), ' ') .trim(); return cleaned; } List<_TtsSegment> _buildSegments( String content, { String? title, bool includeTitle = true, }) { final segments = <_TtsSegment>[]; final titleText = title?.trim(); if (includeTitle && titleText != null && titleText.isNotEmpty) { final sanitizedTitle = _sanitizeForTts(titleText); if (sanitizedTitle.isNotEmpty) { segments.add( _TtsSegment( text: sanitizedTitle, paragraphIndex: -1, start: -1, end: -1, ), ); } } final paragraphs = content .split(RegExp(r'\n+')) .map((p) => p.trim()) .where((p) => p.isNotEmpty) .toList(); for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) { final paragraph = paragraphs[pIndex]; final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph); var cursor = 0; for (final match in sentenceMatches) { final sentence = match.group(0)?.trim() ?? ''; if (sentence.isEmpty) continue; final sanitizedSentence = _sanitizeForTts(sentence); if (sanitizedSentence.isEmpty) continue; var start = paragraph.indexOf(sentence, cursor); if (start < 0) { start = cursor.clamp(0, paragraph.length); } final end = (start + sentence.length).clamp(0, paragraph.length); cursor = end; segments.add( _TtsSegment( text: sanitizedSentence, paragraphIndex: pIndex, start: start, end: end, ), ); } } return segments; } int _resolveStartIndex( int paragraphIndex, { int? startParagraphIndex, int? startCharOffset, }) { var validIndex = paragraphIndex.clamp(0, _segments.length - 1); if (startParagraphIndex != null) { final matchIndex = _segments.indexWhere( (segment) => segment.paragraphIndex == startParagraphIndex && (startCharOffset == null || segment.start >= startCharOffset), ); if (matchIndex >= 0) { validIndex = matchIndex; } else { final fallbackIndex = _segments.indexWhere( (segment) => segment.paragraphIndex >= startParagraphIndex, ); if (fallbackIndex >= 0) { validIndex = fallbackIndex; } } } return validIndex; } Future setVoiceByName(String voiceName) async { if (_useNativeAndroidMediaService) { final selected = state.availableVietnameseVoices.where( (voice) => voice.name == voiceName, ); if (selected.isEmpty) return; final voice = selected.first; await _mediaChannel.invokeMethod('setVoiceByName', { 'voiceName': voice.name, 'language': voice.locale, }); state = state.copyWith(language: voice.locale, voiceName: voice.name); return; } final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName); if (selected.isEmpty) return; final voice = selected.first; if (!voice.locale.toLowerCase().startsWith('vi')) return; await _tts.setLanguage(voice.locale); await _tts.setVoice({'name': voice.name, 'locale': voice.locale}); state = state.copyWith(language: voice.locale, voiceName: voice.name); } Future setBackgroundModeEnabled(bool enabled) async { state = state.copyWith(backgroundModeEnabled: enabled); if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('setBackgroundModeEnabled', { 'enabled': enabled, }); if (enabled) { await ensureBatteryOptimizationIgnored(); } return; } await _syncBackgroundMode(); if (enabled) { await ensureBatteryOptimizationIgnored(); } } void scheduleAutoStartForChapter(String chapterId) { state = state.copyWith(pendingAutoStartChapterId: chapterId); } void clearPendingAutoStartChapter() { state = state.copyWith(clearPendingAutoStartChapterId: true); } Future ensureBatteryOptimizationIgnored() async { if (!Platform.isAndroid) return; try { final isIgnored = await _backgroundChannel.invokeMethod( 'isIgnoringBatteryOptimizations', ) ?? false; state = state.copyWith(batteryOptimizationIgnored: isIgnored); if (isIgnored) return; await _backgroundChannel.invokeMethod('requestIgnoreBatteryOptimizations'); final afterRequest = await _backgroundChannel.invokeMethod( 'isIgnoringBatteryOptimizations', ) ?? false; state = state.copyWith(batteryOptimizationIgnored: afterRequest); } catch (_) { // Ignore bridge errors and keep TTS playback functional. } } Future _syncBackgroundMode() async { if (_useNativeAndroidMediaService || !Platform.isAndroid) return; final shouldKeepAlive = state.backgroundModeEnabled && state.status == TtsStatus.playing; try { await _backgroundChannel.invokeMethod('setWakeLock', { 'enabled': shouldKeepAlive, }); } catch (_) { // Keep playback functional even if native wake lock bridge is unavailable. } } Future startReading( String content, { int paragraphIndex = 0, int? startParagraphIndex, int? startCharOffset, String? contentKey, String? title, bool includeTitle = true, }) async { if (!_initialized) { await (_initFuture ?? _init()); } _segments = _buildSegments( content, title: title, includeTitle: includeTitle, ); if (_segments.isEmpty) { state = state.copyWith( status: TtsStatus.idle, paragraphIndex: 0, totalParagraphs: 0, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, contentKey: contentKey, ); if (!_useNativeAndroidMediaService) { await _syncBackgroundMode(); } return; } final validIndex = _resolveStartIndex( paragraphIndex, startParagraphIndex: startParagraphIndex, startCharOffset: startCharOffset, ); final selectedSegment = _segments[validIndex]; if (_useNativeAndroidMediaService) { await _ensureAndroidMediaNotificationsEnabled(); state = state.copyWith( status: TtsStatus.playing, paragraphIndex: validIndex, totalParagraphs: _segments.length, activeParagraphIndex: selectedSegment.paragraphIndex, progressStart: selectedSegment.start, progressEnd: selectedSegment.end, contentKey: contentKey, ); try { await _mediaChannel.invokeMethod('startReading', { 'contentKey': contentKey, 'title': title, 'startIndex': validIndex, 'speed': state.speed, 'language': state.language, 'voiceName': state.voiceName, 'backgroundModeEnabled': state.backgroundModeEnabled, 'segments': _segments.map((segment) => segment.toMap()).toList(), }); } on PlatformException { await _startFallbackReading( validIndex: validIndex, selectedSegment: selectedSegment, contentKey: contentKey, ); } return; } await _startFallbackReading( validIndex: validIndex, selectedSegment: selectedSegment, contentKey: contentKey, ); } Future _interruptFallbackPlayback() async { _playbackGeneration++; _pendingFallbackIndex = -1; _didStartCurrentFallbackUtterance = false; _isInterruptingPlayback = true; try { await _tts.stop(); if (Platform.isAndroid) { await Future.delayed(const Duration(milliseconds: 120)); } } finally { _isInterruptingPlayback = false; } return _playbackGeneration; } Future _playFallbackFromGeneration(int startIndex, int generation) async { if (startIndex < 0 || startIndex >= _segments.length) { state = state.copyWith( status: TtsStatus.idle, paragraphIndex: 0, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); await _syncBackgroundMode(); return; } for (var index = startIndex; index < _segments.length; index++) { if (generation != _playbackGeneration) return; if (state.status != TtsStatus.playing) return; _pendingFallbackIndex = index; _didStartCurrentFallbackUtterance = false; state = state.copyWith( status: TtsStatus.playing, paragraphIndex: index, totalParagraphs: _segments.length, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); await _syncBackgroundMode(); await _tts.setSpeechRate(state.speed); final result = await _tts.speak(_segments[index].text); if (generation != _playbackGeneration) return; if (state.status != TtsStatus.playing) return; if (result is int && result != 1) { state = state.copyWith( status: TtsStatus.idle, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); await _syncBackgroundMode(); return; } if (!_didStartCurrentFallbackUtterance) { state = state.copyWith( status: TtsStatus.idle, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); await _syncBackgroundMode(); return; } } if (generation != _playbackGeneration) return; _pendingFallbackIndex = -1; _didStartCurrentFallbackUtterance = false; state = state.copyWith( status: TtsStatus.idle, paragraphIndex: 0, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, completedCount: state.completedCount + 1, ); await _syncBackgroundMode(); } Future pause() async { if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('pause'); return; } if (state.status != TtsStatus.playing) return; _playbackGeneration++; await _tts.pause(); state = state.copyWith(status: TtsStatus.paused); await _syncBackgroundMode(); } Future _restartFallbackFromIndex(int index) async { if (_segments.isEmpty) return; final sessionId = await _interruptFallbackPlayback(); final validIndex = index.clamp(0, _segments.length - 1); state = state.copyWith( status: TtsStatus.playing, paragraphIndex: validIndex, totalParagraphs: _segments.length, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, ); await _syncBackgroundMode(); unawaited(_playFallbackFromGeneration(validIndex, sessionId)); } Future resume() async { if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('resume'); return; } if (state.status != TtsStatus.paused) return; await _restartFallbackFromIndex(state.paragraphIndex); } Future stop() async { if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('stop'); state = state.copyWith( status: TtsStatus.idle, paragraphIndex: 0, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, clearContentKey: true, ); return; } await _interruptFallbackPlayback(); state = state.copyWith( status: TtsStatus.idle, paragraphIndex: 0, activeParagraphIndex: -1, progressStart: -1, progressEnd: -1, clearContentKey: true, ); await _syncBackgroundMode(); } Future skipForward() async { if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('skipForward'); return; } if (_segments.isEmpty || state.totalParagraphs <= 0) return; final next = state.paragraphIndex + 1; if (next >= _segments.length) { await stop(); return; } await _restartFallbackFromIndex(next); } Future skipBack() async { if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('skipBack'); return; } if (_segments.isEmpty || state.totalParagraphs <= 0) return; final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1); await _restartFallbackFromIndex(prev); } Future setSpeed(double speed) async { state = state.copyWith(speed: speed); if (_useNativeAndroidMediaService) { await _mediaChannel.invokeMethod('setSpeed', {'speed': speed}); return; } await _tts.setSpeechRate(speed); } @override void dispose() { _mediaEventsSub?.cancel(); if (_useNativeAndroidMediaService) { unawaited(_mediaChannel.invokeMethod('dispose')); } else { unawaited(_tts.stop()); } super.dispose(); } } final ttsProvider = StateNotifierProvider((ref) { return TtsNotifier(); });