Initial commit
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user