Files
reelbox/lib/media/media_helpers.dart
T

444 lines
11 KiB
Dart

import 'dart:convert';
import 'package:media_kit/media_kit.dart';
import '../data/models.dart';
import '../state/app_state.dart';
/// Orchestrates pending player work across route disposal/source changes.
class PlaybackLifecycle {
PlaybackLifecycle({this.isMounted});
final bool Function()? isMounted;
bool _disposed = false;
int _openGeneration = 0;
// Platform/window state is shared by every player route, not just a widget.
static Future<void>? _systemTail;
static PlaybackLifecycle? _systemOwner;
bool get ownsSystem => _active && identical(_systemOwner, this);
bool get _active => !_disposed && (isMounted?.call() ?? true);
static Future<T> _serialize<T>(Future<T> Function() action) {
final previous = _systemTail;
// Drop completed futures when idle, rather than keeping a previous player's
// scheduling zone alive. Work is still serialized across active routes.
final work = previous == null
? Future<T>.microtask(action)
: previous.then((_) => action());
late final Future<void> tail;
tail = work
.then<void>((_) {}, onError: (Object _, StackTrace _) {})
.whenComplete(() {
if (identical(_systemTail, tail)) _systemTail = null;
});
_systemTail = tail;
return work;
}
Future<bool> setup(List<Future<void> Function()> steps) {
// Capture ownership before even awaiting the system-work queue.
final activeAtStart = _active;
return _serialize(() async {
if (!activeAtStart || !_active) return false;
_systemOwner = this;
for (final step in steps) {
await step();
if (!_active) return false;
}
return true;
});
}
Future<bool> open({
required Future<void> Function() save,
required Future<void> Function(bool Function() current) dispatch,
}) async {
final generation = ++_openGeneration;
bool current() => _active && generation == _openGeneration;
if (!current()) return false;
await save();
if (!current()) return false;
await dispatch(current);
return current();
}
Future<void> dispose(Future<void> Function() restore) {
_disposed = true;
_openGeneration++;
// A previous route must never restore global state over a newer owner.
return _serialize(() async {
if (!identical(_systemOwner, this)) return;
await restore();
if (identical(_systemOwner, this)) _systemOwner = null;
});
}
}
Future<void> dispatchMediaOpen({
required Future<void> Function(Media media, {bool play}) open,
required String uri,
required Duration start,
required bool autoplay,
}) => open(Media(uri, start: start), play: autoplay);
/// Keep startup/failed-open zero state out of durable resume history.
class PlaybackCheckpoint {
Duration position = Duration.zero;
Duration duration = Duration.zero;
bool _opened = false;
bool get canPersist => _opened && position > Duration.zero;
void beginOpen() {
_opened = false;
position = Duration.zero;
duration = Duration.zero;
}
void didOpen() {
_opened = true;
}
}
EpisodeRecord? nextEpisode(List<EpisodeRecord> episodes, int? currentId) {
final index = episodes.indexWhere((episode) => episode.id == currentId);
return index >= 0 && index + 1 < episodes.length ? episodes[index + 1] : null;
}
String watchKey(int titleId, int? episodeId) =>
'$titleId:${episodeId ?? 'movie'}';
Duration resumePosition({
required Duration position,
required Duration duration,
int rewindSeconds = 0,
}) {
if (position < const Duration(seconds: 5)) return Duration.zero;
if (duration > Duration.zero &&
(position >= duration - const Duration(seconds: 15) ||
position.inMilliseconds / duration.inMilliseconds >= .95)) {
return Duration.zero;
}
return Duration(
milliseconds: (position.inMilliseconds - rewindSeconds.clamp(0, 120) * 1000)
.clamp(0, position.inMilliseconds),
);
}
/// Shared by enqueue, retry and external handoff: manifest aliases must never
/// fall through to a direct-file job, including extensionless MPD endpoints.
bool isSegmentedMediaSource(MediaSource source) {
final path = Uri.tryParse(source.url)?.path.toLowerCase() ?? '';
final type = source.type.split(';').first.trim().toLowerCase();
return path.endsWith('.m3u8') ||
path.endsWith('.mpd') ||
type == 'mpd' ||
RegExp(r'hls|dash|m3u8|mpegurl').hasMatch(type);
}
String? downloadUnsupportedReason(MediaSource source) {
if (!isRemoteMedia(source.url)) {
return 'Only public HTTP(S) files can be downloaded.';
}
if (isSegmentedMediaSource(source)) {
return 'Use offline HLS/DASH segment packaging instead of a direct video file.';
}
return null;
}
String downloadFilename({
required String title,
required String identity,
required String url,
}) {
var name = title
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), '_')
.replaceAll(RegExp(r'^\.+_?'), '')
.trim();
if (name.isEmpty) name = 'Video';
if (name.length > 90) name = name.substring(0, 90);
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
final ext =
RegExp(r'\.(mp4|mkv|webm|mov|avi|m4v|ts)$').firstMatch(path)?.group(1) ??
'mp4';
return '${name}_$identity.$ext';
}
bool isRemoteMedia(String url) {
final uri = Uri.tryParse(url);
return uri != null &&
(uri.scheme == 'https' || uri.scheme == 'http') &&
uri.host.isNotEmpty;
}
MediaSource? selectSource(
Iterable<MediaSource> sources, {
String preferredQuality = '',
}) {
final options = playableSources(sources);
if (options.isEmpty) return null;
return options.firstWhere(
(s) => s.quality == preferredQuality,
orElse: () => options.first,
);
}
List<MediaSource> playableSources(Iterable<MediaSource> sources) {
final seen = <String>{};
return sources
.where((s) => !s.isTrailer && isRemoteMedia(s.url) && seen.add(s.url))
.toList();
}
/// Strict Unicode first; legacy Persian/Arabic files commonly use Windows-1256.
String decodeSubtitle(List<int> bytes) {
if (bytes.length >= 2 &&
((bytes[0] == 255 && bytes[1] == 254) ||
(bytes[0] == 254 && bytes[1] == 255))) {
final little = bytes[0] == 255;
if (bytes.length.isOdd) {
throw const FormatException('Truncated UTF-16 subtitle');
}
return String.fromCharCodes([
for (var i = 2; i + 1 < bytes.length; i += 2)
little ? bytes[i] | bytes[i + 1] << 8 : bytes[i] << 8 | bytes[i + 1],
]);
}
try {
return utf8.decode(bytes).replaceFirst(RegExp(r'^\uFEFF'), '');
} on FormatException {
return String.fromCharCodes(
bytes.map((b) => b < 128 ? b : _windows1256[b - 128]),
);
}
}
// Unicode mapping of the Windows-1256 high-byte range (not Latin-1).
const _windows1256 = [
8364,
1662,
8218,
402,
8222,
8230,
8224,
8225,
710,
8240,
1657,
8249,
338,
1670,
1688,
1672,
1711,
8216,
8217,
8220,
8221,
8226,
8211,
8212,
1705,
8482,
1681,
8250,
339,
8204,
8205,
1722,
160,
1548,
162,
163,
164,
165,
166,
167,
168,
169,
1726,
171,
172,
173,
174,
175,
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
1563,
187,
188,
189,
190,
1567,
1729,
1569,
1570,
1571,
1572,
1573,
1574,
1575,
1576,
1577,
1578,
1579,
1580,
1581,
1582,
1583,
1584,
1585,
1586,
1587,
1588,
1589,
1590,
215,
1591,
1592,
1593,
1594,
1600,
1601,
1602,
1603,
224,
1604,
226,
1605,
1606,
1607,
1608,
231,
232,
233,
234,
235,
1609,
1610,
238,
239,
1611,
1612,
1613,
1614,
244,
1615,
1616,
247,
1617,
249,
1618,
251,
252,
8206,
8207,
1746,
];
class SubtitleVariant {
const SubtitleVariant(this.language, this.label, this.url);
final String language, label, url;
}
/// Accept flat lists as well as language groups containing release variants.
Map<String, List<SubtitleVariant>> subtitleGroups(
List<Map<String, dynamic>> rows,
) {
final result = <String, List<SubtitleVariant>>{};
final seen = <String>{};
String text(dynamic value) => value is Map
? '${value['name'] ?? value['title'] ?? value['code'] ?? ''}'
: value is String
? value
: '';
void visit(Map item, String inherited) {
final lang = text(item['language']).trim();
final language = lang.isEmpty ? inherited : lang;
final url = text(item['url'] ?? item['link']);
if (isRemoteMedia(url) && seen.add(url)) {
final label = text(item['title'] ?? item['name'] ?? item['quality']);
(result[language] ??= []).add(
SubtitleVariant(language, label.isEmpty ? 'Subtitle' : label, url),
);
}
for (final key in ['subtitles', 'variants', 'files', 'releases', 'items']) {
final children = item[key];
if (children is List) {
for (final child in children.whereType<Map>()) {
visit(child, language);
}
}
}
}
for (final item in rows) {
visit(item, 'Other');
}
return result;
}
Future<void> applyPlayerOptions(
AppSettings prefs, {
required Future<void> Function(double) rate,
required Future<void> Function(bool) audioOnly,
required Future<void> Function(double) volume,
bool Function()? current,
}) async {
if (current != null && !current()) return;
await rate(prefs.playbackSpeed);
if (current != null && !current()) return;
await audioOnly(prefs.audioOnly);
if (current != null && !current()) return;
if (prefs.rememberVolume) await volume(prefs.playerVolume);
}
AppSettings resetSubtitleSettings(AppSettings prefs) {
final defaults = AppSettings();
return prefs.copyWith(
subtitleEnabled: defaults.subtitleEnabled,
subtitleFontFamily: defaults.subtitleFontFamily,
subtitleFontSize: defaults.subtitleFontSize,
subtitleColor: defaults.subtitleColor,
subtitleBackground: defaults.subtitleBackground,
subtitleDelay: defaults.subtitleDelay,
);
}
AppSettings resetPlayerSettings(AppSettings prefs) {
final d = AppSettings();
return resetSubtitleSettings(prefs).copyWith(
playbackSpeed: d.playbackSpeed,
audioDelay: d.audioDelay,
videoFit: d.videoFit,
audioOnly: d.audioOnly,
playerVolume: d.playerVolume,
rememberVolume: d.rememberVolume,
backgroundAudio: d.backgroundAudio,
closeOnCompletion: d.closeOnCompletion,
resumeRewindSeconds: d.resumeRewindSeconds,
skipSeconds: d.skipSeconds,
resumePlayback: d.resumePlayback,
autoplay: d.autoplay,
autoNext: d.autoNext,
orientation: d.orientation,
externalPlayer: d.externalPlayer,
externalPlayerPackage: d.externalPlayerPackage,
);
}
String mediaContentType(String url) {
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
if (path.endsWith('.m3u8')) return 'application/x-mpegURL';
if (path.endsWith('.mpd')) return 'application/dash+xml';
if (path.endsWith('.webm')) return 'video/webm';
if (path.endsWith('.mkv')) return 'video/x-matroska';
return 'video/mp4';
}