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'] ?? '',
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());

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/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<VideoSource?> 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,

View File

@ -43,8 +43,7 @@ class GCastRepository {
}
Future<void> 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<String, dynamic> message) {
if (_castSession == null) {
throw Exception("Cast session is not established");

View File

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

View File

@ -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<String, dynamic> message) {
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 {
@ -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

View File

@ -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,
),

View File

@ -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<bool>(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;
}
},
),
),