Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
250
mobile/lib/services/gcast.service.dart
Normal file
250
mobile/lib/services/gcast.service.dart
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:cast/session.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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';
|
||||
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final gCastServiceProvider = Provider(
|
||||
(ref) => GCastService(
|
||||
ref.watch(gCastRepositoryProvider),
|
||||
ref.watch(sessionsAPIRepositoryProvider),
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class GCastService {
|
||||
final GCastRepository _gCastRepository;
|
||||
final SessionsAPIRepository _sessionsApiService;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
SessionCreateResponse? sessionKey;
|
||||
String? currentAssetId;
|
||||
bool isConnected = false;
|
||||
int? _sessionId;
|
||||
Timer? _mediaStatusPollingTimer;
|
||||
|
||||
void Function(bool)? onConnectionState;
|
||||
|
||||
void Function(Duration)? onCurrentTime;
|
||||
|
||||
void Function(Duration)? onDuration;
|
||||
|
||||
void Function(String)? onReceiverName;
|
||||
|
||||
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;
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCastMessageCallback(Map<String, dynamic> message) {
|
||||
switch (message['type']) {
|
||||
case "MEDIA_STATUS":
|
||||
_handleMediaStatus(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMediaStatus(Map<String, dynamic> message) {
|
||||
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(dynamic device) async {
|
||||
await _gCastRepository.connect(device);
|
||||
|
||||
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
|
||||
}
|
||||
|
||||
CastDestinationType getType() {
|
||||
return CastDestinationType.googleCast;
|
||||
}
|
||||
|
||||
Future<bool> initialize() async {
|
||||
// there is nothing blocking us from using Google Cast that we can check for
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
await _gCastRepository.disconnect();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) async {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
} else if (asset.id == 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.id)
|
||||
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
|
||||
|
||||
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
|
||||
|
||||
// get image mime type
|
||||
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
|
||||
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "LOAD",
|
||||
"media": {
|
||||
"contentId": authenticatedURL,
|
||||
"streamType": "BUFFERED",
|
||||
"contentType": mimeType,
|
||||
"contentUrl": authenticatedURL,
|
||||
},
|
||||
"autoplay": true,
|
||||
});
|
||||
|
||||
currentAssetId = asset.id;
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void play() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PLAY", "mediaSessionId": _sessionId});
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PAUSE", "mediaSessionId": _sessionId});
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "SEEK",
|
||||
"mediaSessionId": _sessionId,
|
||||
"currentTime": position.inSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "STOP", "mediaSessionId": _sessionId});
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
|
||||
currentAssetId = null;
|
||||
}
|
||||
|
||||
// 0x01 is display capability bitmask
|
||||
bool isDisplay(int ca) => (ca & 0x01) != 0;
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
final dests = await _gCastRepository.listDestinations();
|
||||
|
||||
return dests
|
||||
.map((device) => (device.extras["fn"] ?? "Google Cast", CastDestinationType.googleCast, device))
|
||||
.where((device) {
|
||||
final caString = device.$3.extras["ca"];
|
||||
final caNumber = int.tryParse(caString ?? "0") ?? 0;
|
||||
|
||||
return isDisplay(caNumber);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue