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 headers; final String title; final int? durationSeconds; final String? seriesName; final int? episode; const DirectStreamLaunchData({ required this.url, this.headers = const {}, required this.title, this.durationSeconds, this.seriesName, this.episode, }); } class PlayerLauncher { static const _tag = 'PlayerLauncher'; static Future 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? 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 = {}; 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.landscapeLeft, DeviceOrientation.landscapeRight, ]); if (!context.mounted) return null; await _waitForAndroidLandscape(playerView); if (!context.mounted) return null; } final playerResult = context.push( uri.toString(), extra: directStream, ); if (androidLaunchCover != null) { await WidgetsBinding.instance.endOfFrame; removeAndroidLaunchCover(); } return await playerResult; } finally { removeAndroidLaunchCover(); if (Platform.isAndroid) { await SystemChrome.setPreferredOrientations( const [], ); } } } 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 _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.delayed(pollInterval); } } }