import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// Exit checkpoint from the embedded Android LibVLC activity. `opened` means /// the decoder actually reached Playing, not merely that Android opened a view. class NativePlaybackResult { const NativePlaybackResult({ required this.position, required this.duration, required this.opened, required this.completed, }); final Duration position, duration; final bool opened, completed; factory NativePlaybackResult.fromMap(Map value) { int milliseconds(String key) { final raw = value[key]; // Duration stores microseconds in a signed 64-bit integer on Android. if (raw is! int || raw < 0 || raw > 0x7fffffffffffffff ~/ Duration.microsecondsPerMillisecond) { throw FormatException('Invalid native playback $key'); } return raw; } final position = milliseconds('positionMs'); final duration = milliseconds('durationMs'); if (value['opened'] is! bool || value['completed'] is! bool) { throw const FormatException('Invalid native playback flags'); } // An EOF flag alone must not advance the playlist. Validate the raw timing // before its display bounds are applied, including unknown-duration input. final atEnd = duration > 0 && position > 0 && position <= duration && duration - position <= (duration ~/ 50).clamp(1, 2000); return NativePlaybackResult( position: Duration( milliseconds: duration > 0 && position > duration ? duration : position, ), duration: Duration(milliseconds: duration), opened: value['opened'] as bool, completed: value['completed'] == true && value['opened'] == true && atEnd, ); } } /// Standalone native player, independent of the Flutter PlayerPage/decoder. /// /// The future resolves ONLY when the activity exits. Keep any offline loopback /// server lease alive until then; source URLs are forwarded byte-for-byte. /// Callers own history persistence, ID association and local lease cleanup. /// Validation/platform failures may throw. If Android cannot launch the activity, /// the result has `opened: false`; no path fabricates successful playback. class NativePlayerBridge { static const _channel = MethodChannel('org.reelbox/media'); static bool get supported => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; static Future open({ required String url, required String title, Duration position = Duration.zero, bool autoplay = true, String externalPackage = 'ask', String orientation = 'auto', String videoFit = 'contain', double speed = 1.0, bool subtitleEnabled = true, String? subtitleUrl, bool hardwareAcceleration = true, bool pipEnabled = true, }) async { if (!supported) { throw UnsupportedError('The embedded native player requires Android.'); } if (url.trim().isEmpty) throw ArgumentError.value(url, 'url'); if (!speed.isFinite || speed < .25 || speed > 3) { throw ArgumentError.value(speed, 'speed', 'Must be between 0.25 and 3'); } if (!const ['auto', 'portrait', 'landscape'].contains(orientation)) { throw ArgumentError.value(orientation, 'orientation'); } if (!const ['contain', 'cover', 'fill'].contains(videoFit)) { throw ArgumentError.value(videoFit, 'videoFit'); } final response = await _channel.invokeMapMethod( 'openNativePlayer', { 'url': url, 'title': title, 'positionMs': position.inMilliseconds < 0 ? 0 : position.inMilliseconds, 'autoplay': autoplay, 'externalPackage': externalPackage, 'orientation': orientation, 'videoFit': videoFit, 'speed': speed, 'subtitleEnabled': subtitleEnabled, 'subtitleUrl': ?subtitleUrl, 'hardwareAcceleration': hardwareAcceleration, 'pipEnabled': pipEnabled, }, ); if (response == null) { throw const FormatException('Native player returned no exit checkpoint'); } return NativePlaybackResult.fromMap(response); } }