import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart' show protected; import 'package:flutter/services.dart'; import '../../media/media_display_criteria.dart'; import '../../utils/app_logger.dart'; import '../../utils/track_label_builder.dart'; import '../font_loader.dart'; import '../models.dart'; import 'player.dart'; import 'player_state.dart'; import 'player_stream_controllers.dart'; import 'player_streams.dart'; /// Abstract base class for player implementations. /// /// This class contains shared logic for both [PlayerAndroid] (ExoPlayer) /// and [PlayerNative] (MPV) implementations, including: /// - State management /// - Stream controller setup /// - Event handling infrastructure /// - Property change handlers /// - Track parsing and selection /// - Common lifecycle methods abstract class PlayerBase with PlayerStreamControllersMixin implements Player { PlayerState _state = const PlayerState(); @override PlayerState get state => _state; late final PlayerStreams _streams; @override PlayerStreams get streams => _streams; @override int? get textureId => null; StreamSubscription? _eventSubscription; StreamSubscription? _logSubscription; bool _disposed = false; final _throttleSw = Stopwatch()..start(); int _lastEmitMs = 0; int _lastCacheStateMs = 0; int _positionMs = 0; Duration _timelineOffset = Duration.zero; Duration? _timelineDuration; int _nextPropId = 0; final Map _propIdToName = {}; @protected bool initialized = false; @override bool get disposed => _disposed; MethodChannel get methodChannel; EventChannel get eventChannel; String get logPrefix; PlayerBase() { _streams = createStreams(); _setupEventListener(); _logSubscription = logController.stream.listen(_forwardToAppLogger); } void _forwardToAppLogger(PlayerLog log) { final message = '[$logPrefix:${log.prefix}] ${log.text}'.trimRight(); switch (log.level) { case PlayerLogLevel.fatal: case PlayerLogLevel.error: appLogger.e(message); case PlayerLogLevel.warn: appLogger.w(message); case PlayerLogLevel.info: case PlayerLogLevel.verbose: appLogger.i(message); case PlayerLogLevel.debug: case PlayerLogLevel.trace: appLogger.d(message); case PlayerLogLevel.none: break; } } void _setupEventListener() { _eventSubscription = eventChannel.receiveBroadcastStream().listen( _handleEvent, onError: (error) { if (_disposed) return; errorController.add(PlayerError(error.toString())); }, ); } @protected Future observeProperty(String name, String format) async { final propId = _nextPropId++; _propIdToName[propId] = name; await invoke('observeProperty', {'name': name, 'format': format, 'id': propId}); } void _handleEvent(dynamic event) { if (_disposed) return; if (event is List && event.length == 2) { final name = _propIdToName[event.first as int]; if (name != null) { handlePropertyChange(name, event[1]); } } else if (event is Map) { final type = event['type'] as String?; final name = event['name'] as String?; if (type == 'event' && name != null) { handlePlayerEvent(name, event['data'] as Map?); } } } void handlePropertyChange(String name, dynamic value) { if (_disposed) return; switch (name) { case 'pause': final playing = value == false; _state = _state.copyWith(playing: playing); playingController.add(playing); break; case 'eof-reached': final completed = value == true; _state = _state.copyWith(completed: completed); completedController.add(completed); break; case 'paused-for-cache': final buffering = value == true; _state = _state.copyWith(buffering: buffering); bufferingController.add(buffering); break; case 'time-pos': if (value is num) { final pos = _toTimelinePosition(Duration(milliseconds: (value * 1000).round())); _positionMs = pos.inMilliseconds; // Only allocate Duration + copyWith + emit at ~4Hz (250ms). // Raw int is stored every tick so synchronous reads via _positionMs stay current. final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastEmitMs >= 250) { _lastEmitMs = nowMs; _state = _state.copyWith(position: pos); positionController.add(pos); } } break; case 'duration': if (value is num) { final duration = _timelineDuration ?? _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt())); _state = _state.copyWith(duration: duration); durationController.add(duration); } break; case 'seekable': if (value is bool) { setSeekable(value); } break; case 'demuxer-cache-time': if (value is num) { final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) break; _lastCacheStateMs = nowMs; final buffer = _toTimelinePosition(Duration(milliseconds: (value * 1000).toInt())); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); // Synthesize a single range for players without demuxer-cache-state (ExoPlayer). // ExoPlayer only buffers ahead of the current position, so use position as start. final ranges = [BufferRange(start: _state.position, end: buffer)]; _state = _state.copyWith(bufferRanges: ranges); bufferRangesController.add(ranges); } break; case 'demuxer-cache-state': _handleDemuxerCacheState(value); break; case 'volume': if (value is num) { setVolumeState(value.toDouble()); } break; case 'speed': if (value is num) { final rate = value.toDouble(); _state = _state.copyWith(rate: rate); rateController.add(rate); } break; case 'track-list': List? trackList; if (value is List) { trackList = value; } else if (value is String && value.isNotEmpty) { try { final parsed = jsonDecode(value); if (parsed is List) trackList = parsed; } catch (e) { appLogger.d('Player: track-list parse failed', error: e); } } if (trackList != null) { final result = parseTrackList(trackList); _state = _state.copyWith(tracks: result.tracks); tracksController.add(result.tracks); // Derive selection from mpv's "selected" field in the track data. // This is the source of truth and handles cases where aid/sid // values don't match track IDs (e.g. "auto", "0", "no"). if (result.selectedAudioId != null) { updateSelectedAudioTrack(result.selectedAudioId); } if (result.selectedSubtitleId != null) { updateSelectedSubtitleTrack(result.selectedSubtitleId); } } break; case 'aid': updateSelectedAudioTrack(value); break; case 'sid': updateSelectedSubtitleTrack(value); break; case 'secondary-sid': updateSelectedSecondarySubtitleTrack(value); break; case 'audio-device-list': List? deviceList; if (value is List) { deviceList = value; } else if (value is String && value.isNotEmpty) { try { final parsed = jsonDecode(value); if (parsed is List) deviceList = parsed; } catch (e) { appLogger.d('Player: device-list parse failed', error: e); } } if (deviceList != null) { final devices = deviceList .whereType() .map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? '')) .toList(); _state = _state.copyWith(audioDevices: devices); audioDevicesController.add(devices); } break; case 'audio-device': if (value is String && value.isNotEmpty) { final device = _state.audioDevices.firstWhereOrNull((d) => d.name == value) ?? AudioDevice(name: value); _state = _state.copyWith(audioDevice: device); audioDeviceController.add(device); } break; } } /// Parse demuxer-cache-state property to extract seekable ranges and buffer end. void _handleDemuxerCacheState(dynamic value) { Map? cacheState; if (value is Map) { cacheState = value; } else if (value is String && value.isNotEmpty) { // Throttle JSON parsing to avoid ANR on low-end devices final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) return; _lastCacheStateMs = nowMs; try { final parsed = jsonDecode(value); if (parsed is Map) cacheState = parsed; } catch (_) {} } if (cacheState == null) return; // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) final cacheEnd = cacheState['cache-end'] as num?; if (cacheEnd != null) { final buffer = _toTimelinePosition(Duration(milliseconds: (cacheEnd * 1000).toInt())); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); } // Extract seekable-ranges array final seekableRanges = cacheState['seekable-ranges']; if (seekableRanges is List) { final ranges = []; for (final range in seekableRanges) { if (range is Map) { final start = range['start'] as num?; final end = range['end'] as num?; if (start != null && end != null) { ranges.add( BufferRange( start: _toTimelinePosition(Duration(milliseconds: (start * 1000).toInt())), end: _toTimelinePosition(Duration(milliseconds: (end * 1000).toInt())), ), ); } } } _state = _state.copyWith(bufferRanges: ranges); bufferRangesController.add(ranges); } } void handlePlayerEvent(String name, Map? data) { if (_disposed) return; switch (name) { case 'end-file': setSeekable(false); final rawReason = data?['reason']; final reason = switch (rawReason) { 0 => 'eof', 2 => 'stop', 3 => 'quit', 4 => 'error', 5 => 'redirect', final String s => s, _ => null, }; if (reason == 'eof') { _state = _state.copyWith(completed: true); completedController.add(true); } else if (reason == 'error') { errorController.add( PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?), ); } break; case 'file-loaded': _state = _state.copyWith(completed: false); completedController.add(false); break; case 'playback-restart': playbackRestartController.add(null); break; case 'log-message': final prefix = data?['prefix'] as String? ?? ''; final levelStr = data?['level'] as String? ?? 'info'; final text = data?['text'] as String? ?? ''; final level = parseLogLevel(levelStr); logController.add(PlayerLog(level: level, prefix: prefix, text: text)); break; } } PlayerLogLevel parseLogLevel(String level) { return switch (level) { 'fatal' => PlayerLogLevel.fatal, 'error' => PlayerLogLevel.error, 'warn' => PlayerLogLevel.warn, 'info' => PlayerLogLevel.info, 'v' || 'verbose' => PlayerLogLevel.verbose, 'debug' => PlayerLogLevel.debug, 'trace' => PlayerLogLevel.trace, _ => PlayerLogLevel.info, }; } ({Tracks tracks, String? selectedAudioId, String? selectedSubtitleId}) parseTrackList(List trackList) { final audioTracks = []; final subtitleTracks = []; String? selectedAudioId; String? selectedSubtitleId; for (final track in trackList) { if (track is! Map) continue; final type = track['type'] as String?; final id = track['id']?.toString() ?? ''; final selected = track['selected'] as bool? ?? false; if (type == 'audio') { if (selected) selectedAudioId = id; audioTracks.add( AudioTrack( id: id, title: cleanTrackMetadataValue(track['title'] as String?), language: cleanTrackMetadataValue(track['lang'] as String?), codec: track['codec'] as String?, channels: (track['demux-channel-count'] as num?)?.toInt(), sampleRate: (track['demux-samplerate'] as num?)?.toInt(), isDefault: track['default'] as bool? ?? false, ), ); } else if (type == 'sub') { if (selected) selectedSubtitleId = id; final codec = track['codec'] as String?; subtitleTracks.add( SubtitleTrack( id: id, title: cleanSubtitleTitle(track['title'] as String?, codec: codec), language: cleanTrackMetadataValue(track['lang'] as String?), codec: codec, isDefault: track['default'] as bool? ?? false, isForced: track['forced'] as bool? ?? false, isExternal: track['external'] as bool? ?? false, uri: track['external-filename'] as String?, ), ); } } return ( tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks), selectedAudioId: selectedAudioId, selectedSubtitleId: selectedSubtitleId, ); } void updateSelectedAudioTrack(dynamic trackId) { final id = trackId?.toString(); AudioTrack? selectedTrack; if (id != null && id != 'no') { selectedTrack = _state.tracks.audio.firstWhereOrNull((t) => t.id == id); } if (selectedTrack == null) return; _state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack)); trackController.add(_state.track); } void updateSelectedSubtitleTrack(dynamic trackId) { final id = trackId?.toString(); final selectedTrack = (id == null || id == 'no') ? SubtitleTrack.off : _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id); if (selectedTrack == null) return; _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); trackController.add(_state.track); } void updateSelectedSecondarySubtitleTrack(dynamic trackId) { final id = trackId?.toString(); SubtitleTrack? selectedTrack; if (id == null || id == 'no') { selectedTrack = null; } else { selectedTrack = _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id); } _state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack)); trackController.add(_state.track); } @protected void clearTracks() { const empty = Tracks(); _state = _state.copyWith(tracks: empty, track: const TrackSelection()); tracksController.add(empty); } @protected void setVolumeState(double volume) { if (_state.volume == volume) return; _state = _state.copyWith(volume: volume); volumeController.add(volume); } @protected void setSeekable(bool seekable) { if (_state.seekable == seekable) return; _state = _state.copyWith(seekable: seekable); seekableController.add(seekable); } @protected void configureTimeline({Duration offset = Duration.zero, Duration? duration}) { _timelineOffset = offset; _timelineDuration = duration; } @protected Duration sourceSeekPosition(Duration timelinePosition) { final sourcePosition = timelinePosition - _timelineOffset; return sourcePosition.isNegative ? Duration.zero : sourcePosition; } Duration _toTimelinePosition(Duration sourcePosition) { return sourcePosition + _timelineOffset; } @protected void resetPlaybackProgress(Duration sourcePosition) { final position = _toTimelinePosition(sourcePosition); _positionMs = position.inMilliseconds; _state = _state.copyWith( completed: false, position: position, duration: _timelineDuration ?? Duration.zero, buffer: Duration.zero, bufferRanges: const [], ); completedController.add(false); positionController.add(position); durationController.add(_timelineDuration ?? Duration.zero); bufferController.add(Duration.zero); bufferRangesController.add(const []); } @protected Future invoke(String method, [dynamic args]) async { if (_disposed) return null; return methodChannel.invokeMethod(method, args); } @override Future playOrPause() async { if (_disposed) return; if (_state.playing) { await pause(); } else { await play(); } } @override Future setDisplayCriteria(MediaDisplayCriteria? criteria) async {} @override Future setVisible(bool visible, {bool restoreOnWindowVisible = false}) async { if (_disposed) return false; try { await invoke('setVisible', {'visible': visible, 'restoreOnWindowVisible': restoreOnWindowVisible}); return true; } catch (e) { errorController.add(PlayerError('Failed to set visibility: $e')); return false; } } @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future updateFrame() async {} @override Future setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async => false; @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future clearVideoFrameRate() async {} @override Future requestAudioFocus() async { // Default returns true, overridden by Android return true; } @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future abandonAudioFocus() async {} @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioDevice(AudioDevice device) async {} @override bool get supportsSecondarySubtitles => true; @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future selectSecondarySubtitleTrack(SubtitleTrack track) async {} @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioPassthrough(bool enabled) async {} @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setLogLevel(String level) async {} @override Future configureSubtitleFonts() async { try { final fontDir = await SubtitleFontLoader.loadSubtitleFont(); if (fontDir != null) { await setProperty('sub-fonts-dir', fontDir); await setProperty('sub-font', SubtitleFontLoader.fontName); } } catch (e) { // Font configuration is not critical - continue without it logController.add( PlayerLog(prefix: 'fonts', level: PlayerLogLevel.warn, text: 'Failed to configure subtitle fonts: $e'), ); } } void _setPlaybackPosition(Duration position) { _positionMs = position.inMilliseconds; _state = _state.copyWith(position: position); positionController.add(position); } /// Run a backend-specific seek call, swallowing the common "not ready" errors /// the native channel throws when the engine was torn down mid-seek. @protected Future runSeek(Duration position, Future Function() seekFn) async { if (_disposed) return; final previousPosition = Duration(milliseconds: _positionMs); _setPlaybackPosition(position); void rollbackPosition() { // Avoid overwriting a newer native position update if one arrived while // the platform seek was in flight. if (_positionMs == position.inMilliseconds) { _setPlaybackPosition(previousPosition); } } try { await seekFn(); } on PlatformException catch (e) { if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') { rollbackPosition(); appLogger.w('Seek failed (${e.code}), player not ready'); return; } rollbackPosition(); rethrow; } catch (_) { rollbackPosition(); rethrow; } } /// Injects the log + error events that would fire when the server rejects the /// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by /// the in-player debug button to preview the end-to-end detection path /// without needing a real misbehaving server. void debugSimulateServer500() { if (_disposed) return; logController.add( const PlayerLog( level: PlayerLogLevel.warn, prefix: 'ffmpeg', text: 'https: HTTP error 500 Internal Server Error', ), ); errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500)); } @override Future dispose() async { if (_disposed) return; _disposed = true; await _eventSubscription?.cancel(); await _logSubscription?.cancel(); await methodChannel.invokeMethod('dispose'); // Direct call — already guarded by _disposed check above await closeStreamControllers(); } }