immich/mobile/lib/services/gcast.service.dart
2025-05-21 11:08:15 -05:00

283 lines
7.8 KiB
Dart

import 'dart:async';
import 'package:cast/device.dart';
import 'package:cast/session.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/gcast.repository.dart';
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:openapi/api.dart';
final gCastServiceProvider = Provider(
(ref) => GCastService(
ref.watch(gCastRepositoryProvider),
ref.watch(sessionsAPIRepositoryProvider),
ref.watch(assetApiRepositoryProvider),
),
);
class GCastService implements ICastDestinationService {
final GCastRepository _gCastRepository;
final SessionsAPIRepository _sessionsApiService;
final AssetApiRepository _assetApiRepository;
SessionCreateResponse? sessionKey;
String? currentAssetId;
bool isConnected = false;
int? _sessionId;
Timer? _mediaStatusPollingTimer;
CastState? castState;
@override
void Function(bool)? onConnectionState;
@override
void Function(Duration)? onCurrentTime;
@override
void Function(Duration)? onDuration;
@override
void Function(String)? onReceiverName;
@override
void Function(CastState)? onCastState;
GCastService(
this._gCastRepository,
this._sessionsApiService,
this._assetApiRepository,
) {
_gCastRepository.onCastStatus = _onCastStatusCallback;
_gCastRepository.onCastMessage = _onCastMessageCallback;
}
void _onCastStatusCallback(CastSessionState state) {
if (state == CastSessionState.connected) {
onConnectionState?.call(true);
isConnected = true;
} else if (state == CastSessionState.closed) {
onConnectionState?.call(false);
isConnected = false;
}
}
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 {
await _gCastRepository.connect(device);
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
}
@override
CastDestinationType getType() {
return CastDestinationType.googleCast;
}
@override
Future<bool> initialize() async {
// check if server URL is https
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
return serverUrl?.startsWith("https://") ?? false;
}
@override
void disconnect() {
_gCastRepository.disconnect();
onReceiverName?.call("");
}
@override
bool isAvailable() {
// check if server URL is https
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
return serverUrl?.startsWith("https://") ?? false;
}
bool isSessionValid() {
// check if we already have a session token
// we should always have a expiration date
if (sessionKey == null || sessionKey?.expiresAt == null) {
return false;
}
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
// we want to make sure we have at least 10 seconds remaining in the session
// this is to account for network latency and other delays when sending the request
final bufferedExpiration =
tokenExpiration.subtract(const Duration(seconds: 10));
return bufferedExpiration.isAfter(DateTime.now());
}
@override
void loadMedia(Asset asset, bool reload) async {
if (!isConnected) {
return;
} else if (asset.remoteId == null) {
return;
} else if (asset.remoteId == currentAssetId && !reload) {
return;
}
// create a session key
if (!isSessionValid()) {
sessionKey = await _sessionsApiService.createSession(
"Cast",
"Google Cast",
duration: const Duration(minutes: 15).inSeconds,
);
}
final unauthenticatedUrl = asset.isVideo
? getPlaybackUrlForRemoteId(
asset.remoteId!,
)
: getThumbnailUrlForRemoteId(
asset.remoteId!,
type: AssetMediaSize.fullsize,
);
final authenticatedURL =
"$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type
final mimeType =
await _assetApiRepository.getAssetMIMEType(asset.remoteId!);
if (mimeType == null) {
return;
}
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "LOAD",
"media": {
"contentId": authenticatedURL,
"streamType": "BUFFERED",
"contentType": mimeType,
"contentUrl": authenticatedURL,
},
"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() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "PLAY",
"mediaSessionId": _sessionId,
});
}
@override
void pause() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "PAUSE",
"mediaSessionId": _sessionId,
});
}
@override
void seekTo(Duration position) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "SEEK",
"mediaSessionId": _sessionId,
"currentTime": position.inSeconds,
});
}
@override
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
final dests = await _gCastRepository.listDestinations();
return dests
.map(
(device) => (
device.extras["fn"] ?? "Google Cast",
CastDestinationType.googleCast,
device
),
)
.toList(growable: false);
}
}