diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart index e9bb0c5efe8..9f9c5190621 100644 --- a/mobile/lib/models/cast/cast_manager_state.dart +++ b/mobile/lib/models/cast/cast_manager_state.dart @@ -53,7 +53,7 @@ class CastManagerState { receiverName: map['receiverName'] ?? '', castState: map['castState'] ?? CastState.idle, currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0), - duration: Duration(seconds: map['duration']?.toInt() ?? 0)); + duration: Duration(seconds: map['duration']?.toInt() ?? 0),); } String toJson() => json.encode(toMap()); diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 957a119f66d..75eb278c5e5 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; @@ -60,6 +61,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { final log = Logger('NativeVideoViewerPage'); + final cast = ref.watch(castProvider); + Future createSource() async { if (!context.mounted) { return null; @@ -391,7 +394,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { // This remains under the video to avoid flickering // For motion videos, this is the image portion of the asset Center(key: ValueKey(asset.id), child: image), - if (aspectRatio.value != null) + if (aspectRatio.value != null && !cast.isCasting) Visibility.maintain( key: ValueKey(asset), visible: isVisible.value, diff --git a/mobile/lib/repositories/gcast.repository.dart b/mobile/lib/repositories/gcast.repository.dart index 4d6bb296d9e..ee3e721dabe 100644 --- a/mobile/lib/repositories/gcast.repository.dart +++ b/mobile/lib/repositories/gcast.repository.dart @@ -43,8 +43,7 @@ class GCastRepository { } Future disconnect() async { - final sessionID = - _receiverStatus?['status']['applications'][0]['sessionId']; + final sessionID = getSessionId(); sendMessage(CastSession.kNamespaceReceiver, { 'type': "STOP", @@ -54,6 +53,13 @@ class GCastRepository { await _castSession?.close(); } + String? getSessionId() { + if (_receiverStatus == null) { + return null; + } + return _receiverStatus!['status']['applications'][0]['sessionId']; + } + void sendMessage(String namespace, Map message) { if (_castSession == null) { throw Exception("Cast session is not established"); diff --git a/mobile/lib/repositories/sessions_api.repository.dart b/mobile/lib/repositories/sessions_api.repository.dart index 25efed1ee86..db3f57fffb1 100644 --- a/mobile/lib/repositories/sessions_api.repository.dart +++ b/mobile/lib/repositories/sessions_api.repository.dart @@ -20,7 +20,7 @@ class SessionsAPIRepository extends ApiRepository @override Future createSession( String deviceType, String deviceOS, - {int? duration}) async { + {int? duration,}) async { final dto = await checkNull( _api.createSession( SessionCreateDto( diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart index ed1d20c081a..430341c5eb2 100644 --- a/mobile/lib/services/gcast.service.dart +++ b/mobile/lib/services/gcast.service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cast/device.dart'; import 'package:cast/session.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -29,6 +31,9 @@ class GCastService implements ICastDestinationService { SessionCreateResponse? sessionKey; String? currentAssetId; bool isConnected = false; + int? _sessionId; + Timer? _mediaStatusPollingTimer; + CastState? castState; @override void Function(bool)? onConnectionState; @@ -62,6 +67,54 @@ class GCastService implements ICastDestinationService { void _onCastMessageCallback(Map message) { final msgType = message['type']; + + if (msgType == "MEDIA_STATUS") { + final statusList = (message['status'] as List) + .whereType>() + .toList(); + + if (statusList.isEmpty) { + return; + } + + final status = statusList[0]; + switch (status['playerState']) { + case "PLAYING": + onCastState?.call(CastState.playing); + break; + case "PAUSED": + onCastState?.call(CastState.paused); + break; + case "BUFFERING": + onCastState?.call(CastState.buffering); + break; + case "IDLE": + onCastState?.call(CastState.idle); + + // stop polling for media status if the video finished playing + if (status["idleReason"] == "FINISHED") { + _mediaStatusPollingTimer?.cancel(); + } + + break; + } + + if (status["media"] != null && status["media"]["duration"] != null) { + final duration = Duration( + milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt()); + onDuration?.call(duration); + } + + if (status["mediaSessionId"] != null) { + _sessionId = status["mediaSessionId"]; + } + + if (status["currentTime"] != null) { + final currentTime = + Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt()); + onCurrentTime?.call(currentTime); + } + } } Future connect(CastDevice device) async { @@ -161,21 +214,50 @@ class GCastService implements ICastDestinationService { }, "autoplay": true, }); + + // we need to poll for media status since the cast device does not + // send a message when the media is loaded for whatever reason + // only do this on videos + _mediaStatusPollingTimer?.cancel(); + + if (asset.isVideo) { + _mediaStatusPollingTimer = + Timer.periodic(const Duration(milliseconds: 500), (timer) { + if (isConnected) { + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "GET_STATUS", + "mediaSessionId": _sessionId, + }); + } else { + timer.cancel(); + } + }); + } } @override void play() { - // TODO: implement play + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "PLAY", + "mediaSessionId": _sessionId, + }); } @override void pause() { - // TODO: implement pause + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "PAUSE", + "mediaSessionId": _sessionId, + }); } @override void seekTo(Duration position) { - // TODO: implement seekTo + _gCastRepository.sendMessage(CastSession.kNamespaceMedia, { + "type": "SEEK", + "mediaSessionId": _sessionId, + "currentTime": position.inSeconds, + }); } @override diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index d759b0d80b3..6cae882ef78 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; @@ -25,6 +28,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final cast = ref.watch(castProvider); + // A timer to hide the controls final hideTimer = useTimer( hideTimerDuration, @@ -42,7 +47,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { } }, ); - final showBuffering = state == VideoPlaybackState.buffering; + final showBuffering = + state == VideoPlaybackState.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -59,12 +65,28 @@ class CustomVideoPlayerControls extends HookConsumerWidget { /// Toggles between playing and pausing depending on the state of the video void togglePlay() { showControlsAndStartHideTimer(); - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); + + if (cast.isCasting) { + if (cast.castState == CastState.playing) { + ref.read(castProvider.notifier).pause(); + } else if (cast.castState == CastState.paused) { + ref.read(castProvider.notifier).play(); + } else if (cast.castState == CastState.idle) { + // resend the play command since its finished + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } + ref.read(castProvider.notifier).loadMedia(asset, true); + } } else { - ref.read(videoPlayerControlsProvider.notifier).play(); + if (state == VideoPlaybackState.playing) { + ref.read(videoPlayerControlsProvider.notifier).pause(); + } else if (state == VideoPlaybackState.completed) { + ref.read(videoPlayerControlsProvider.notifier).restart(); + } else { + ref.read(videoPlayerControlsProvider.notifier).play(); + } } } @@ -89,7 +111,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget { backgroundColor: Colors.black54, iconColor: Colors.white, isFinished: state == VideoPlaybackState.completed, - isPlaying: state == VideoPlaybackState.playing, + isPlaying: state == VideoPlaybackState.playing || + (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index 4d0e7aa17f7..d5e5bea0965 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; class VideoPosition extends HookConsumerWidget { @@ -13,9 +14,16 @@ class VideoPosition extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final (position, duration) = ref.watch( - videoPlaybackValueProvider.select((v) => (v.position, v.duration)), - ); + final isCasting = ref.watch(castProvider).isCasting; + + final (position, duration) = isCasting + ? ref.watch( + castProvider.select((c) => (c.currentTime, c.duration)), + ) + : ref.watch( + videoPlaybackValueProvider.select((v) => (v.position, v.duration)), + ); + final wasPlaying = useRef(true); return duration == Duration.zero ? const _VideoPositionPlaceholder() @@ -57,15 +65,22 @@ class VideoPosition extends HookConsumerWidget { } }, onChanged: (value) { - final inSeconds = - (duration * (value / 100.0)).inSeconds; - final position = inSeconds.toDouble(); - ref - .read(videoPlayerControlsProvider.notifier) - .position = position; - // This immediately updates the slider position without waiting for the video to update - ref.read(videoPlaybackValueProvider.notifier).position = - Duration(seconds: inSeconds); + final seekToDuration = (duration * (value / 100.0)); + + if (isCasting) { + ref + .read(castProvider.notifier) + .seekTo(seekToDuration); + } else { + ref + .read(videoPlayerControlsProvider.notifier) + .position = seekToDuration.inSeconds.toDouble(); + + // This immediately updates the slider position without waiting for the video to update + ref + .read(videoPlaybackValueProvider.notifier) + .position = seekToDuration; + } }, ), ),