diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index f697d17411..5841bf987b 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -27,12 +27,30 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup +def ensure_fcast_sender_sdk_podspec + plugin_ios_dir = File.expand_path(File.join('.symlinks', 'plugins', 'fcast_sender_sdk', 'ios'), __dir__) + original_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk_flutter_plugin.podspec') + expected_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk.podspec') + + return unless File.exist?(original_podspec) + + contents = File.read(original_podspec) + fixed_contents = contents + .sub("s.name = 'fcast_sender_sdk_flutter_plugin'", "s.name = 'fcast_sender_sdk'") + .gsub('libfcast_sender_sdk_flutter_plugin.a', 'libfcast_sender_sdk.a') + + return if File.exist?(expected_podspec) && File.read(expected_podspec) == fixed_contents + + File.write(expected_podspec, fixed_contents) +end + target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - + ensure_fcast_sender_sdk_podspec + # share_handler addition start target 'ShareExtension' do inherit! :search_paths @@ -49,7 +67,7 @@ post_install do |installer| end end end - + installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 75eda67ce1..d2e9e2ae64 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,10 +1,9 @@ PODS: - - bonsoir_darwin (0.0.1): - - Flutter - - FlutterMacOS - cupertino_http (0.0.1): - Flutter - FlutterMacOS + - fcast_sender_sdk (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter @@ -24,8 +23,8 @@ PODS: - share_handler_ios_models (0.0.9) DEPENDENCIES: - - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`) + - fcast_sender_sdk (from `.symlinks/plugins/fcast_sender_sdk/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) @@ -35,10 +34,10 @@ DEPENDENCIES: - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) EXTERNAL SOURCES: - bonsoir_darwin: - :path: ".symlinks/plugins/bonsoir_darwin/darwin" cupertino_http: :path: ".symlinks/plugins/cupertino_http/darwin" + fcast_sender_sdk: + :path: ".symlinks/plugins/fcast_sender_sdk/ios" Flutter: :path: Flutter flutter_local_notifications: @@ -55,8 +54,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_handler_ios/ios/Models" SPEC CHECKSUMS: - bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c + fcast_sender_sdk: 8bf227a5cbcedaea2e580a633ff0f706f1e90328 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 @@ -65,6 +64,6 @@ SPEC CHECKSUMS: share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 -PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c +PODFILE CHECKSUM: 44895462563291c3e4328a856c6482a25165b507 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a9381e541a..e1c39435fa 100644 --- a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809", - "version" : "6.14.0" + "revision" : "84a79bc375a301169390ac110c868f06c857b83f", + "version" : "6.27.0" } }, { diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index da29f0f979..37c56fda05 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -127,6 +127,7 @@ _googlecast._tcp _CC1AD845._googlecast._tcp + _fcast._tcp NSCameraUsageDescription We need to access the camera to let you take beautiful video using this app diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart index 9727bc7ed8..d4f7cf7516 100644 --- a/mobile/lib/models/cast/cast_manager_state.dart +++ b/mobile/lib/models/cast/cast_manager_state.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -enum CastDestinationType { googleCast } +enum CastDestinationType { googleCast, fCast } enum CastState { idle, playing, paused, buffering } diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index b298514d67..2a69b5d28c 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -1,19 +1,17 @@ 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/services/gcast.service.dart'; +import 'package:immich_mobile/services/cast.service.dart'; final castProvider = StateNotifierProvider( - (ref) => CastNotifier(ref.watch(gCastServiceProvider)), + (ref) => CastNotifier(ref.watch(castServiceProvider)), ); class CastNotifier extends StateNotifier { // more cast providers can be added here (ie Fcast) - final GCastService _gCastService; + final CastService _castService; - List<(String, CastDestinationType, dynamic)> discovered = List.empty(); - - CastNotifier(this._gCastService) + CastNotifier(this._castService) : super( const CastManagerState( isCasting: false, @@ -23,11 +21,11 @@ class CastNotifier extends StateNotifier { castState: CastState.idle, ), ) { - _gCastService.onConnectionState = _onConnectionState; - _gCastService.onCurrentTime = _onCurrentTime; - _gCastService.onDuration = _onDuration; - _gCastService.onReceiverName = _onReceiverName; - _gCastService.onCastState = _onCastState; + _castService.onConnectionState = _onConnectionState; + _castService.onCurrentTime = _onCurrentTime; + _castService.onDuration = _onDuration; + _castService.onReceiverName = _onReceiverName; + _castService.onCastState = _onCastState; } void _onConnectionState(bool isCasting) { @@ -51,23 +49,15 @@ class CastNotifier extends StateNotifier { } void loadMedia(RemoteAsset asset, bool reload) { - _gCastService.loadMedia(asset, reload); + _castService.loadMedia(asset, reload); } - Future connect(CastDestinationType type, dynamic device) async { - switch (type) { - case CastDestinationType.googleCast: - await _gCastService.connect(device); - break; - } + Future connect(dynamic device) async { + await _castService.connect(device); } - Future> getDevices() async { - if (discovered.isEmpty) { - discovered = await _gCastService.getDevices(); - } - - return discovered; + Future> getDevices() { + return _castService.getDevices(); } void toggle() { @@ -81,22 +71,22 @@ class CastNotifier extends StateNotifier { } void play() { - _gCastService.play(); + _castService.play(); } void pause() { - _gCastService.pause(); + _castService.pause(); } void seekTo(Duration position) { - _gCastService.seekTo(position); + _castService.seekTo(position); } void stop() { - _gCastService.stop(); + _castService.stop(); } Future disconnect() async { - await _gCastService.disconnect(); + await _castService.disconnect(); } } diff --git a/mobile/lib/repositories/cast.repository.dart b/mobile/lib/repositories/cast.repository.dart new file mode 100644 index 0000000000..a809f702e0 --- /dev/null +++ b/mobile/lib/repositories/cast.repository.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'package:fcast_sender_sdk/fcast_sender_sdk.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final castRepositoryProvider = Provider((_) => CastRepository()); + +class CastRepository { + CastContext? _castContext; + CastingDevice? _device; + + void Function(DeviceConnectionState)? onConnectionState; + void Function(DeviceEvent)? onDeviceEvent; + + final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {}; + int _currentDeviceGeneration = 0; + Future? _initialized; + + Future connect(DeviceInfo deviceInfo) async { + await _ensureInitialized(); + + _device?.disconnect(); + final device = _castContext!.createDeviceFromInfo(info: deviceInfo); + _device = device; + + final thisDeviceGeneration = ++_currentDeviceGeneration; + device.connect( + eventHandler: DeviceEventHandler( + onEvent: (event) { + if (thisDeviceGeneration != _currentDeviceGeneration) { + return; + } + + if (event is DeviceEvent_ConnectionStateChanged) { + onConnectionState?.call(event.newState); + } + + onDeviceEvent?.call(event); + }, + ), + reconnectIntervalMillis: 1000, + ); + } + + Future disconnect() async { + final device = _device; + if (device == null) { + return; + } + + _device = null; + _currentDeviceGeneration++; + + if (device.isReady()) { + device.stopPlayback(); + + await Future.delayed(const Duration(milliseconds: 500)); + } + + device.disconnect(); + onConnectionState?.call(const DeviceConnectionState.disconnected()); + } + + void loadMedia(LoadRequest request) => _device?.load(request: request); + void play() => _device?.resumePlayback(); + void pause() => _device?.pausePlayback(); + void stop() => _device?.stopPlayback(); + void seekTo(Duration position) => _device?.seek(timeSeconds: position.inMilliseconds / 1000); + + Future> listDestinations() async { + final isFirstScan = _initialized == null; + await _ensureInitialized(); + + if (isFirstScan) { + await Future.delayed(const Duration(seconds: 3)); + } + + return _discoveredDevices.values.toList(growable: false); + } + + Future _ensureInitialized() => _initialized ??= _initialize(); + + Future _initialize() async { + await FCastSenderSdkLib.init(); + _castContext = CastContext(); + + final discoverer = DeviceDiscoverer(); + discoverer.eventStreamController.stream.listen((event) { + switch (event) { + case DiscoveryEventDeviceAdded(:final deviceInfo, :final gcastCaps) || + DiscoveryEventDeviceUpdated(:final deviceInfo, :final gcastCaps): + _discoveredDevices[(deviceInfo.name, deviceInfo.protocol)] = (deviceInfo, gcastCaps); + case DiscoveryEventDeviceRemoved(): + _discoveredDevices.removeWhere((key, _) => key.$1 == event.name); + } + }); + + await discoverer.init(); + } +} diff --git a/mobile/lib/repositories/gcast.repository.dart b/mobile/lib/repositories/gcast.repository.dart deleted file mode 100644 index db3e0f45d0..0000000000 --- a/mobile/lib/repositories/gcast.repository.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:cast/device.dart'; -import 'package:cast/session.dart'; -import 'package:cast/session_manager.dart'; -import 'package:cast/discovery_service.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -final gCastRepositoryProvider = Provider((_) { - return GCastRepository(); -}); - -class GCastRepository { - CastSession? _castSession; - - void Function(CastSessionState)? onCastStatus; - void Function(Map)? onCastMessage; - - Map? _receiverStatus; - - GCastRepository(); - - Future connect(CastDevice device) async { - _castSession = await CastSessionManager().startSession(device); - - _castSession?.stateStream.listen((state) { - onCastStatus?.call(state); - }); - - _castSession?.messageStream.listen((message) { - onCastMessage?.call(message); - if (message['type'] == 'RECEIVER_STATUS') { - _receiverStatus = message; - } - }); - - // open the default receiver - sendMessage(CastSession.kNamespaceReceiver, {'type': 'LAUNCH', 'appId': 'CC1AD845'}); - } - - Future disconnect() async { - final sessionID = getSessionId(); - - sendMessage(CastSession.kNamespaceReceiver, {'type': "STOP", "sessionId": sessionID}); - - // wait 500ms to ensure the stop command is processed - await Future.delayed(const Duration(milliseconds: 500)); - - await _castSession?.close(); - } - - String? getSessionId() { - if (_receiverStatus == null) { - return null; - } - return _receiverStatus!['status']['applications'][0]['sessionId']; - } - - void sendMessage(String namespace, Map message) { - if (_castSession == null) { - throw Exception("Cast session is not established"); - } - - _castSession!.sendMessage(namespace, message); - } - - Future> listDestinations() async { - return await CastDiscoveryService().search(timeout: const Duration(seconds: 3)); - } -} diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart new file mode 100644 index 0000000000..529dc8c504 --- /dev/null +++ b/mobile/lib/services/cast.service.dart @@ -0,0 +1,205 @@ + import 'package:fcast_sender_sdk/fcast_sender_sdk.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/cast.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 castServiceProvider = Provider( + (ref) => CastService( + ref.watch(castRepositoryProvider), + ref.watch(sessionsAPIRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ), +); + +class CastService { + final CastRepository _castRepository; + final SessionsAPIRepository _sessionsApiService; + final AssetApiRepository _assetApiRepository; + + SessionCreateResponse? sessionKey; + String? currentAssetId; + bool isConnected = false; + + void Function(bool)? onConnectionState; + + void Function(Duration)? onCurrentTime; + + void Function(Duration)? onDuration; + + void Function(String)? onReceiverName; + + void Function(CastState)? onCastState; + + CastService(this._castRepository, this._sessionsApiService, this._assetApiRepository) { + _castRepository.onConnectionState = _onCastStatusCallback; + _castRepository.onDeviceEvent = _onDeviceEventCallback; + } + + void _onCastStatusCallback(DeviceConnectionState state) { + if (state is DeviceConnectionState_Connected) { + onConnectionState?.call(true); + isConnected = true; + } else if (state is DeviceConnectionState_Disconnected) { + onConnectionState?.call(false); + isConnected = false; + onReceiverName?.call(""); + currentAssetId = null; + } + } + + void _onDeviceEventCallback(DeviceEvent event) { + switch (event) { + case DeviceEvent_PlaybackStateChanged(): + _handlePlaybackState(event.newPlaybackState); + break; + case DeviceEvent_TimeChanged(): + onCurrentTime?.call(Duration(milliseconds: (event.newTime * 1000).toInt())); + break; + case DeviceEvent_DurationChanged(): + onDuration?.call(Duration(milliseconds: (event.newDuration * 1000).toInt())); + break; + default: + break; + } + } + + void _handlePlaybackState(PlaybackState state) { + switch (state) { + case PlaybackState.playing: + onCastState?.call(CastState.playing); + break; + case PlaybackState.paused: + onCastState?.call(CastState.paused); + break; + case PlaybackState.buffering: + onCastState?.call(CastState.buffering); + break; + case PlaybackState.idle: + onCastState?.call(CastState.idle); + break; + } + } + + Future connect(dynamic device) async { + await _castRepository.connect(device); + + onReceiverName?.call(device.name); + } + + Future disconnect() async { + onReceiverName?.call(""); + currentAssetId = null; + await _castRepository.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", + "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; + } + + final request = asset.isVideo + ? LoadRequest.video(contentType: mimeType, url: authenticatedURL, resumePosition: 0.0) + : LoadRequest.image(contentType: mimeType, url: authenticatedURL); + + _castRepository.loadMedia(request); + + currentAssetId = asset.id; + } + + void play() { + _castRepository.play(); + } + + void pause() { + _castRepository.pause(); + } + + void seekTo(Duration position) { + _castRepository.seekTo(position); + } + + void stop() { + _castRepository.stop(); + + currentAssetId = null; + } + + // 0x01 is display capability bitmask + bool isDisplay(int ca) => (ca & 0x01) != 0; + + Future> getDevices() async { + final dests = await _castRepository.listDestinations(); + + final fCastNames = dests + .where((dest) => dest.$1.protocol == ProtocolType.fCast) + .map((dest) => dest.$1.name) + .toSet(); + + return dests + .where((dest) { + final (device, gcastCaps) = dest; + + if (device.protocol == ProtocolType.fCast) { + return true; + } + + return isDisplay(gcastCaps ?? 0) && !fCastNames.contains(device.name); + }) + .map((dest) { + final device = dest.$1; + final type = device.protocol == ProtocolType.fCast + ? CastDestinationType.fCast + : CastDestinationType.googleCast; + + return (device.name, type, device as dynamic); + }) + .toList(growable: false); + } +} diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart deleted file mode 100644 index dcf7685237..0000000000 --- a/mobile/lib/services/gcast.service.dart +++ /dev/null @@ -1,250 +0,0 @@ -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 message) { - switch (message['type']) { - case "MEDIA_STATUS": - _handleMediaStatus(message); - break; - } - } - - void _handleMediaStatus(Map message) { - final statusList = (message['status'] as List).whereType>().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 connect(dynamic device) async { - await _gCastRepository.connect(device); - - onReceiverName?.call(device.extras["fn"] ?? "Google Cast"); - } - - CastDestinationType getType() { - return CastDestinationType.googleCast; - } - - Future initialize() async { - // there is nothing blocking us from using Google Cast that we can check for - return true; - } - - Future 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> 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); - } -} diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart index d406f29a22..3d56d4837e 100644 --- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -69,7 +69,7 @@ class CastDialog extends ConsumerWidget { child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(), ); } else { - final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic); + final (deviceName, _, deviceObj) = item as (String, CastDestinationType, dynamic); return ListTile( title: Text( @@ -77,7 +77,7 @@ class CastDialog extends ConsumerWidget { style: TextStyle(color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null), ), leading: Icon( - type == CastDestinationType.googleCast ? Icons.cast : Icons.cast_connected, + isCurrentDevice(deviceName) ? Icons.cast_connected : Icons.cast, color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null, ), trailing: isCurrentDevice(deviceName) @@ -95,7 +95,7 @@ class CastDialog extends ConsumerWidget { } if (!isCurrentDevice(deviceName)) { - unawaited(ref.read(castProvider.notifier).connect(type, deviceObj)); + unawaited(ref.read(castProvider.notifier).connect(deviceObj)); } }, ); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f8caf6f6c9..39d43ffd89 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -74,53 +74,53 @@ packages: source: hosted version: "9.5.5" bonsoir: - dependency: "direct overridden" + dependency: transitive description: name: bonsoir - sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496" + sha256: "1b112a966302a739d253c8dbc7ea4c1cb3eca6d8110dc59e36d76f7cf0b6edf2" url: "https://pub.dev" source: hosted - version: "5.1.11" + version: "6.1.0" bonsoir_android: dependency: transitive description: name: bonsoir_android - sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b" + sha256: bfa3ab7e2f65473cb369bce639dbdbdc75064848c01ac11b3c2bc3e16031ff3a url: "https://pub.dev" source: hosted - version: "5.1.6" + version: "6.0.2" bonsoir_darwin: dependency: transitive description: name: bonsoir_darwin - sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4" + sha256: d62fd62ed433aa09ec99f71f95dae53ffa0adf788c9051ff3d6b903463045d3c url: "https://pub.dev" source: hosted - version: "5.1.3" + version: "6.1.0" bonsoir_linux: dependency: transitive description: name: bonsoir_linux - sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2 + sha256: a49d5f328a197b27a3901b833f92c93366f2cd7085dcb495fa11ee6d9f2509a9 url: "https://pub.dev" source: hosted - version: "5.1.3" + version: "6.0.3" bonsoir_platform_interface: dependency: transitive description: name: bonsoir_platform_interface - sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45" + sha256: ba1cc30daaa172dfc76f88e4fee8d090674179439201997ba2b3bd9e1cca84c0 url: "https://pub.dev" source: hosted - version: "5.1.3" + version: "6.1.0" bonsoir_windows: dependency: transitive description: name: bonsoir_windows - sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d + sha256: "01aba2516b776eb1deb68845124dc0a41095da108276d4b307bf19c4c3e2d9b1" url: "https://pub.dev" source: hosted - version: "5.1.5" + version: "6.0.3" boolean_selector: dependency: transitive description: @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.6" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -177,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" - cast: - dependency: "direct main" - description: - name: cast - sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e - url: "https://pub.dev" - source: hosted - version: "2.1.0" characters: dependency: transitive description: @@ -418,6 +418,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fcast_sender_sdk: + dependency: "direct main" + description: + name: fcast_sender_sdk + sha256: "4c4e0f51749a0930e26e42e6a3f456293c7f5f0ffed2ecd1e283c28de5e11b33" + url: "https://pub.dev" + source: hosted + version: "0.0.3" ffi: dependency: "direct main" description: @@ -569,6 +577,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_secure_storage: dependency: "direct main" description: @@ -667,6 +683,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.14" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -1398,14 +1422,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" - protobuf: - dependency: transitive - description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" - url: "https://pub.dev" - source: hosted - version: "3.1.0" pub_semver: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f66d55dc99..e0c5f2207d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: async: ^2.13.1 auto_route: ^11.1.0 background_downloader: ^9.5.4 - cast: ^2.1.0 collection: ^1.19.1 connectivity_plus: ^7.0.0 crop_image: ^1.0.17 @@ -91,6 +90,7 @@ dependencies: url: https://github.com/mertalev/http ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877 path: pkgs/ok_http/ + fcast_sender_sdk: ^0.0.3 dev_dependencies: auto_route_generator: ^10.5.0 @@ -113,7 +113,6 @@ dev_dependencies: # cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API. # Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x. dependency_overrides: - bonsoir: ^5.1.11 objective_c: git: url: https://github.com/mertalev/native