import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/contracts/cancellation.dart'; import '../../../../core/contracts/danmaku.dart'; import '../../../../core/domain/ports/danmaku_gateway.dart'; import '../../../../core/media/danmaku_comment_pipeline.dart'; import '../../../../providers/danmaku_settings_provider.dart'; import '../../../../providers/di_providers.dart'; import '../../../../shared/utils/app_logger.dart'; import 'danmaku_matcher.dart'; const _tag = 'DanmakuSession'; class DanmakuSessionNotifier extends AsyncNotifier { late final DanmakuGateway _gateway; late final DanmakuMatcher _matcher; CancellationToken? _token; String? _currentItemId; List _sources = const []; int _chConvert = 0; @override Future build() async { _gateway = ref.read(danmakuGatewayProvider); _matcher = DanmakuMatcher(gateway: _gateway); ref.onDispose(() => _token?.cancel('notifier disposed')); return const DanmakuSessionIdle(); } Future loadFor(DanmakuMatchContext context) async { final token = _swapToken('loadFor:${context.itemId}'); _currentItemId = context.itemId; final settings = _readSettings(); final sources = settings.enabledSources; if (!settings.enabled || sources.isEmpty) { AppLogger.debug( _tag, 'loadFor(${context.itemId}) -> Idle: ' 'enabled=${settings.enabled} sources=${sources.length}', ); _sources = const []; state = const AsyncData(DanmakuSessionIdle()); return; } _sources = sources; _chConvert = _chConvertFromMode(settings.chineseConvert); state = const AsyncData(DanmakuSessionMatching()); try { final result = await _matcher.resolve( sources: _sources, context: context, cancelToken: token, ); if (_isStale(token, context.itemId)) return; if (result.matches.isEmpty || result.pickedIndex < 0) { AppLogger.info( _tag, 'loadFor(${context.itemId}) -> Empty: no matches found', ); state = const AsyncData(DanmakuSessionEmpty()); return; } state = AsyncData( DanmakuSessionMatched( matches: result.matches, selectedIndex: result.pickedIndex, commentStatus: DanmakuCommentStatus.loading, ), ); AppLogger.debug( _tag, 'auto-select index=${result.pickedIndex} ' '(target ep=${context.episode})', ); await _loadCommentsForCurrent(token: token); } on CancelledException { return; } catch (e, st) { if (_isStale(token, context.itemId)) return; AppLogger.error(_tag, 'loadFor failed', e, st); state = AsyncData(DanmakuSessionFailed(e.toString())); } } Future selectMatch(int index) async { final current = state.value; if (current is! DanmakuSessionMatched) return; if (index < 0 || index >= current.matches.length) return; if (index == current.selectedIndex && current.commentStatus == DanmakuCommentStatus.ready) { return; } final token = _swapToken('selectMatch:$index'); state = AsyncData( current.copyWith( selectedIndex: index, commentStatus: DanmakuCommentStatus.loading, ), ); await _loadCommentsForCurrent(token: token); } Future applyManualSelection({ required DanmakuSource source, required int episodeId, required int animeId, required String animeTitle, required String episodeTitle, }) async { if (source.url.isEmpty || episodeId <= 0) return; final current = state.value; final picked = DanmakuMatchCandidate( source: source, match: DanmakuMatch( episodeId: episodeId, animeId: animeId, animeTitle: animeTitle, episodeTitle: episodeTitle, ), ); final previous = current is DanmakuSessionMatched ? current : null; final newMatches = previous == null ? [picked] : [ picked, ...previous.matches.where( (m) => m.episodeId != episodeId || m.source.url != source.url, ), ]; final token = _swapToken('applyManual:$episodeId'); state = AsyncData( DanmakuSessionMatched( matches: newMatches, selectedIndex: 0, commentStatus: DanmakuCommentStatus.loading, ), ); try { await _loadCommentsForCurrent(token: token); } catch (_) { if (_isStale(token, _currentItemId)) return; if (previous != null) { state = AsyncData(previous); } else { state = const AsyncData(DanmakuSessionEmpty()); } rethrow; } } Future?> manualSearch(String query) async { final trimmed = query.trim(); if (_sources.isEmpty || trimmed.isEmpty) return const []; final token = _token; final results = []; for (final source in _sources) { List matches; try { matches = await _gateway.match(source.url, trimmed, 0); } on CancelledException { return null; } catch (e) { if (token != null && token.isCancelled) return null; AppLogger.warn(_tag, 'manualSearch source "${source.url}" failed', e); continue; } if (token != null && token.isCancelled) return null; results.addAll( matches.map( (match) => DanmakuMatchCandidate(source: source, match: match), ), ); } return results; } Future?> listEpisodesForAnime( DanmakuSource source, int animeId, ) async { if (source.url.isEmpty || animeId <= 0) return const []; final token = _token; final res = await _gateway.getBangumiEpisodes(source.url, animeId); if (token != null && token.isCancelled) return null; return res; } Future _loadCommentsForCurrent({ required CancellationToken token, }) async { final current = state.value; if (current is! DanmakuSessionMatched) return; final selected = current.matches[current.selectedIndex]; final episodeId = selected.episodeId; final settings = _readSettings(); try { final raw = await _gateway.fetchComments( selected.source.url, episodeId, chConvert: _chConvert, ); token.throwIfCancelled(); final comments = processDanmakuComments( raw, blockedKeywords: settings.blockedKeywords, mergeDuplicates: settings.mergeDuplicates, ); if (_isStale(token, _currentItemId)) return; AppLogger.debug( _tag, 'comments ready from "${selected.source.displayName}" ' 'for ep=$episodeId: ${comments.length} items', ); final cur = state.value; if (cur is DanmakuSessionMatched) { state = AsyncData( cur.copyWith( commentStatus: DanmakuCommentStatus.ready, comments: comments, ), ); } } on CancelledException { return; } catch (e) { if (_isStale(token, _currentItemId)) return; AppLogger.warn(_tag, 'load comments failed for ep=$episodeId', e); final cur = state.value; if (cur is DanmakuSessionMatched) { state = AsyncData( cur.copyWith(commentStatus: DanmakuCommentStatus.failed), ); } } } CancellationToken _swapToken(String reason) { _token?.cancel(reason); final t = CancellationToken(); _token = t; return t; } bool _isStale(CancellationToken token, String? itemIdAtStart) { if (token.isCancelled) return true; if (token != _token) return true; if (itemIdAtStart != null && itemIdAtStart != _currentItemId) return true; return false; } DanmakuSettingsState _readSettings() => ref.read(danmakuSettingsProvider).value ?? const DanmakuSettingsState(); static int _chConvertFromMode(ChineseConvertMode mode) => switch (mode) { ChineseConvertMode.toSimplified => 1, ChineseConvertMode.toTraditional => 2, ChineseConvertMode.none => 0, }; } final danmakuSessionProvider = AsyncNotifierProvider.autoDispose< DanmakuSessionNotifier, DanmakuSessionState >(DanmakuSessionNotifier.new);