395 lines
11 KiB
Dart
395 lines
11 KiB
Dart
|
|
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<String, List<TimedSubtitle>> _cacheByUrl = {};
|
||
|
|
final Map<String, Future<List<TimedSubtitle>>> _inflightByUrl = {};
|
||
|
|
|
||
|
|
ExternalSubtitleLoader({Dio? dio})
|
||
|
|
: _dio =
|
||
|
|
dio ??
|
||
|
|
Dio(
|
||
|
|
BaseOptions(
|
||
|
|
connectTimeout: const Duration(seconds: 10),
|
||
|
|
receiveTimeout: const Duration(seconds: 20),
|
||
|
|
responseType: ResponseType.bytes,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
|
||
|
|
Future<List<TimedSubtitle>> load(
|
||
|
|
String url, {
|
||
|
|
Map<String, String>? 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<List<TimedSubtitle>> _download(
|
||
|
|
String url,
|
||
|
|
Map<String, String>? headers,
|
||
|
|
Duration? receiveTimeout,
|
||
|
|
) async {
|
||
|
|
List<int> bytes;
|
||
|
|
try {
|
||
|
|
final response = await _dio.get<List<int>>(
|
||
|
|
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<int> 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<TimedSubtitle> 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<TimedSubtitle> _parseSrt(String content) {
|
||
|
|
final cues = <TimedSubtitle>[];
|
||
|
|
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<TimedSubtitle> _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 = <TimedSubtitle>[];
|
||
|
|
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<TimedSubtitle> _parseAss(String content) {
|
||
|
|
final cues = <TimedSubtitle>[];
|
||
|
|
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<TimedSubtitle> _sortAndValidate(List<TimedSubtitle> 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<TimedSubtitle> _cues;
|
||
|
|
final List<Duration> _prefixMaximumEnd;
|
||
|
|
|
||
|
|
SubtitleCueSource(List<TimedSubtitle> sortedCues)
|
||
|
|
: _cues = sortedCues,
|
||
|
|
_prefixMaximumEnd = _buildPrefixMaximumEnd(sortedCues);
|
||
|
|
|
||
|
|
bool get isEmpty => _cues.isEmpty;
|
||
|
|
|
||
|
|
|
||
|
|
List<String> textAt(Duration position) {
|
||
|
|
final firstPossibleIndex = _firstCueThatMayOverlap(position);
|
||
|
|
if (firstPossibleIndex >= _cues.length) return const [];
|
||
|
|
|
||
|
|
final lines = <String>[];
|
||
|
|
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<Duration> _buildPrefixMaximumEnd(List<TimedSubtitle> sortedCues) {
|
||
|
|
final prefixMaximumEnd = <Duration>[];
|
||
|
|
var maximumEnd = Duration.zero;
|
||
|
|
for (final cue in sortedCues) {
|
||
|
|
if (cue.end > maximumEnd) maximumEnd = cue.end;
|
||
|
|
prefixMaximumEnd.add(maximumEnd);
|
||
|
|
}
|
||
|
|
return prefixMaximumEnd;
|
||
|
|
}
|
||
|
|
}
|