mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
283 lines
7.8 KiB
Dart
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);
|
|
}
|
|
}
|