import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../../shared/utils/app_logger.dart'; import 'embedded_subtitle_cues.dart'; const _tag = 'ExternalSubtitleLoader'; const _backgroundParseThresholdBytes = 256 * 1024; class ExternalSubtitleLoadException implements Exception { final String message; final Object? cause; const ExternalSubtitleLoadException(this.message, [this.cause]); @override String toString() => 'ExternalSubtitleLoadException: $message' '${cause != null ? ' ($cause)' : ''}'; } class ExternalSubtitleLoader { final Dio _dio; final Map> _cacheByUrl = {}; final Map>> _inflightByUrl = {}; ExternalSubtitleLoader({Dio? dio}) : _dio = dio ?? Dio( BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 20), responseType: ResponseType.bytes, ), ); Future> load( String url, { Map? headers, Duration? receiveTimeout, }) { final cached = _cacheByUrl[url]; if (cached != null) return Future.value(cached); final inflight = _inflightByUrl[url]; if (inflight != null) return inflight; final future = _download(url, headers, receiveTimeout).whenComplete(() { _inflightByUrl.remove(url); }); _inflightByUrl[url] = future; return future; } Future> _download( String url, Map? headers, Duration? receiveTimeout, ) async { List bytes; try { final response = await _dio.get>( url, options: Options( headers: headers, responseType: ResponseType.bytes, receiveTimeout: receiveTimeout, ), ); bytes = response.data ?? const []; } on DioException catch (error) { AppLogger.warn( _tag, 'download failed url=$url status=${error.response?.statusCode} ' 'type=${error.type}', ); throw ExternalSubtitleLoadException('外挂字幕下载失败', error); } if (bytes.isEmpty) { AppLogger.warn(_tag, 'download returned empty body url=$url'); throw const ExternalSubtitleLoadException('外挂字幕内容为空'); } final content = _decodeText(bytes); final cues = bytes.length >= _backgroundParseThresholdBytes ? await compute(parseSubtitleContent, content) : parseSubtitleContent(content); if (cues.isEmpty) { AppLogger.warn( _tag, 'parse produced no cues url=$url bytes=${bytes.length}', ); throw const ExternalSubtitleLoadException('外挂字幕解析结果为空'); } _cacheByUrl[url] = cues; AppLogger.debug(_tag, 'loaded url=$url cues=${cues.length}'); return cues; } void clear() { _cacheByUrl.clear(); _inflightByUrl.clear(); } static String _decodeText(List bytes) { var start = 0; if (bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { start = 3; } final body = start == 0 ? bytes : bytes.sublist(start); try { return utf8.decode(body); } on FormatException { return latin1.decode(body); } } static List parseSubtitleContent(String content) { final normalized = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); final trimmed = normalized.trimLeft(); if (trimmed.startsWith('WEBVTT')) { return _parseVtt(normalized); } if (trimmed.startsWith('[Script Info]') || normalized.contains('\nDialogue:') || trimmed.startsWith('Dialogue:')) { return _parseAss(normalized); } return _parseSrt(normalized); } static final RegExp _srtTimeLine = RegExp( r'(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})', ); static List _parseSrt(String content) { final cues = []; final blocks = content.split(RegExp(r'\n{2,}')); for (final block in blocks) { final lines = block .split('\n') .where((line) => line.trim().isNotEmpty) .toList(); if (lines.isEmpty) continue; var timeLineIndex = -1; RegExpMatch? timeMatch; for (var i = 0; i < lines.length; i++) { timeMatch = _srtTimeLine.firstMatch(lines[i]); if (timeMatch != null) { timeLineIndex = i; break; } } if (timeMatch == null) continue; final textLines = lines .sublist(timeLineIndex + 1) .map(_stripHtmlTags) .where((line) => line.trim().isNotEmpty) .toList(); if (textLines.isEmpty) continue; cues.add( TimedSubtitle( start: _timeFromMatch(timeMatch, 1), end: _timeFromMatch(timeMatch, 5), text: textLines.join('\n'), ), ); } return _sortAndValidate(cues); } static List _parseVtt(String content) { final vttTimeLine = RegExp( r'(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})', ); final cues = []; final blocks = content.split(RegExp(r'\n{2,}')); for (final block in blocks) { final lines = block .split('\n') .where((line) => line.trim().isNotEmpty) .toList(); if (lines.isEmpty) continue; final first = lines.first.trim(); if (first.startsWith('WEBVTT') || first.startsWith('NOTE') || first.startsWith('STYLE') || first.startsWith('REGION')) { continue; } var timeLineIndex = -1; RegExpMatch? timeMatch; for (var i = 0; i < lines.length; i++) { timeMatch = vttTimeLine.firstMatch(lines[i]); if (timeMatch != null) { timeLineIndex = i; break; } } if (timeMatch == null) continue; final textLines = lines .sublist(timeLineIndex + 1) .map(_stripHtmlTags) .where((line) => line.trim().isNotEmpty) .toList(); if (textLines.isEmpty) continue; cues.add( TimedSubtitle( start: _vttTimeFromMatch(timeMatch, 1), end: _vttTimeFromMatch(timeMatch, 5), text: textLines.join('\n'), ), ); } return _sortAndValidate(cues); } static final RegExp _assTime = RegExp(r'^(\d+):(\d{2}):(\d{2})\.(\d{2})$'); static List _parseAss(String content) { final cues = []; var startFieldIndex = 1; var endFieldIndex = 2; var textFieldIndex = 9; for (final rawLine in content.split('\n')) { final line = rawLine.trim(); if (line.startsWith('Format:')) { final fields = line .substring('Format:'.length) .split(',') .map((field) => field.trim().toLowerCase()) .toList(); final start = fields.indexOf('start'); final end = fields.indexOf('end'); final text = fields.indexOf('text'); if (start >= 0 && end >= 0 && text >= 0) { startFieldIndex = start; endFieldIndex = end; textFieldIndex = text; } continue; } if (!line.startsWith('Dialogue:')) continue; final payload = line.substring('Dialogue:'.length).trim(); final parts = payload.split(','); if (parts.length <= textFieldIndex) continue; final startRaw = parts[startFieldIndex].trim(); final endRaw = parts[endFieldIndex].trim(); final textRaw = parts.sublist(textFieldIndex).join(','); final start = _parseAssTime(startRaw); final end = _parseAssTime(endRaw); if (start == null || end == null) continue; final text = _cleanAssText(textRaw); if (text.isEmpty) continue; cues.add(TimedSubtitle(start: start, end: end, text: text)); } return _sortAndValidate(cues); } static Duration? _parseAssTime(String raw) { final match = _assTime.firstMatch(raw); if (match == null) return null; return Duration( hours: int.parse(match.group(1)!), minutes: int.parse(match.group(2)!), seconds: int.parse(match.group(3)!), milliseconds: int.parse(match.group(4)!) * 10, ); } static String _cleanAssText(String raw) { var text = raw.replaceAll(RegExp(r'\{[^}]*\}'), ''); text = text .replaceAll(r'\N', '\n') .replaceAll(r'\n', '\n') .replaceAll(r'\h', ' '); return text .split('\n') .map((line) => line.trim()) .where((line) => line.isNotEmpty) .join('\n') .trim(); } static String _stripHtmlTags(String line) => line.replaceAll(RegExp(r']+>'), ''); static Duration _timeFromMatch(RegExpMatch match, int groupOffset) { final millisRaw = match.group(groupOffset + 3)!; final millis = int.parse(millisRaw.padRight(3, '0')); return Duration( hours: int.parse(match.group(groupOffset)!), minutes: int.parse(match.group(groupOffset + 1)!), seconds: int.parse(match.group(groupOffset + 2)!), milliseconds: millis, ); } static Duration _vttTimeFromMatch(RegExpMatch match, int groupOffset) { return Duration( hours: int.parse(match.group(groupOffset) ?? '0'), minutes: int.parse(match.group(groupOffset + 1)!), seconds: int.parse(match.group(groupOffset + 2)!), milliseconds: int.parse(match.group(groupOffset + 3)!), ); } static List _sortAndValidate(List cues) { final valid = cues.where((cue) => cue.end >= cue.start).toList() ..sort((a, b) => a.start.compareTo(b.start)); return valid; } } class SubtitleCueSource { final List _cues; final List _prefixMaximumEnd; SubtitleCueSource(List sortedCues) : _cues = sortedCues, _prefixMaximumEnd = _buildPrefixMaximumEnd(sortedCues); bool get isEmpty => _cues.isEmpty; List textAt(Duration position) { final firstPossibleIndex = _firstCueThatMayOverlap(position); if (firstPossibleIndex >= _cues.length) return const []; final lines = []; for ( var cueIndex = firstPossibleIndex; cueIndex < _cues.length; cueIndex++ ) { final cue = _cues[cueIndex]; if (position < cue.start) break; if (position <= cue.end) lines.addAll(cue.text.split('\n')); } return lines; } int _firstCueThatMayOverlap(Duration position) { var lowerBound = 0; var upperBound = _prefixMaximumEnd.length; while (lowerBound < upperBound) { final middleIndex = (lowerBound + upperBound) >> 1; if (_prefixMaximumEnd[middleIndex] < position) { lowerBound = middleIndex + 1; } else { upperBound = middleIndex; } } return lowerBound; } static List _buildPrefixMaximumEnd(List sortedCues) { final prefixMaximumEnd = []; var maximumEnd = Duration.zero; for (final cue in sortedCues) { if (cue.end > maximumEnd) maximumEnd = cue.end; prefixMaximumEnd.add(maximumEnd); } return prefixMaximumEnd; } }