Files
sm-emby-share/lib/core/media/external_subtitle_loader.dart
T

395 lines
11 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
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;
}
}