180 lines
5.3 KiB
Dart
180 lines
5.3 KiB
Dart
|
|
import 'dart:io';
|
||
|
|
import 'dart:ui' as ui;
|
||
|
|
|
||
|
|
import 'package:flutter/services.dart';
|
||
|
|
import 'package:flutter/widgets.dart';
|
||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
import 'package:go_router/go_router.dart';
|
||
|
|
|
||
|
|
import '../../shared/utils/app_logger.dart';
|
||
|
|
import 'playlist/cross_server_playlist.dart';
|
||
|
|
import 'playlist/pending_cross_server_sequence_provider.dart';
|
||
|
|
|
||
|
|
|
||
|
|
class PlayerExitResult {
|
||
|
|
final int? positionTicks;
|
||
|
|
final String serverId;
|
||
|
|
final String itemId;
|
||
|
|
final String? mediaSourceId;
|
||
|
|
|
||
|
|
const PlayerExitResult({
|
||
|
|
required this.positionTicks,
|
||
|
|
required this.serverId,
|
||
|
|
required this.itemId,
|
||
|
|
required this.mediaSourceId,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class DirectStreamLaunchData {
|
||
|
|
final String url;
|
||
|
|
final Map<String, String> headers;
|
||
|
|
final String title;
|
||
|
|
final int? durationSeconds;
|
||
|
|
final String? seriesName;
|
||
|
|
final int? episode;
|
||
|
|
|
||
|
|
const DirectStreamLaunchData({
|
||
|
|
required this.url,
|
||
|
|
this.headers = const <String, String>{},
|
||
|
|
required this.title,
|
||
|
|
this.durationSeconds,
|
||
|
|
this.seriesName,
|
||
|
|
this.episode,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
class PlayerLauncher {
|
||
|
|
static const _tag = 'PlayerLauncher';
|
||
|
|
|
||
|
|
|
||
|
|
static Future<PlayerExitResult?> launch({
|
||
|
|
required BuildContext context,
|
||
|
|
required String itemId,
|
||
|
|
String? serverId,
|
||
|
|
String? mediaSourceId,
|
||
|
|
int preferredSubtitleStreamIndex = -1,
|
||
|
|
int? preferredAudioStreamIndex,
|
||
|
|
int? startPositionTicks,
|
||
|
|
bool fromStart = false,
|
||
|
|
String? backdropUrl,
|
||
|
|
WidgetRef? ref,
|
||
|
|
List<CrossServerPlaylistEntry>? crossServerSequence,
|
||
|
|
DirectStreamLaunchData? directStream,
|
||
|
|
}) async {
|
||
|
|
if (crossServerSequence != null && crossServerSequence.isNotEmpty) {
|
||
|
|
assert(
|
||
|
|
ref != null,
|
||
|
|
'PlayerLauncher.launch: ref is required when crossServerSequence is provided',
|
||
|
|
);
|
||
|
|
ref!.read(pendingCrossServerSequenceProvider.notifier).state =
|
||
|
|
List.unmodifiable(crossServerSequence);
|
||
|
|
}
|
||
|
|
AppLogger.debug(
|
||
|
|
_tag,
|
||
|
|
'launch serverId=$serverId itemId=$itemId mediaSourceId=$mediaSourceId '
|
||
|
|
'subtitleIndex=$preferredSubtitleStreamIndex '
|
||
|
|
'audioIndex=$preferredAudioStreamIndex '
|
||
|
|
'startPositionTicks=$startPositionTicks '
|
||
|
|
'fromStart=$fromStart '
|
||
|
|
'hasBackdrop=${backdropUrl?.isNotEmpty ?? false} '
|
||
|
|
'directStream=${directStream != null} '
|
||
|
|
'crossServerSequence=${crossServerSequence?.length ?? 0}',
|
||
|
|
);
|
||
|
|
|
||
|
|
final queryParams = <String, String>{};
|
||
|
|
if (serverId != null && serverId.isNotEmpty) {
|
||
|
|
queryParams['serverId'] = serverId;
|
||
|
|
}
|
||
|
|
if (mediaSourceId != null) queryParams['mediaSourceId'] = mediaSourceId;
|
||
|
|
if (backdropUrl != null && backdropUrl.isNotEmpty) {
|
||
|
|
queryParams['backdropUrl'] = backdropUrl;
|
||
|
|
}
|
||
|
|
queryParams['subtitleIndex'] = '$preferredSubtitleStreamIndex';
|
||
|
|
if (preferredAudioStreamIndex != null) {
|
||
|
|
queryParams['audioIndex'] = '$preferredAudioStreamIndex';
|
||
|
|
}
|
||
|
|
if (fromStart) {
|
||
|
|
queryParams['fromStart'] = '1';
|
||
|
|
} else if (startPositionTicks != null && startPositionTicks > 0) {
|
||
|
|
queryParams['startPositionTicks'] = '$startPositionTicks';
|
||
|
|
}
|
||
|
|
final uri = Uri(
|
||
|
|
path: '/player/$itemId',
|
||
|
|
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||
|
|
);
|
||
|
|
AppLogger.debug(_tag, 'navigating to: ${uri.toString()}');
|
||
|
|
|
||
|
|
OverlayEntry? androidLaunchCover;
|
||
|
|
void removeAndroidLaunchCover() {
|
||
|
|
final launchCover = androidLaunchCover;
|
||
|
|
androidLaunchCover = null;
|
||
|
|
launchCover?.remove();
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (Platform.isAndroid) {
|
||
|
|
final playerView = View.of(context);
|
||
|
|
androidLaunchCover = _insertAndroidLaunchCover(context);
|
||
|
|
if (androidLaunchCover != null) {
|
||
|
|
await WidgetsBinding.instance.endOfFrame;
|
||
|
|
if (!context.mounted) return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[
|
||
|
|
DeviceOrientation.landscapeLeft,
|
||
|
|
DeviceOrientation.landscapeRight,
|
||
|
|
]);
|
||
|
|
if (!context.mounted) return null;
|
||
|
|
|
||
|
|
await _waitForAndroidLandscape(playerView);
|
||
|
|
if (!context.mounted) return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
final playerResult = context.push<PlayerExitResult>(
|
||
|
|
uri.toString(),
|
||
|
|
extra: directStream,
|
||
|
|
);
|
||
|
|
if (androidLaunchCover != null) {
|
||
|
|
await WidgetsBinding.instance.endOfFrame;
|
||
|
|
removeAndroidLaunchCover();
|
||
|
|
}
|
||
|
|
return await playerResult;
|
||
|
|
} finally {
|
||
|
|
removeAndroidLaunchCover();
|
||
|
|
if (Platform.isAndroid) {
|
||
|
|
await SystemChrome.setPreferredOrientations(
|
||
|
|
const <DeviceOrientation>[],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
static OverlayEntry? _insertAndroidLaunchCover(BuildContext context) {
|
||
|
|
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||
|
|
if (overlay == null) return null;
|
||
|
|
|
||
|
|
final launchCover = OverlayEntry(
|
||
|
|
builder: (context) => const Positioned.fill(
|
||
|
|
child: IgnorePointer(child: ColoredBox(color: Color(0xFF08090C))),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
overlay.insert(launchCover);
|
||
|
|
return launchCover;
|
||
|
|
}
|
||
|
|
|
||
|
|
static Future<void> _waitForAndroidLandscape(
|
||
|
|
ui.FlutterView playerView,
|
||
|
|
) async {
|
||
|
|
const pollInterval = Duration(milliseconds: 16);
|
||
|
|
final deadline = DateTime.now().add(const Duration(milliseconds: 500));
|
||
|
|
|
||
|
|
while (true) {
|
||
|
|
final windowSize = playerView.physicalSize;
|
||
|
|
if (windowSize.width > windowSize.height) return;
|
||
|
|
if (DateTime.now().isAfter(deadline)) return;
|
||
|
|
await Future<void>.delayed(pollInterval);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|