Initial commit
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user