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? _systemTail; static PlaybackLifecycle? _systemOwner; bool get ownsSystem => _active && identical(_systemOwner, this); bool get _active => !_disposed && (isMounted?.call() ?? true); static Future _serialize(Future 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.microtask(action) : previous.then((_) => action()); late final Future tail; tail = work .then((_) {}, onError: (Object _, StackTrace _) {}) .whenComplete(() { if (identical(_systemTail, tail)) _systemTail = null; }); _systemTail = tail; return work; } Future setup(List 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 open({ required Future Function() save, required Future 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 dispose(Future 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 dispatchMediaOpen({ required Future 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 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 sources, { String preferredQuality = '', }) { final options = playableSources(sources); if (options.isEmpty) return null; return options.firstWhere( (s) => s.quality == preferredQuality, orElse: () => options.first, ); } List playableSources(Iterable sources) { final seen = {}; 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 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> subtitleGroups( List> rows, ) { final result = >{}; final seen = {}; 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()) { visit(child, language); } } } } for (final item in rows) { visit(item, 'Other'); } return result; } Future applyPlayerOptions( AppSettings prefs, { required Future Function(double) rate, required Future Function(bool) audioOnly, required Future 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'; }