115 lines
4.1 KiB
Dart
115 lines
4.1 KiB
Dart
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<dynamic, dynamic> 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<NativePlaybackResult> 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<dynamic, dynamic>(
|
|
'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);
|
|
}
|
|
}
|