Initial commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import '../contracts/danmaku.dart';
|
||||
|
||||
const defaultDuplicateDanmakuMergeWindowSeconds = 1.0;
|
||||
|
||||
final _whitespacePattern = RegExp(r'\s+');
|
||||
|
||||
List<DanmakuComment> mergeDuplicateDanmakuComments(
|
||||
Iterable<DanmakuComment> comments, {
|
||||
double windowSeconds = defaultDuplicateDanmakuMergeWindowSeconds,
|
||||
}) {
|
||||
if (windowSeconds < 0) {
|
||||
throw ArgumentError.value(
|
||||
windowSeconds,
|
||||
'windowSeconds',
|
||||
'must be non-negative',
|
||||
);
|
||||
}
|
||||
|
||||
final sorted = comments.toList()..sort(_compareDanmakuComments);
|
||||
if (sorted.length < 2) return sorted;
|
||||
|
||||
|
||||
final keptByText = <String, List<DanmakuComment>>{};
|
||||
final passthrough = <DanmakuComment>[];
|
||||
|
||||
for (final comment in sorted) {
|
||||
final key = _mergeKey(comment);
|
||||
if (key.isEmpty) {
|
||||
passthrough.add(comment);
|
||||
continue;
|
||||
}
|
||||
|
||||
final kept = keptByText.putIfAbsent(key, () => []);
|
||||
final windowStart = kept.isEmpty ? null : kept.last.time;
|
||||
if (windowStart == null || comment.time - windowStart > windowSeconds) {
|
||||
kept.add(comment);
|
||||
}
|
||||
}
|
||||
|
||||
return <DanmakuComment>[
|
||||
...passthrough,
|
||||
for (final kept in keptByText.values) ...kept,
|
||||
]..sort(_compareDanmakuComments);
|
||||
}
|
||||
|
||||
int _compareDanmakuComments(DanmakuComment a, DanmakuComment b) {
|
||||
final timeOrder = a.time.compareTo(b.time);
|
||||
if (timeOrder != 0) return timeOrder;
|
||||
return a.cid.compareTo(b.cid);
|
||||
}
|
||||
|
||||
String _mergeKey(DanmakuComment comment) {
|
||||
return comment.text.trim().replaceAll(_whitespacePattern, ' ');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../contracts/danmaku.dart';
|
||||
import 'danmaku_comment_merger.dart';
|
||||
|
||||
|
||||
List<DanmakuComment> processDanmakuComments(
|
||||
List<DanmakuComment> comments, {
|
||||
required List<String> blockedKeywords,
|
||||
required bool mergeDuplicates,
|
||||
}) {
|
||||
final visible = blockedKeywords.isEmpty
|
||||
? comments
|
||||
: comments
|
||||
.where((c) => blockedKeywords.every((kw) => !c.text.contains(kw)))
|
||||
.toList();
|
||||
final processed = mergeDuplicates
|
||||
? mergeDuplicateDanmakuComments(visible)
|
||||
: List<DanmakuComment>.of(visible);
|
||||
processed.sort((a, b) => a.time.compareTo(b.time));
|
||||
return processed;
|
||||
}
|
||||
|
||||
|
||||
int lowerBoundByTime(List<DanmakuComment> sortedByTime, double posSec) {
|
||||
var lo = 0;
|
||||
var hi = sortedByTime.length;
|
||||
while (lo < hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
if (sortedByTime[mid].time < posSec) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
int? extractEpisodeNumberFromTitle(String title) {
|
||||
for (final p in _episodeTitlePatterns) {
|
||||
final m = p.firstMatch(title);
|
||||
if (m != null) {
|
||||
final n = int.tryParse(m.group(1)!);
|
||||
if (n != null) return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<RegExp> _episodeTitlePatterns = [
|
||||
RegExp(r'第\s*0*(\d+)\s*[话集話]'),
|
||||
RegExp(r'\bS\d+E0*(\d+)\b', caseSensitive: false),
|
||||
RegExp(r'\bEP?\s*0*(\d+)\b', caseSensitive: false),
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
import '../contracts/danmaku.dart';
|
||||
|
||||
|
||||
const int kDefaultHeatmapMinComments = 50;
|
||||
|
||||
|
||||
const int kDefaultHeatmapBucketCount = 200;
|
||||
|
||||
|
||||
class DanmakuHeatmapData {
|
||||
final List<double> buckets;
|
||||
final int bucketCount;
|
||||
final int totalComments;
|
||||
final int maxCount;
|
||||
final bool visible;
|
||||
|
||||
const DanmakuHeatmapData({
|
||||
required this.buckets,
|
||||
required this.bucketCount,
|
||||
required this.totalComments,
|
||||
required this.maxCount,
|
||||
required this.visible,
|
||||
});
|
||||
|
||||
static const empty = DanmakuHeatmapData(
|
||||
buckets: <double>[],
|
||||
bucketCount: 0,
|
||||
totalComments: 0,
|
||||
maxCount: 0,
|
||||
visible: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
DanmakuHeatmapData computeHeatmap({
|
||||
required List<DanmakuComment> comments,
|
||||
required double durationSec,
|
||||
int bucketCount = kDefaultHeatmapBucketCount,
|
||||
int minComments = kDefaultHeatmapMinComments,
|
||||
}) {
|
||||
if (bucketCount <= 0) return DanmakuHeatmapData.empty;
|
||||
if (durationSec <= 0) return DanmakuHeatmapData.empty;
|
||||
|
||||
final counts = List<int>.filled(bucketCount, 0);
|
||||
int total = 0;
|
||||
for (final c in comments) {
|
||||
final t = c.time;
|
||||
if (t < 0 || t > durationSec) continue;
|
||||
var idx = (t / durationSec * bucketCount).floor();
|
||||
if (idx >= bucketCount) idx = bucketCount - 1;
|
||||
if (idx < 0) idx = 0;
|
||||
counts[idx]++;
|
||||
total++;
|
||||
}
|
||||
|
||||
if (total < minComments) {
|
||||
return DanmakuHeatmapData(
|
||||
buckets: const <double>[],
|
||||
bucketCount: bucketCount,
|
||||
totalComments: total,
|
||||
maxCount: 0,
|
||||
visible: false,
|
||||
);
|
||||
}
|
||||
|
||||
var maxCount = 0;
|
||||
for (final v in counts) {
|
||||
if (v > maxCount) maxCount = v;
|
||||
}
|
||||
if (maxCount <= 0) {
|
||||
return DanmakuHeatmapData(
|
||||
buckets: const <double>[],
|
||||
bucketCount: bucketCount,
|
||||
totalComments: total,
|
||||
maxCount: 0,
|
||||
visible: false,
|
||||
);
|
||||
}
|
||||
|
||||
final buckets = List<double>.generate(
|
||||
bucketCount,
|
||||
(i) => counts[i] / maxCount,
|
||||
growable: false,
|
||||
);
|
||||
|
||||
return DanmakuHeatmapData(
|
||||
buckets: buckets,
|
||||
bucketCount: bucketCount,
|
||||
totalComments: total,
|
||||
maxCount: maxCount,
|
||||
visible: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
List<double> downsampleHeatmap(List<double> buckets, int targetCount) {
|
||||
if (buckets.isEmpty || targetCount <= 0) return const <double>[];
|
||||
if (targetCount >= buckets.length) return buckets;
|
||||
final out = List<double>.filled(targetCount, 0.0);
|
||||
final src = buckets.length;
|
||||
for (var i = 0; i < targetCount; i++) {
|
||||
final start = (i * src / targetCount).floor();
|
||||
final end = ((i + 1) * src / targetCount).floor().clamp(start + 1, src);
|
||||
var m = 0.0;
|
||||
for (var j = start; j < end; j++) {
|
||||
if (buckets[j] > m) m = buckets[j];
|
||||
}
|
||||
out[i] = m;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import '../contracts/danmaku.dart';
|
||||
import 'danmaku_episode_parser.dart';
|
||||
|
||||
|
||||
int pickBestMatchIndex(List<DanmakuMatch> matches, int? targetEpisode) {
|
||||
if (targetEpisode == null) return 0;
|
||||
for (var i = 0; i < matches.length; i++) {
|
||||
final n = extractEpisodeNumberFromTitle(matches[i].episodeTitle);
|
||||
if (n == targetEpisode) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
class TimedSubtitle {
|
||||
final Duration start;
|
||||
final Duration end;
|
||||
final String text;
|
||||
|
||||
const TimedSubtitle({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.text,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class EmbeddedSubtitleCues {
|
||||
final List<TimedSubtitle> _cues = [];
|
||||
|
||||
bool get isEmpty => _cues.isEmpty;
|
||||
|
||||
|
||||
void add(double startSec, double endSec, List<String> lines) {
|
||||
final text = lines
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join('\n');
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final start = _toDuration(startSec);
|
||||
final end = _toDuration(endSec);
|
||||
if (end < start) return;
|
||||
|
||||
final at = _lowerBound(start);
|
||||
for (var i = at; i < _cues.length && _cues[i].start == start; i++) {
|
||||
if (_cues[i].end == end && _cues[i].text == text) return;
|
||||
}
|
||||
_cues.insert(at, TimedSubtitle(start: start, end: end, text: text));
|
||||
}
|
||||
|
||||
|
||||
List<String> textAt(Duration position) {
|
||||
final out = <String>[];
|
||||
for (final cue in _cues) {
|
||||
if (position < cue.start) break;
|
||||
if (position <= cue.end) out.addAll(cue.text.split('\n'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void clear() => _cues.clear();
|
||||
|
||||
static Duration _toDuration(double seconds) =>
|
||||
Duration(milliseconds: (seconds * 1000).round());
|
||||
|
||||
|
||||
int _lowerBound(Duration start) {
|
||||
int lo = 0;
|
||||
int hi = _cues.length;
|
||||
while (lo < hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
if (_cues[mid].start < start) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import '../contracts/library.dart';
|
||||
import '../../shared/utils/app_logger.dart';
|
||||
import 'media_range_detector.dart';
|
||||
|
||||
enum VideoRangeCategory { dolbyVision, hdr10, hlg, sdr, unknown }
|
||||
|
||||
class MediaCapability {
|
||||
final VideoRangeCategory rangeCategory;
|
||||
final String? codec;
|
||||
|
||||
const MediaCapability({required this.rangeCategory, this.codec});
|
||||
}
|
||||
|
||||
class ProbeResult {
|
||||
final String? videoCodec;
|
||||
final String? videoRange;
|
||||
final String? videoRangeType;
|
||||
|
||||
const ProbeResult({this.videoCodec, this.videoRange, this.videoRangeType});
|
||||
}
|
||||
|
||||
class MediaCapabilityAnalyzer {
|
||||
static const _tag = 'MediaCap';
|
||||
|
||||
static MediaCapability analyze(
|
||||
EmbyRawMediaSource? source, {
|
||||
ProbeResult? probeResult,
|
||||
}) {
|
||||
if (source == null && probeResult == null) {
|
||||
AppLogger.debug(_tag, 'analyze: source=null probeResult=null -> unknown');
|
||||
return const MediaCapability(rangeCategory: VideoRangeCategory.unknown);
|
||||
}
|
||||
|
||||
final codec = _resolveCodec(source, probeResult);
|
||||
final videoRange = _resolveVideoRange(source, probeResult);
|
||||
final videoRangeType = _resolveVideoRangeType(source, probeResult);
|
||||
|
||||
final isDV = _checkDolbyVision(
|
||||
source,
|
||||
probeResult,
|
||||
codec,
|
||||
videoRange,
|
||||
videoRangeType,
|
||||
);
|
||||
final isHdrDetected = _checkHdr(
|
||||
source,
|
||||
probeResult,
|
||||
videoRange,
|
||||
videoRangeType,
|
||||
);
|
||||
|
||||
final VideoRangeCategory rangeCategory;
|
||||
if (isDV) {
|
||||
rangeCategory = VideoRangeCategory.dolbyVision;
|
||||
} else if (isHdrDetected) {
|
||||
rangeCategory = _classifyHdr(videoRangeType, videoRange);
|
||||
} else {
|
||||
rangeCategory = VideoRangeCategory.sdr;
|
||||
}
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'analyze: codec=$codec '
|
||||
'videoRange=$videoRange videoRangeType=$videoRangeType '
|
||||
'-> rangeCategory=${rangeCategory.name}',
|
||||
);
|
||||
|
||||
return MediaCapability(rangeCategory: rangeCategory, codec: codec);
|
||||
}
|
||||
|
||||
static String? _resolveCodec(EmbyRawMediaSource? source, ProbeResult? probe) {
|
||||
if (probe?.videoCodec != null) return probe!.videoCodec;
|
||||
if (source == null) return null;
|
||||
final video = findVideoStream(source);
|
||||
return video?.Codec;
|
||||
}
|
||||
|
||||
static String? _resolveVideoRange(
|
||||
EmbyRawMediaSource? source,
|
||||
ProbeResult? probe,
|
||||
) {
|
||||
if (probe?.videoRange != null) return probe!.videoRange;
|
||||
if (source == null) return null;
|
||||
final video = findVideoStream(source);
|
||||
return video?.extra['VideoRange'] as String?;
|
||||
}
|
||||
|
||||
static String? _resolveVideoRangeType(
|
||||
EmbyRawMediaSource? source,
|
||||
ProbeResult? probe,
|
||||
) {
|
||||
if (probe?.videoRangeType != null) return probe!.videoRangeType;
|
||||
if (source == null) return null;
|
||||
final video = findVideoStream(source);
|
||||
return video?.extra['VideoRangeType'] as String?;
|
||||
}
|
||||
|
||||
static bool _checkDolbyVision(
|
||||
EmbyRawMediaSource? source,
|
||||
ProbeResult? probe,
|
||||
String? codec,
|
||||
String? videoRange,
|
||||
String? videoRangeType,
|
||||
) {
|
||||
if (probe != null) {
|
||||
final lCodec = codec?.toLowerCase() ?? '';
|
||||
final lRange = videoRange?.toLowerCase() ?? '';
|
||||
final lRangeType = videoRangeType?.toLowerCase() ?? '';
|
||||
|
||||
if (lRangeType.contains('dovi') || lRangeType.contains('dolby')) {
|
||||
return true;
|
||||
}
|
||||
if (lRange.contains('dovi') || lRange.contains('dolby')) return true;
|
||||
if (lCodec.contains('dvh') || lCodec.contains('dovi')) return true;
|
||||
|
||||
if (videoRange != null || videoRangeType != null) return false;
|
||||
}
|
||||
if (source != null) return isDolbyVision(source);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _checkHdr(
|
||||
EmbyRawMediaSource? source,
|
||||
ProbeResult? probe,
|
||||
String? videoRange,
|
||||
String? videoRangeType,
|
||||
) {
|
||||
if (probe != null) {
|
||||
final lRange = videoRange?.toLowerCase() ?? '';
|
||||
final lRangeType = videoRangeType?.toLowerCase() ?? '';
|
||||
final hasHdrSignal =
|
||||
lRange.contains('hdr') ||
|
||||
lRange.contains('dovi') ||
|
||||
lRangeType.contains('hdr') ||
|
||||
lRangeType.contains('dovi') ||
|
||||
lRangeType.contains('hlg');
|
||||
|
||||
if (hasHdrSignal) return true;
|
||||
|
||||
if (videoRange != null || videoRangeType != null) return false;
|
||||
}
|
||||
if (source != null) {
|
||||
if (isHdr(source)) return true;
|
||||
final video = findVideoStream(source);
|
||||
if (video != null) {
|
||||
final rangeType = (video.extra['VideoRangeType'] as String? ?? '')
|
||||
.toLowerCase();
|
||||
if (rangeType.contains('hlg')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static VideoRangeCategory _classifyHdr(
|
||||
String? videoRangeType,
|
||||
String? videoRange,
|
||||
) {
|
||||
final lRangeType = videoRangeType?.toLowerCase() ?? '';
|
||||
if (lRangeType.contains('hlg')) return VideoRangeCategory.hlg;
|
||||
if (lRangeType.contains('hdr10')) return VideoRangeCategory.hdr10;
|
||||
if (lRangeType.contains('hdr')) return VideoRangeCategory.hdr10;
|
||||
|
||||
final lRange = videoRange?.toLowerCase() ?? '';
|
||||
if (lRange.contains('hlg')) return VideoRangeCategory.hlg;
|
||||
if (lRange.contains('hdr')) return VideoRangeCategory.hdr10;
|
||||
|
||||
return VideoRangeCategory.unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fvp/mdk.dart' as mdk;
|
||||
|
||||
import '../../shared/utils/app_logger.dart';
|
||||
import 'media_capability_analyzer.dart';
|
||||
|
||||
|
||||
class MediaProbeService {
|
||||
static final MediaProbeService instance = MediaProbeService._();
|
||||
MediaProbeService._();
|
||||
|
||||
static const _tag = 'MediaProbe';
|
||||
|
||||
final Map<String, ProbeResult> _cache = {};
|
||||
|
||||
Future<ProbeResult?> probe(
|
||||
String url, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final cached = _cache[url];
|
||||
if (cached != null) {
|
||||
AppLogger.debug(_tag, 'probe: cache hit url=${_truncate(url)}');
|
||||
return cached;
|
||||
}
|
||||
|
||||
AppLogger.debug(_tag, 'probe: start url=${_truncate(url)}');
|
||||
|
||||
mdk.Player? player;
|
||||
try {
|
||||
player = mdk.Player()
|
||||
..setProperty('avio.reconnect', '1')
|
||||
..setProperty('avformat.extension_picky', '0')
|
||||
..setProperty('avformat.allowed_segment_extensions', 'ALL');
|
||||
player.media = url;
|
||||
|
||||
final ret = await player.prepare().timeout(timeout, onTimeout: () => -10);
|
||||
|
||||
ProbeResult? result;
|
||||
if (ret >= 0) {
|
||||
final video = player.mediaInfo.video;
|
||||
if (video != null && video.isNotEmpty) {
|
||||
final codec = video.first.codec;
|
||||
result = ProbeResult(
|
||||
videoCodec: codec.codec.isNotEmpty ? codec.codec : null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'probe: prepare failed ret=$ret url=${_truncate(url)}',
|
||||
);
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
_cache[url] = result;
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'probe: success url=${_truncate(url)} codec=${result.videoCodec}',
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'probe: error url=${_truncate(url)}', e);
|
||||
return null;
|
||||
} finally {
|
||||
player?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static String _truncate(String url) {
|
||||
return url.length > 50 ? '${url.substring(0, 50)}...' : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import '../contracts/library.dart';
|
||||
import '../../shared/utils/app_logger.dart';
|
||||
|
||||
EmbyRawMediaStream? findVideoStream(EmbyRawMediaSource source) {
|
||||
final streams = source.MediaStreams;
|
||||
if (streams == null) return null;
|
||||
for (final stream in streams) {
|
||||
if (stream.Type == 'Video') return stream;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final RegExp _dvNameTokenRegex = RegExp(r'\bdv\b', caseSensitive: false);
|
||||
|
||||
bool _nameLooksLikeDolbyVision(String? name) {
|
||||
if (name == null || name.isEmpty) return false;
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.contains('dovi')) return true;
|
||||
if (lower.contains('dolby vision')) return true;
|
||||
if (_dvNameTokenRegex.hasMatch(name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _nameLooksLikeHdr(String? name) {
|
||||
if (name == null || name.isEmpty) return false;
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.contains('hdr')) return true;
|
||||
return _nameLooksLikeDolbyVision(name);
|
||||
}
|
||||
|
||||
bool isDolbyVision(EmbyRawMediaSource source) {
|
||||
final video = findVideoStream(source);
|
||||
final hasVideoStream = video != null;
|
||||
final rawRange = video?.extra['VideoRange'] as String? ?? '';
|
||||
final rawRangeType = video?.extra['VideoRangeType'] as String? ?? '';
|
||||
final rawCodec = video?.Codec ?? '';
|
||||
final rawDisplayTitle = video?.DisplayTitle ?? '';
|
||||
final rawTitle = video?.Title ?? '';
|
||||
|
||||
bool result;
|
||||
if (!hasVideoStream) {
|
||||
result = false;
|
||||
} else {
|
||||
final range = rawRange.toLowerCase();
|
||||
final rangeType = rawRangeType.toLowerCase();
|
||||
final codec = rawCodec.toLowerCase();
|
||||
final displayTitle = rawDisplayTitle.toLowerCase();
|
||||
final title = rawTitle.toLowerCase();
|
||||
result =
|
||||
rangeType.contains('dovi') ||
|
||||
rangeType.contains('dolby') ||
|
||||
range.contains('dovi') ||
|
||||
range.contains('dolby') ||
|
||||
codec.contains('dvh') ||
|
||||
codec.contains('dovi') ||
|
||||
displayTitle.contains('dovi') ||
|
||||
displayTitle.contains('dolby vision') ||
|
||||
title.contains('dovi') ||
|
||||
title.contains('dolby vision');
|
||||
}
|
||||
|
||||
String fmt(String v) => v.isEmpty ? '<empty>' : v;
|
||||
final sourceId = source.Id ?? '<null>';
|
||||
AppLogger.debug(
|
||||
'DVDetect',
|
||||
'source=$sourceId hasVideo=$hasVideoStream '
|
||||
'VideoRange="${fmt(rawRange)}" VideoRangeType="${fmt(rawRangeType)}" '
|
||||
'Codec="${fmt(rawCodec)}" DisplayTitle="${fmt(rawDisplayTitle)}" '
|
||||
'Title="${fmt(rawTitle)}" -> isDV=$result',
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
final name = source.Name;
|
||||
final fallback = _nameLooksLikeDolbyVision(name);
|
||||
if (fallback) {
|
||||
AppLogger.debug(
|
||||
'DVDetect',
|
||||
'name fallback hit: name="${name ?? '<null>'}" -> isDV=true',
|
||||
);
|
||||
result = true;
|
||||
} else {
|
||||
AppLogger.debug(
|
||||
'DVDetect',
|
||||
'name fallback miss: name="${name ?? '<null>'}"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isHdr(EmbyRawMediaSource source) {
|
||||
final video = findVideoStream(source);
|
||||
bool result;
|
||||
if (video == null) {
|
||||
result = false;
|
||||
} else {
|
||||
final range = (video.extra['VideoRange'] as String? ?? '').toLowerCase();
|
||||
final rangeType = (video.extra['VideoRangeType'] as String? ?? '')
|
||||
.toLowerCase();
|
||||
result =
|
||||
range.contains('hdr') ||
|
||||
range.contains('dovi') ||
|
||||
rangeType.contains('hdr') ||
|
||||
rangeType.contains('dovi');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
final name = source.Name;
|
||||
final fallback = _nameLooksLikeHdr(name);
|
||||
if (fallback) {
|
||||
AppLogger.debug(
|
||||
'HDRDetect',
|
||||
'name fallback hit: name="${name ?? '<null>'}" -> isHdr=true',
|
||||
);
|
||||
result = true;
|
||||
} else {
|
||||
AppLogger.debug(
|
||||
'HDRDetect',
|
||||
'name fallback miss: name="${name ?? '<null>'}"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
double getFrameRate(EmbyRawMediaSource source) {
|
||||
final video = findVideoStream(source);
|
||||
if (video == null) return 0;
|
||||
return (video.extra['RealFrameRate'] as num?)?.toDouble() ??
|
||||
(video.extra['AverageFrameRate'] as num?)?.toDouble() ??
|
||||
0;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import '../contracts/library.dart';
|
||||
import '../../shared/utils/app_logger.dart';
|
||||
import 'media_capability_analyzer.dart';
|
||||
import 'media_probe_service.dart';
|
||||
import 'media_range_detector.dart';
|
||||
|
||||
const _tag = 'MediaSourceMeta';
|
||||
|
||||
class MediaSourceMetadataResolver {
|
||||
MediaSourceMetadataResolver({required this._probeService});
|
||||
|
||||
final MediaProbeService _probeService;
|
||||
|
||||
Future<ProbeResult?> resolve({
|
||||
required EmbyRawMediaSource? source,
|
||||
required String streamUrl,
|
||||
EmbyRawItem? item,
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
if (source == null) {
|
||||
return _probeService.probe(streamUrl, timeout: timeout);
|
||||
}
|
||||
|
||||
final sourceMetadata = _metadataFromSource(source);
|
||||
if (sourceMetadata != null) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'source metadata hit sourceId=${source.Id ?? '<null>'} '
|
||||
'container=${source.Container}',
|
||||
);
|
||||
return sourceMetadata;
|
||||
}
|
||||
|
||||
final itemMetadata = _metadataFromItem(item, source);
|
||||
if (itemMetadata != null) return itemMetadata;
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'metadata empty for sourceId=${source.Id ?? '<null>'} '
|
||||
'container=${source.Container}, probing stream',
|
||||
);
|
||||
return _probeService.probe(streamUrl, timeout: timeout);
|
||||
}
|
||||
|
||||
ProbeResult? _metadataFromItem(EmbyRawItem? item, EmbyRawMediaSource source) {
|
||||
final itemSources = _itemMediaSources(item);
|
||||
if (itemSources.isEmpty) return null;
|
||||
|
||||
Map<String, dynamic>? matched;
|
||||
var matchMode = 'none';
|
||||
for (final candidate in itemSources) {
|
||||
if (candidate['Id'] == source.Id) {
|
||||
matched = candidate;
|
||||
matchMode = 'matchById';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched == null && itemSources.length == 1) {
|
||||
matched = itemSources.first;
|
||||
matchMode = 'singleSource';
|
||||
}
|
||||
if (matched == null) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'no matching item MediaSource for sourceId=${source.Id ?? '<null>'}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final fallbackSource = EmbyRawMediaSource.fromJson(matched);
|
||||
final metadata = _metadataFromSource(fallbackSource);
|
||||
if (metadata == null) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'item.MediaSources matched but its video metadata is empty '
|
||||
'(sourceId=${source.Id ?? '<null>'}, $matchMode)',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'backfilled metadata from item.MediaSources '
|
||||
'sourceId=${source.Id ?? '<null>'} ($matchMode)',
|
||||
);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _itemMediaSources(EmbyRawItem? item) {
|
||||
final raw = item?.extra['MediaSources'];
|
||||
if (raw is! List) return const <Map<String, dynamic>>[];
|
||||
|
||||
final sources = <Map<String, dynamic>>[];
|
||||
for (final entry in raw) {
|
||||
if (entry is Map) {
|
||||
sources.add(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
ProbeResult? _metadataFromSource(EmbyRawMediaSource source) {
|
||||
final video = findVideoStream(source);
|
||||
if (video == null) return null;
|
||||
|
||||
return ProbeResult(
|
||||
videoCodec: video.Codec,
|
||||
videoRange: video.extra['VideoRange'] as String?,
|
||||
videoRangeType: video.extra['VideoRangeType'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user