280 lines
8.2 KiB
Dart
280 lines
8.2 KiB
Dart
|
|
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<DanmakuSessionState> {
|
||
|
|
late final DanmakuGateway _gateway;
|
||
|
|
late final DanmakuMatcher _matcher;
|
||
|
|
|
||
|
|
CancellationToken? _token;
|
||
|
|
String? _currentItemId;
|
||
|
|
List<DanmakuSource> _sources = const [];
|
||
|
|
int _chConvert = 0;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<DanmakuSessionState> build() async {
|
||
|
|
_gateway = ref.read(danmakuGatewayProvider);
|
||
|
|
_matcher = DanmakuMatcher(gateway: _gateway);
|
||
|
|
ref.onDispose(() => _token?.cancel('notifier disposed'));
|
||
|
|
return const DanmakuSessionIdle();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> 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<void> 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<void> 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<List<DanmakuMatchCandidate>?> manualSearch(String query) async {
|
||
|
|
final trimmed = query.trim();
|
||
|
|
if (_sources.isEmpty || trimmed.isEmpty) return const [];
|
||
|
|
final token = _token;
|
||
|
|
final results = <DanmakuMatchCandidate>[];
|
||
|
|
|
||
|
|
for (final source in _sources) {
|
||
|
|
List<DanmakuMatch> 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<List<DanmakuBangumiEpisode>?> 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<void> _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);
|