Files

180 lines
5.3 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
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);
}
}
}