cast video player finalized

This commit is contained in:
bwees 2025-05-21 10:36:44 -05:00
parent 8a31c68ddd
commit 93d59cd41a
No known key found for this signature in database
7 changed files with 156 additions and 27 deletions

View File

@ -53,7 +53,7 @@ class CastManagerState {
receiverName: map['receiverName'] ?? '', receiverName: map['receiverName'] ?? '',
castState: map['castState'] ?? CastState.idle, castState: map['castState'] ?? CastState.idle,
currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0), 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()); String toJson() => json.encode(toMap());

View File

@ -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/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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
@ -60,6 +61,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
final log = Logger('NativeVideoViewerPage'); final log = Logger('NativeVideoViewerPage');
final cast = ref.watch(castProvider);
Future<VideoSource?> createSource() async { Future<VideoSource?> createSource() async {
if (!context.mounted) { if (!context.mounted) {
return null; return null;
@ -391,7 +394,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
// This remains under the video to avoid flickering // This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset // For motion videos, this is the image portion of the asset
Center(key: ValueKey(asset.id), child: image), Center(key: ValueKey(asset.id), child: image),
if (aspectRatio.value != null) if (aspectRatio.value != null && !cast.isCasting)
Visibility.maintain( Visibility.maintain(
key: ValueKey(asset), key: ValueKey(asset),
visible: isVisible.value, visible: isVisible.value,

View File

@ -43,8 +43,7 @@ class GCastRepository {
} }
Future<void> disconnect() async { Future<void> disconnect() async {
final sessionID = final sessionID = getSessionId();
_receiverStatus?['status']['applications'][0]['sessionId'];
sendMessage(CastSession.kNamespaceReceiver, { sendMessage(CastSession.kNamespaceReceiver, {
'type': "STOP", 'type': "STOP",
@ -54,6 +53,13 @@ class GCastRepository {
await _castSession?.close(); await _castSession?.close();
} }
String? getSessionId() {
if (_receiverStatus == null) {
return null;
}
return _receiverStatus!['status']['applications'][0]['sessionId'];
}
void sendMessage(String namespace, Map<String, dynamic> message) { void sendMessage(String namespace, Map<String, dynamic> message) {
if (_castSession == null) { if (_castSession == null) {
throw Exception("Cast session is not established"); throw Exception("Cast session is not established");

View File

@ -20,7 +20,7 @@ class SessionsAPIRepository extends ApiRepository
@override @override
Future<SessionCreateResponse> createSession( Future<SessionCreateResponse> createSession(
String deviceType, String deviceOS, String deviceType, String deviceOS,
{int? duration}) async { {int? duration,}) async {
final dto = await checkNull( final dto = await checkNull(
_api.createSession( _api.createSession(
SessionCreateDto( SessionCreateDto(

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:cast/device.dart'; import 'package:cast/device.dart';
import 'package:cast/session.dart'; import 'package:cast/session.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -29,6 +31,9 @@ class GCastService implements ICastDestinationService {
SessionCreateResponse? sessionKey; SessionCreateResponse? sessionKey;
String? currentAssetId; String? currentAssetId;
bool isConnected = false; bool isConnected = false;
int? _sessionId;
Timer? _mediaStatusPollingTimer;
CastState? castState;
@override @override
void Function(bool)? onConnectionState; void Function(bool)? onConnectionState;
@ -62,6 +67,54 @@ class GCastService implements ICastDestinationService {
void _onCastMessageCallback(Map<String, dynamic> message) { void _onCastMessageCallback(Map<String, dynamic> message) {
final msgType = message['type']; final msgType = message['type'];
if (msgType == "MEDIA_STATUS") {
final statusList = (message['status'] as List)
.whereType<Map<String, dynamic>>()
.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<void> connect(CastDevice device) async { Future<void> connect(CastDevice device) async {
@ -161,21 +214,50 @@ class GCastService implements ICastDestinationService {
}, },
"autoplay": true, "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 @override
void play() { void play() {
// TODO: implement play _gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "PLAY",
"mediaSessionId": _sessionId,
});
} }
@override @override
void pause() { void pause() {
// TODO: implement pause _gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "PAUSE",
"mediaSessionId": _sessionId,
});
} }
@override @override
void seekTo(Duration position) { void seekTo(Duration position) {
// TODO: implement seekTo _gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "SEEK",
"mediaSessionId": _sessionId,
"currentTime": position.inSeconds,
});
} }
@override @override

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
@ -25,6 +28,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
final VideoPlaybackState state = final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider.select((value) => value.state)); ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final cast = ref.watch(castProvider);
// A timer to hide the controls // A timer to hide the controls
final hideTimer = useTimer( final hideTimer = useTimer(
hideTimerDuration, 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 /// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() { void showControlsAndStartHideTimer() {
@ -59,12 +65,28 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
/// Toggles between playing and pausing depending on the state of the video /// Toggles between playing and pausing depending on the state of the video
void togglePlay() { void togglePlay() {
showControlsAndStartHideTimer(); showControlsAndStartHideTimer();
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause(); if (cast.isCasting) {
} else if (state == VideoPlaybackState.completed) { if (cast.castState == CastState.playing) {
ref.read(videoPlayerControlsProvider.notifier).restart(); 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 { } 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, backgroundColor: Colors.black54,
iconColor: Colors.white, iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed, isFinished: state == VideoPlaybackState.completed,
isPlaying: state == VideoPlaybackState.playing, isPlaying: state == VideoPlaybackState.playing ||
(cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls, show: assetIsVideo && showControls,
onPressed: togglePlay, onPressed: togglePlay,
), ),

View File

@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.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_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget { class VideoPosition extends HookConsumerWidget {
@ -13,9 +14,16 @@ class VideoPosition extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final (position, duration) = ref.watch( final isCasting = ref.watch(castProvider).isCasting;
videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
); 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<bool>(true); final wasPlaying = useRef<bool>(true);
return duration == Duration.zero return duration == Duration.zero
? const _VideoPositionPlaceholder() ? const _VideoPositionPlaceholder()
@ -57,15 +65,22 @@ class VideoPosition extends HookConsumerWidget {
} }
}, },
onChanged: (value) { onChanged: (value) {
final inSeconds = final seekToDuration = (duration * (value / 100.0));
(duration * (value / 100.0)).inSeconds;
final position = inSeconds.toDouble(); if (isCasting) {
ref ref
.read(videoPlayerControlsProvider.notifier) .read(castProvider.notifier)
.position = position; .seekTo(seekToDuration);
// This immediately updates the slider position without waiting for the video to update } else {
ref.read(videoPlaybackValueProvider.notifier).position = ref
Duration(seconds: inSeconds); .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;
}
}, },
), ),
), ),