mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	* chore: bump dart sdk to 3.8 * chore: make build * make pigeon * chore: format files --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
		
			
				
	
	
		
			251 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			251 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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);
 | 
						|
  }
 | 
						|
}
 |