Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,186 @@
import 'package:canvas_danmaku/canvas_danmaku.dart';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/material.dart' show Colors;
import '../../../../core/contracts/danmaku.dart';
import '../../../../core/media/danmaku_comment_pipeline.dart';
const double _kBackwardSeekThreshold = -1.0;
const double _kForwardSeekThreshold = 5.0;
const int _kMaxFeedPerTick = 10;
const double _kMaxFeedLagSec = 1.0;
enum DanmakuPlaybackState { detached, disabled, playing, frozen, seeking }
class DanmakuFeeder {
DanmakuController? _canvas;
List<DanmakuComment> _comments = const [];
DanmakuPanelSettings _settings = const DanmakuPanelSettings();
int _feedIndex = 0;
double _lastPosSec = -1;
DanmakuPlaybackState _state = DanmakuPlaybackState.detached;
double? _frozenPosSec;
void attach(DanmakuController controller) {
_canvas = controller;
_state = _settings.enabled
? DanmakuPlaybackState.frozen
: DanmakuPlaybackState.disabled;
}
void detach() {
_canvas = null;
_state = DanmakuPlaybackState.detached;
}
void setComments(List<DanmakuComment> commentsSortedByTime) {
_comments = commentsSortedByTime;
_canvas?.clear();
_feedIndex = 0;
_lastPosSec = -1;
_frozenPosSec = null;
}
void setPanelSettings(
DanmakuPanelSettings settings, {
DanmakuOption? option,
}) {
_settings = settings;
if (!_settings.enabled) {
_state = DanmakuPlaybackState.disabled;
} else if (_state == DanmakuPlaybackState.disabled) {
_state = _canvas == null
? DanmakuPlaybackState.detached
: DanmakuPlaybackState.frozen;
}
if (option != null) {
try {
_canvas?.updateOption(option);
} catch (e) {
debugPrint('[DanmakuFeeder] updateOption failed: $e; detaching canvas');
_canvas = null;
_state = DanmakuPlaybackState.detached;
}
}
}
void onPosition(double posSec) {
final canvas = _canvas;
if (canvas == null ||
_comments.isEmpty ||
!_settings.enabled ||
_state != DanmakuPlaybackState.playing) {
return;
}
final effectivePos = posSec - _settings.offset;
final last = _lastPosSec;
if (last >= 0 && _isTimelineJump(posSec - last)) {
canvas.clear();
_feedIndex = lowerBoundByTime(_comments, effectivePos);
}
_lastPosSec = posSec;
var fedThisTick = 0;
while (_feedIndex < _comments.length && fedThisTick < _kMaxFeedPerTick) {
final comment = _comments[_feedIndex];
if (comment.time > effectivePos) break;
_feedIndex++;
if (effectivePos - comment.time > _kMaxFeedLagSec) continue;
canvas.addDanmaku(_toContentItem(comment));
fedThisTick++;
}
}
void freezeAt(double posSec) {
final canvas = _canvas;
if (canvas == null) {
_state = DanmakuPlaybackState.detached;
return;
}
if (!_settings.enabled) {
_state = DanmakuPlaybackState.disabled;
return;
}
_state = DanmakuPlaybackState.frozen;
_frozenPosSec = posSec;
_lastPosSec = posSec;
if (canvas.running) {
canvas.pause();
}
}
void playFrom(double posSec) {
final canvas = _canvas;
if (canvas == null) {
_state = DanmakuPlaybackState.detached;
return;
}
if (!_settings.enabled) {
_state = DanmakuPlaybackState.disabled;
return;
}
final frozenPos = _frozenPosSec;
if (_state == DanmakuPlaybackState.frozen &&
frozenPos != null &&
posSec > frozenPos) {
final targetIndex = lowerBoundByTime(
_comments,
posSec - _settings.offset,
);
if (targetIndex > _feedIndex) {
_feedIndex = targetIndex;
}
}
_lastPosSec = posSec;
_frozenPosSec = null;
_state = DanmakuPlaybackState.playing;
if (!canvas.running) {
canvas.resume();
}
}
void seekTo(double posSec, {bool clear = true}) {
if (clear) _canvas?.clear();
_feedIndex = lowerBoundByTime(_comments, posSec - _settings.offset);
_lastPosSec = posSec;
_frozenPosSec = null;
if (_canvas == null) {
_state = DanmakuPlaybackState.detached;
} else {
_state = _settings.enabled
? DanmakuPlaybackState.seeking
: DanmakuPlaybackState.disabled;
}
}
bool _isTimelineJump(double deltaSec) {
return deltaSec < _kBackwardSeekThreshold ||
deltaSec > _kForwardSeekThreshold;
}
DanmakuContentItem _toContentItem(DanmakuComment c) {
final baseColor = _settings.colored ? c.color : Colors.white;
final opacity = _settings.opacity.clamp(0.0, 1.0);
final color = opacity >= 1.0
? baseColor
: baseColor.withValues(alpha: opacity);
return DanmakuContentItem(
c.text,
color: color,
type: switch (c.type) {
4 when !_settings.fixedToScroll => DanmakuItemType.bottom,
5 when !_settings.fixedToScroll => DanmakuItemType.top,
_ => DanmakuItemType.scroll,
},
);
}
}
@@ -0,0 +1,212 @@
import '../../../../core/contracts/cancellation.dart';
import '../../../../core/contracts/danmaku.dart';
import '../../../../core/domain/ports/danmaku_gateway.dart';
import '../../../../core/infra/danmaku_api/danmaku_client.dart';
import '../../../../core/media/danmaku_episode_parser.dart';
import '../../../../core/media/danmaku_match_picker.dart';
import '../../../../shared/utils/app_logger.dart';
const _tag = 'DanmakuMatcher';
typedef _SingleSourceMatcherResult = ({
List<DanmakuMatch> matches,
int pickedIndex,
});
const _emptySingleSourceResult = (matches: <DanmakuMatch>[], pickedIndex: -1);
class DanmakuMatcherResult {
final List<DanmakuMatchCandidate> matches;
final int pickedIndex;
const DanmakuMatcherResult({
required this.matches,
required this.pickedIndex,
});
static const empty = DanmakuMatcherResult(matches: [], pickedIndex: -1);
}
class DanmakuMatcher {
DanmakuMatcher({required this._gateway});
final DanmakuGateway _gateway;
Future<DanmakuMatcherResult> resolve({
required List<DanmakuSource> sources,
required DanmakuMatchContext context,
CancellationToken? cancelToken,
}) async {
if (sources.isEmpty) return DanmakuMatcherResult.empty;
final allMatches = <DanmakuMatchCandidate>[];
var pickedIndex = -1;
for (final source in sources) {
cancelToken?.throwIfCancelled();
_SingleSourceMatcherResult result;
try {
result = await _resolveSingle(
sourceUrl: source.url,
context: context,
cancelToken: cancelToken,
);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.warn(
_tag,
'source "${source.url}" match failed, skipping',
e,
);
continue;
}
cancelToken?.throwIfCancelled();
if (result.matches.isEmpty) continue;
final baseIndex = allMatches.length;
allMatches.addAll(
result.matches.map(
(match) => DanmakuMatchCandidate(source: source, match: match),
),
);
if (pickedIndex < 0 && result.pickedIndex >= 0) {
pickedIndex = baseIndex + result.pickedIndex;
}
}
if (allMatches.isEmpty) return DanmakuMatcherResult.empty;
return DanmakuMatcherResult(
matches: allMatches,
pickedIndex: pickedIndex < 0 ? 0 : pickedIndex,
);
}
Future<_SingleSourceMatcherResult> _resolveSingle({
required String sourceUrl,
required DanmakuMatchContext context,
CancellationToken? cancelToken,
}) async {
final fileName = buildDanmakuFileName(
name: context.name,
type: context.type,
seriesName: context.seriesName,
season: context.season,
episode: context.episode,
);
final episodeIndex = context.episode;
AppLogger.debug(
_tag,
'match request source="$sourceUrl" fileName="$fileName" '
'durationSec=${context.durationSec} targetEp=$episodeIndex',
);
var matches = await _gateway.match(
sourceUrl,
fileName,
context.durationSec,
);
cancelToken?.throwIfCancelled();
AppLogger.debug(
_tag,
'match received from "$sourceUrl": ${matches.length} entries',
);
if (matches.isEmpty &&
context.type == 'Episode' &&
context.seriesName != null &&
context.seriesName!.isNotEmpty) {
AppLogger.info(
_tag,
'exact match empty, falling back to searchEpisodes source="$sourceUrl" '
'seriesName="${context.seriesName}" ep=$episodeIndex',
);
matches = await _gateway.searchEpisodes(
sourceUrl,
context.seriesName!,
episodeIndex,
);
cancelToken?.throwIfCancelled();
AppLogger.debug(
_tag,
'searchEpisodes fallback: ${matches.length} entries',
);
}
if (matches.isEmpty) return _emptySingleSourceResult;
final pickIndex = pickBestMatchIndex(matches, episodeIndex);
if (episodeIndex != null) {
final pickedEp = extractEpisodeNumberFromTitle(
matches[pickIndex].episodeTitle,
);
if (pickedEp != episodeIndex) {
final corrected = await _correctViaBangumi(
sourceUrl: sourceUrl,
animeId: matches[pickIndex].animeId,
targetEpisode: episodeIndex,
animeTitle: matches[pickIndex].animeTitle,
cancelToken: cancelToken,
);
if (corrected != null) {
AppLogger.info(
_tag,
'bangumi correction applied: episodeId=${corrected.episodeId} '
'title="${corrected.episodeTitle}"',
);
return (matches: [corrected, ...matches], pickedIndex: 0);
}
}
}
return (matches: matches, pickedIndex: pickIndex);
}
Future<DanmakuMatch?> _correctViaBangumi({
required String sourceUrl,
required int animeId,
required int targetEpisode,
required String animeTitle,
CancellationToken? cancelToken,
}) async {
if (animeId <= 0) {
AppLogger.warn(
_tag,
'skip bangumi correction: animeId missing in match response',
);
return null;
}
AppLogger.debug(
_tag,
'bangumi correction probing: animeId=$animeId targetEp=$targetEpisode',
);
final episodes = await _gateway.getBangumiEpisodes(sourceUrl, animeId);
cancelToken?.throwIfCancelled();
if (episodes.isEmpty) {
AppLogger.warn(_tag, 'bangumi $animeId returned no episodes');
return null;
}
for (final ep in episodes) {
final n =
int.tryParse(ep.episodeNumber) ??
extractEpisodeNumberFromTitle(ep.episodeTitle);
if (n == targetEpisode) {
return DanmakuMatch(
episodeId: ep.episodeId,
animeId: animeId,
animeTitle: animeTitle,
episodeTitle: ep.episodeTitle,
);
}
}
AppLogger.warn(
_tag,
'bangumi $animeId has ${episodes.length} episodes but none matches '
'targetEp=$targetEpisode',
);
return null;
}
}
@@ -0,0 +1,279 @@
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);