From 8e7ccf949ee9a46a91158d70ccb7340fbe34864e Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 23 Jul 2026 10:28:24 -0500 Subject: [PATCH 01/15] feat: mobile fcast support --- mobile/ios/Podfile | 22 +- mobile/ios/Podfile.lock | 17 +- mobile/ios/Runner/Info.plist | 1 + .../lib/models/cast/cast_manager_state.dart | 2 +- mobile/lib/providers/cast.provider.dart | 47 ++-- mobile/lib/repositories/cast.repository.dart | 99 +++++++ mobile/lib/repositories/gcast.repository.dart | 68 ----- mobile/lib/services/cast.service.dart | 205 +++++++++++++++ mobile/lib/services/gcast.service.dart | 244 ------------------ .../lib/widgets/asset_viewer/cast_dialog.dart | 6 +- mobile/pubspec.lock | 74 +++--- mobile/pubspec.yaml | 3 +- 12 files changed, 402 insertions(+), 386 deletions(-) create mode 100644 mobile/lib/repositories/cast.repository.dart delete mode 100644 mobile/lib/repositories/gcast.repository.dart create mode 100644 mobile/lib/services/cast.service.dart delete mode 100644 mobile/lib/services/gcast.service.dart 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/Info.plist b/mobile/ios/Runner/Info.plist index 31feda2e11..4e96aaeac2 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 776888146b..556e7572cd 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -3,19 +3,17 @@ import 'dart:async'; 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, @@ -25,11 +23,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) { @@ -53,22 +51,15 @@ class CastNotifier extends StateNotifier { } void loadMedia(RemoteAsset asset, bool reload) { - unawaited(_gCastService.loadMedia(asset, reload)); + _castService.loadMedia(asset, reload); } - Future connect(CastDestinationType type, dynamic device) async { - switch (type) { - case CastDestinationType.googleCast: - await _gCastService.connect(device); - } + 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() { @@ -82,22 +73,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 b8ffe79b04..0000000000 --- a/mobile/lib/repositories/gcast.repository.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:cast/device.dart'; -import 'package:cast/discovery_service.dart'; -import 'package:cast/session.dart'; -import 'package:cast/session_manager.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 d9fc44a34d..0000000000 --- a/mobile/lib/services/gcast.service.dart +++ /dev/null @@ -1,244 +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); - } - } - - 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); - case "PAUSED": - onCastState?.call(CastState.paused); - case "BUFFERING": - onCastState?.call(CastState.buffering); - case "IDLE": - onCastState?.call(CastState.idle); - - // stop polling for media status if the video finished playing - if (status["idleReason"] == "FINISHED") { - _mediaStatusPollingTimer?.cancel(); - } - } - - 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()); - } - - Future 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 e0198f2683..135e2d39f1 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 a55bcf515b..5964deb505 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 From bc8a2baa75b7702c6eaa8a6fb32daca9c3638fce Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 23 Jul 2026 11:01:07 -0500 Subject: [PATCH 02/15] chore: documentation --- docs/docs/features/casting.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/features/casting.md b/docs/docs/features/casting.md index 2a6785dc6c..b94dd3b920 100644 --- a/docs/docs/features/casting.md +++ b/docs/docs/features/casting.md @@ -6,6 +6,10 @@ Immich supports the Google's Cast protocol so that photos and videos can be cast Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retrieve them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in. +## Mobile Fcast support + +The mobile app also supports FCast for casting media to both FCast receivers and Google Cast devices such as a Google TV or Nest Hub. Devices are automatically discovered over your local network. + You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast` From f8dfb8d5a80b2b4157d3f9a669b0d250607f0a17 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 23 Jul 2026 11:51:58 -0500 Subject: [PATCH 03/15] fix: rust & protoc --- mobile/lib/services/cast.service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index 529dc8c504..d77f036cce 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -1,4 +1,4 @@ - import 'package:fcast_sender_sdk/fcast_sender_sdk.dart'; +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'; From 40b256772f71a4cf49481b14f91648558febb67e Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 23 Jul 2026 15:05:36 -0500 Subject: [PATCH 04/15] fix: version --- mobile/pubspec.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5964deb505..f1a430b2b2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -113,6 +113,11 @@ 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_darwin: + git: + url: https://github.com/Skyost/Bonsoir + ref: ce155549130e # fix(darwin): add missing Foundation import; still v6.1.0 + path: packages/bonsoir_darwin objective_c: git: url: https://github.com/mertalev/native From 4401347944bfcaa96e12483f43d7783f19cd43e1 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 23 Jul 2026 17:07:22 -0500 Subject: [PATCH 05/15] fix: casting documentation --- docs/docs/features/casting.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/docs/features/casting.md b/docs/docs/features/casting.md index b94dd3b920..708af7b194 100644 --- a/docs/docs/features/casting.md +++ b/docs/docs/features/casting.md @@ -1,23 +1,23 @@ -# Chromecast support +# Casting support -Immich supports the Google's Cast protocol so that photos and videos can be cast to devices such as a Chromecast and a Nest Hub. This feature is considered experimental and has several important limitations listed below. Currently, this feature is only supported by the web client, support on Android and iOS is planned for the future. +Immich supports both the FCast and Google Cast protocols, meaning photos and videos in Immich can be cast to devices such as a Chromecast, Nest Hub, or an FCast receiver. This feature is considered experimental and has several important limitations listed below. This feature is supported by the web client, as well as the mobile app via FCast. -## Enable Google Cast Support - -Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retrieve them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in. - -## Mobile Fcast support +## Mobile FCast support The mobile app also supports FCast for casting media to both FCast receivers and Google Cast devices such as a Google TV or Nest Hub. Devices are automatically discovered over your local network. -You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast` +## Enable web support + +Casting from the web client is currently only possible via Google Cast, so enabling web support means enabling Google Cast. Google Cast support is disabled by default, since it requires the web UI to load Google-provided scripts from Google's servers when the page loads. This is a privacy concern for some and is thus opt-in. + +You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`. ## Limitations -To use casting with Immich, there are a few prerequisites: +To use Google Cast with Immich, there are a few prerequisites: 1. Your instance must be accessed via an HTTPS connection in order for the casting menu to show. -2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`) -3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media) +2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`). +3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media). From b215fbd61d7d74ad30dd39a521a2b90e30e7a365 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 31 Jul 2026 10:05:55 -0500 Subject: [PATCH 06/15] fix(mobile): fcast_sender_sdk 0.0.5 update --- mobile/ios/Podfile | 18 --------- mobile/ios/Podfile.lock | 8 +--- mobile/lib/services/cast.service.dart | 13 ++++--- mobile/mise.lock | 36 +++++++++++++++++ mobile/mise.toml | 2 + mobile/pubspec.lock | 56 +++++++++++++++++++-------- mobile/pubspec.yaml | 9 +---- 7 files changed, 88 insertions(+), 54 deletions(-) diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index 5841bf987b..5426319802 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -27,29 +27,11 @@ 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 diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index d2e9e2ae64..5aff9cb1cd 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -2,8 +2,6 @@ PODS: - 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,7 +22,6 @@ PODS: DEPENDENCIES: - 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`) @@ -36,8 +33,6 @@ DEPENDENCIES: EXTERNAL SOURCES: 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,7 +50,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c - fcast_sender_sdk: 8bf227a5cbcedaea2e580a633ff0f706f1e90328 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 @@ -64,6 +58,6 @@ SPEC CHECKSUMS: share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 -PODFILE CHECKSUM: 44895462563291c3e4328a856c6482a25165b507 +PODFILE CHECKSUM: 3bbbe7bbbc538e252ee25bb56e007b3c9e69bd7f COCOAPODS: 1.17.0 diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index d77f036cce..4d4da4aa60 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -82,6 +82,7 @@ class CastService { onCastState?.call(CastState.buffering); break; case PlaybackState.idle: + case PlaybackState.ended: onCastState?.call(CastState.idle); break; } @@ -177,10 +178,12 @@ class CastService { Future> getDevices() async { final dests = await _castRepository.listDestinations(); - final fCastNames = dests - .where((dest) => dest.$1.protocol == ProtocolType.fCast) - .map((dest) => dest.$1.name) - .toSet(); + final fCastDevices = dests.where((dest) => dest.$1.protocol == ProtocolType.fCast).map((dest) => dest.$1); + final fCastNames = fCastDevices.map((device) => device.name).toSet(); + final fCastAddresses = fCastDevices.expand((device) => device.addresses).toSet(); + + bool hasFCastTwin(DeviceInfo device) => + fCastNames.contains(device.name) || device.addresses.any(fCastAddresses.contains); return dests .where((dest) { @@ -190,7 +193,7 @@ class CastService { return true; } - return isDisplay(gcastCaps ?? 0) && !fCastNames.contains(device.name); + return isDisplay(gcastCaps ?? 0) && !hasFCastTwin(device); }) .map((dest) { final device = dest.$1; diff --git a/mobile/mise.lock b/mobile/mise.lock index 8f2f9572fb..4801ee110f 100644 --- a/mobile/mise.lock +++ b/mobile/mise.lock @@ -126,3 +126,39 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602 [[tools."npm:@openapitools/openapi-generator-cli"]] version = "2.40.1" backend = "npm:@openapitools/openapi-generator-cli" +[[tools.protoc]] +version = "31.1" +backend = "aqua:protocolbuffers/protobuf/protoc" + +[tools.protoc."platforms.linux-arm64"] +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922984" + +[tools.protoc."platforms.linux-arm64-musl"] +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922984" + +[tools.protoc."platforms.linux-x64"] +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922987" + +[tools.protoc."platforms.linux-x64-musl"] +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922987" + +[tools.protoc."platforms.macos-arm64"] +checksum = "blake3:ab170cb340494cab122b645c3e1abdeec0a26742b3272eac71af20836513c8ba" +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-osx-aarch_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922988" + +[tools.protoc."platforms.macos-x64"] +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-osx-x86_64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922990" + +[tools.protoc."platforms.windows-x64"] +url = "https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-win64.zip" +url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/258922991" + +[[tools.rust]] +version = "1.93.1" +backend = "core:rust" diff --git a/mobile/mise.toml b/mobile/mise.toml index ee312178a4..02064acf65 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -1,6 +1,8 @@ [tools] "aqua:flutter/flutter" = "3.44.8" java = "21.0.2" +rust = "1.93.1" +protoc = "31.1" [tools."github:CQLabs/homebrew-dcm"] version = "1.37.0" diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 135e2d39f1..6a5cae6c70 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -77,50 +77,50 @@ packages: dependency: transitive description: name: bonsoir - sha256: "1b112a966302a739d253c8dbc7ea4c1cb3eca6d8110dc59e36d76f7cf0b6edf2" + sha256: "86a8f55539d29c17ac2cb8a16698978aaa9e7c5d14139fb2234dad33b6ecfc33" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.1.4" bonsoir_android: dependency: transitive description: name: bonsoir_android - sha256: bfa3ab7e2f65473cb369bce639dbdbdc75064848c01ac11b3c2bc3e16031ff3a + sha256: c6413e82150074d56e71ffe2e54bb1dfe66a59310affc4486d33e9eee6b362e6 url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "7.1.2" bonsoir_darwin: dependency: transitive description: name: bonsoir_darwin - sha256: d62fd62ed433aa09ec99f71f95dae53ffa0adf788c9051ff3d6b903463045d3c + sha256: "04425a8657e3131683c7966689d4e65e114cb5181de94aac89d2fba84cfaaca4" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.1.0" bonsoir_linux: dependency: transitive description: name: bonsoir_linux - sha256: a49d5f328a197b27a3901b833f92c93366f2cd7085dcb495fa11ee6d9f2509a9 + sha256: "9afae7bb9509c5bd20a0ea2ca02a0f2b1ef09cca1ceb2ea288ed58a0f03d1e11" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "7.1.0" bonsoir_platform_interface: dependency: transitive description: name: bonsoir_platform_interface - sha256: ba1cc30daaa172dfc76f88e4fee8d090674179439201997ba2b3bd9e1cca84c0 + sha256: "6c55c786b01ad279f675ae72cea3a885aa746e416d2e8c1f0c46e9d2c06420b0" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.0" bonsoir_windows: dependency: transitive description: name: bonsoir_windows - sha256: "01aba2516b776eb1deb68845124dc0a41095da108276d4b307bf19c4c3e2d9b1" + sha256: aaabbb652fe68d3eb26a9b2cddefaae26cd9ca6ebe737d476ab9ed535c7c2516 url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "7.3.0" boolean_selector: dependency: transitive description: @@ -422,10 +422,10 @@ packages: dependency: "direct main" description: name: fcast_sender_sdk - sha256: "4c4e0f51749a0930e26e42e6a3f456293c7f5f0ffed2ecd1e283c28de5e11b33" + sha256: "6416b01466341cb29529773a995ba5c533e70441783a51c610cd60dad0e6ca29" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.0.5" ffi: dependency: "direct main" description: @@ -581,10 +581,18 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + sha256: d9d819e3f39efe08a68309ff7cadc9d8bcfd65d86319e0c5735cd4f4fee16c4a url: "https://pub.dev" source: hosted - version: "2.11.1" + version: "2.13.0-beta.5" + flutter_rust_bridge_hooks: + dependency: transitive + description: + name: flutter_rust_bridge_hooks + sha256: "33f96e930af9016405f519696574f6fcb96ec37a515188d1da484fa81f9a90f7" + url: "https://pub.dev" + source: hosted + version: "2.13.0-beta.5" flutter_secure_storage: dependency: "direct main" description: @@ -1148,6 +1156,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.1" + native_toolchain_rust: + dependency: transitive + description: + name: native_toolchain_rust + sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857 + url: "https://pub.dev" + source: hosted + version: "1.0.4+0" native_video_player: dependency: "direct main" description: @@ -1772,6 +1788,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.4" + toml: + dependency: transitive + description: + name: toml + sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e" + url: "https://pub.dev" + source: hosted + version: "0.18.0" typed_data: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f1a430b2b2..c262f9ee89 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -90,7 +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 + fcast_sender_sdk: ^0.0.5 dev_dependencies: auto_route_generator: ^10.5.0 @@ -110,14 +110,7 @@ dev_dependencies: # Type safe platform code pigeon: ^26.3.4 -# 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_darwin: - git: - url: https://github.com/Skyost/Bonsoir - ref: ce155549130e # fix(darwin): add missing Foundation import; still v6.1.0 - path: packages/bonsoir_darwin objective_c: git: url: https://github.com/mertalev/native From 62165069aa3e715c4bfbb3c5201e0c228d93c8f3 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 31 Jul 2026 11:37:59 -0500 Subject: [PATCH 07/15] fix(mobile): mockfile fixes and break removals --- mobile/lib/services/cast.service.dart | 7 ------- mobile/test/service.mocks.dart | 4 ++-- mobile/test/unit/mocks.dart | 2 +- mobile/test/unit/presentation/presentation_context.dart | 4 ++-- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index 4d4da4aa60..7172b50110 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -58,13 +58,10 @@ class CastService { 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; } @@ -74,17 +71,13 @@ class CastService { 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: case PlaybackState.ended: onCastState?.call(CastState.idle); - break; } } diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 785567de56..952f65b27d 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -7,9 +7,9 @@ import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/cast.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/toast.service.dart'; @@ -35,7 +35,7 @@ class MockUserService extends Mock implements UserService {} class MockRemoteAlbumService extends Mock implements RemoteAlbumService {} -class MockGCastService extends Mock implements GCastService {} +class MockCastService extends Mock implements CastService {} class MockForegroundUploadService extends Mock implements ForegroundUploadService {} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 1d43198263..587bf5c208 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -112,7 +112,7 @@ class ServiceMocks { final tag = TagServiceStub(MockTagService()); final backgroundSync = MockBackgroundSyncManager(); final upload = MockForegroundUploadService(); - final cast = MockGCastService(); + final cast = MockCastService(); final serverInfo = MockServerInfoService(); final toast = MockToastService(); diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 36ae8d087d..07d42c7c84 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -22,8 +22,8 @@ import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/services/cast.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; -import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; @@ -54,7 +54,7 @@ class PresentationContext { cleanupServiceProvider.overrideWithValue(service.cleanup.service), remoteAlbumServiceProvider.overrideWithValue(service.album.service), partnerServiceProvider.overrideWithValue(service.partner.service), - gCastServiceProvider.overrideWithValue(service.cast), + castServiceProvider.overrideWithValue(service.cast), serverInfoServiceProvider.overrideWithValue(service.serverInfo), inLockedViewProvider.overrideWithValue(false), remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo), From f0da182e37ca2954bbe79cfbbe3728e9a78cb4c5 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 31 Jul 2026 11:54:23 -0500 Subject: [PATCH 08/15] fix(mobile): update cast service for API changes --- mobile/lib/providers/cast.provider.dart | 2 +- mobile/lib/services/cast.service.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 556e7572cd..409747546e 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -51,7 +51,7 @@ class CastNotifier extends StateNotifier { } void loadMedia(RemoteAsset asset, bool reload) { - _castService.loadMedia(asset, reload); + unawaited(_castService.loadMedia(asset, reload)); } Future connect(dynamic device) async { diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index 7172b50110..dba5c00361 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -109,7 +109,7 @@ class CastService { return bufferedExpiration.isAfter(DateTime.now()); } - void loadMedia(RemoteAsset asset, bool reload) async { + Future loadMedia(RemoteAsset asset, bool reload) async { if (!isConnected) { return; } else if (asset.id == currentAssetId && !reload) { From e3a2e8c7ea387200bef8ef0162450a55f19bf0c6 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 31 Jul 2026 17:08:26 -0500 Subject: [PATCH 09/15] fix: maintainability fixes --- mobile/lib/constants/constants.dart | 6 ++ mobile/lib/providers/cast.provider.dart | 1 - mobile/lib/repositories/cast.repository.dart | 7 ++- mobile/lib/services/cast.service.dart | 66 ++++++++++---------- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index c2a13c3ec2..f5586daeeb 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -55,3 +55,9 @@ const int kLibraryTabIndex = 3; // Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766) const int kDriftMaxChunk = 32000; + +// cast constants +const String kCastDeviceType = "Cast"; +const String kCastDeviceOS = "Cast"; +const Duration kCastSessionDuration = Duration(minutes: 15); +const Duration kCastSessionRenewalBuffer = Duration(minutes: 1); diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 409747546e..1f8a51da01 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -10,7 +10,6 @@ final castProvider = StateNotifierProvider( ); class CastNotifier extends StateNotifier { - // more cast providers can be added here (ie Fcast) final CastService _castService; CastNotifier(this._castService) diff --git a/mobile/lib/repositories/cast.repository.dart b/mobile/lib/repositories/cast.repository.dart index a809f702e0..e10d57073f 100644 --- a/mobile/lib/repositories/cast.repository.dart +++ b/mobile/lib/repositories/cast.repository.dart @@ -22,7 +22,8 @@ class CastRepository { final device = _castContext!.createDeviceFromInfo(info: deviceInfo); _device = device; - final thisDeviceGeneration = ++_currentDeviceGeneration; + _currentDeviceGeneration += 1; + final thisDeviceGeneration = _currentDeviceGeneration; device.connect( eventHandler: DeviceEventHandler( onEvent: (event) { @@ -48,7 +49,7 @@ class CastRepository { } _device = null; - _currentDeviceGeneration++; + _currentDeviceGeneration += 1; if (device.isReady()) { device.stopPlayback(); @@ -64,7 +65,7 @@ class CastRepository { void play() => _device?.resumePlayback(); void pause() => _device?.pausePlayback(); void stop() => _device?.stopPlayback(); - void seekTo(Duration position) => _device?.seek(timeSeconds: position.inMilliseconds / 1000); + void seekTo(Duration position) => _device?.seek(timeSeconds: position.inSeconds.toDouble()); Future> listDestinations() async { final isFirstScan = _initialized == null; diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index dba5c00361..86f1f70611 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -1,5 +1,6 @@ import 'package:fcast_sender_sdk/fcast_sender_sdk.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.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'; @@ -38,30 +39,32 @@ class CastService { void Function(CastState)? onCastState; CastService(this._castRepository, this._sessionsApiService, this._assetApiRepository) { - _castRepository.onConnectionState = _onCastStatusCallback; - _castRepository.onDeviceEvent = _onDeviceEventCallback; + _castRepository.onConnectionState = _onConnectionState; + _castRepository.onDeviceEvent = _onDeviceEvent; } - void _onCastStatusCallback(DeviceConnectionState state) { + void _onConnectionState(DeviceConnectionState state) { if (state is DeviceConnectionState_Connected) { - onConnectionState?.call(true); isConnected = true; + + onConnectionState?.call(true); } else if (state is DeviceConnectionState_Disconnected) { - onConnectionState?.call(false); isConnected = false; - onReceiverName?.call(""); currentAssetId = null; + + onConnectionState?.call(false); + onReceiverName?.call(""); } } - void _onDeviceEventCallback(DeviceEvent event) { + void _onDeviceEvent(DeviceEvent event) { switch (event) { case DeviceEvent_PlaybackStateChanged(): _handlePlaybackState(event.newPlaybackState); case DeviceEvent_TimeChanged(): - onCurrentTime?.call(Duration(milliseconds: (event.newTime * 1000).toInt())); + onCurrentTime?.call(Duration(seconds: event.newTime.toInt())); case DeviceEvent_DurationChanged(): - onDuration?.call(Duration(milliseconds: (event.newDuration * 1000).toInt())); + onDuration?.call(Duration(seconds: event.newDuration.toInt())); default: break; } @@ -102,9 +105,9 @@ class CastService { final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!); - // we want to make sure we have at least 10 seconds remaining in the session + // we want to make sure we have at least 1 minute 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)); + final bufferedExpiration = tokenExpiration.subtract(kCastSessionRenewalBuffer); return bufferedExpiration.isAfter(DateTime.now()); } @@ -119,18 +122,12 @@ class CastService { // create a session key if (!isSessionValid()) { sessionKey = await _sessionsApiService.createSession( - "Cast", - "Cast", - duration: const Duration(minutes: 15).inSeconds, + kCastDeviceType, + kCastDeviceOS, + duration: kCastSessionDuration.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); @@ -138,9 +135,15 @@ class CastService { return; } + final baseUrl = asset.isVideo + ? getPlaybackUrlForRemoteId(asset.id) + : getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize); + + final authenticatedUrl = "$baseUrl&sessionKey=${sessionKey?.token}"; + final request = asset.isVideo - ? LoadRequest.video(contentType: mimeType, url: authenticatedURL, resumePosition: 0.0) - : LoadRequest.image(contentType: mimeType, url: authenticatedURL); + ? LoadRequest.video(contentType: mimeType, url: authenticatedUrl, resumePosition: 0.0) + : LoadRequest.image(contentType: mimeType, url: authenticatedUrl); _castRepository.loadMedia(request); @@ -165,8 +168,7 @@ class CastService { currentAssetId = null; } - // 0x01 is display capability bitmask - bool isDisplay(int ca) => (ca & 0x01) != 0; + bool hasDisplayCapability(int capabilities) => (capabilities & 0x01) != 0; Future> getDevices() async { final dests = await _castRepository.listDestinations(); @@ -182,17 +184,17 @@ class CastService { .where((dest) { final (device, gcastCaps) = dest; - if (device.protocol == ProtocolType.fCast) { - return true; - } - - return isDisplay(gcastCaps ?? 0) && !hasFCastTwin(device); + return switch (device.protocol) { + ProtocolType.fCast => true, + ProtocolType.chromecast => hasDisplayCapability(gcastCaps ?? 0) && !hasFCastTwin(device), + }; }) .map((dest) { final device = dest.$1; - final type = device.protocol == ProtocolType.fCast - ? CastDestinationType.fCast - : CastDestinationType.googleCast; + final type = switch (device.protocol) { + ProtocolType.fCast => CastDestinationType.fCast, + ProtocolType.chromecast => CastDestinationType.googleCast, + }; return (device.name, type, device as dynamic); }) From 6359ad9486c04150a84528385443cafc9f19eb1c Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 3 Aug 2026 09:46:31 -0500 Subject: [PATCH 10/15] fix: receivername defaults to null --- mobile/lib/models/cast/cast_manager_state.dart | 10 ++++++---- mobile/lib/providers/cast.provider.dart | 7 ++++--- mobile/lib/services/cast.service.dart | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart index d4f7cf7516..1269bd8322 100644 --- a/mobile/lib/models/cast/cast_manager_state.dart +++ b/mobile/lib/models/cast/cast_manager_state.dart @@ -1,12 +1,14 @@ import 'dart:convert'; +import 'package:immich_mobile/utils/option.dart'; + enum CastDestinationType { googleCast, fCast } enum CastState { idle, playing, paused, buffering } class CastManagerState { final bool isCasting; - final String receiverName; + final String? receiverName; final CastState castState; final Duration currentTime; final Duration duration; @@ -21,14 +23,14 @@ class CastManagerState { CastManagerState copyWith({ bool? isCasting, - String? receiverName, + Option? receiverName, CastState? castState, Duration? currentTime, Duration? duration, }) { return CastManagerState( isCasting: isCasting ?? this.isCasting, - receiverName: receiverName ?? this.receiverName, + receiverName: receiverName.patch(this.receiverName), castState: castState ?? this.castState, currentTime: currentTime ?? this.currentTime, duration: duration ?? this.duration, @@ -50,7 +52,7 @@ class CastManagerState { factory CastManagerState.fromMap(Map map) { return CastManagerState( isCasting: map['isCasting'] ?? false, - receiverName: map['receiverName'] ?? '', + receiverName: map['receiverName'], castState: map['castState'] ?? CastState.idle, currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0), duration: Duration(seconds: map['duration']?.toInt() ?? 0), diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 1f8a51da01..3cf876d2ef 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -4,6 +4,7 @@ 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/cast.service.dart'; +import 'package:immich_mobile/utils/option.dart'; final castProvider = StateNotifierProvider( (ref) => CastNotifier(ref.watch(castServiceProvider)), @@ -18,7 +19,7 @@ class CastNotifier extends StateNotifier { isCasting: false, currentTime: Duration.zero, duration: Duration.zero, - receiverName: '', + receiverName: null, castState: CastState.idle, ), ) { @@ -41,8 +42,8 @@ class CastNotifier extends StateNotifier { state = state.copyWith(duration: duration); } - void _onReceiverName(String receiverName) { - state = state.copyWith(receiverName: receiverName); + void _onReceiverName(String? receiverName) { + state = state.copyWith(receiverName: Option.fromNullable(receiverName)); } void _onCastState(CastState castState) { diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index 86f1f70611..ad3441bfe6 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -34,7 +34,7 @@ class CastService { void Function(Duration)? onDuration; - void Function(String)? onReceiverName; + void Function(String?)? onReceiverName; void Function(CastState)? onCastState; @@ -53,7 +53,7 @@ class CastService { currentAssetId = null; onConnectionState?.call(false); - onReceiverName?.call(""); + onReceiverName?.call(null); } } @@ -91,7 +91,7 @@ class CastService { } Future disconnect() async { - onReceiverName?.call(""); + onReceiverName?.call(null); currentAssetId = null; await _castRepository.disconnect(); } From f49a3b1d287207fe362a8f04e423258657ba92eb Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 3 Aug 2026 11:00:21 -0500 Subject: [PATCH 11/15] fix: renaming --- mobile/lib/repositories/cast.repository.dart | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/lib/repositories/cast.repository.dart b/mobile/lib/repositories/cast.repository.dart index e10d57073f..f2e2207e06 100644 --- a/mobile/lib/repositories/cast.repository.dart +++ b/mobile/lib/repositories/cast.repository.dart @@ -6,7 +6,7 @@ final castRepositoryProvider = Provider((_) => CastRepository()); class CastRepository { CastContext? _castContext; - CastingDevice? _device; + CastingDevice? _activeDevice; void Function(DeviceConnectionState)? onConnectionState; void Function(DeviceEvent)? onDeviceEvent; @@ -18,9 +18,9 @@ class CastRepository { Future connect(DeviceInfo deviceInfo) async { await _ensureInitialized(); - _device?.disconnect(); + _activeDevice?.disconnect(); final device = _castContext!.createDeviceFromInfo(info: deviceInfo); - _device = device; + _activeDevice = device; _currentDeviceGeneration += 1; final thisDeviceGeneration = _currentDeviceGeneration; @@ -43,29 +43,29 @@ class CastRepository { } Future disconnect() async { - final device = _device; - if (device == null) { + final previousDevice = _activeDevice; + if (previousDevice == null) { return; } - _device = null; + _activeDevice = null; _currentDeviceGeneration += 1; - if (device.isReady()) { - device.stopPlayback(); + if (previousDevice.isReady()) { + previousDevice.stopPlayback(); await Future.delayed(const Duration(milliseconds: 500)); } - device.disconnect(); + previousDevice.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.inSeconds.toDouble()); + void loadMedia(LoadRequest request) => _activeDevice?.load(request: request); + void play() => _activeDevice?.resumePlayback(); + void pause() => _activeDevice?.pausePlayback(); + void stop() => _activeDevice?.stopPlayback(); + void seekTo(Duration position) => _activeDevice?.seek(timeSeconds: position.inSeconds.toDouble()); Future> listDestinations() async { final isFirstScan = _initialized == null; From 54ca7956a923586b09cae13c80906708901d78c3 Mon Sep 17 00:00:00 2001 From: Will Date: Wed, 5 Aug 2026 11:26:48 -0500 Subject: [PATCH 12/15] chore: merge conflicts --- mobile/test/providers/backup/drift_backup_provider_test.dart | 2 +- mobile/test/service.mocks.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/test/providers/backup/drift_backup_provider_test.dart b/mobile/test/providers/backup/drift_backup_provider_test.dart index 9a5f222301..205f564cd9 100644 --- a/mobile/test/providers/backup/drift_backup_provider_test.dart +++ b/mobile/test/providers/backup/drift_backup_provider_test.dart @@ -76,7 +76,7 @@ void main() { firstRun('asset-1', 'remote-1'); expect(notifier.state.remainderCount, 0); - notifier.stopForegroundBackup(); + notifier.stopForegroundBackup(reason: "test"); final resumedRun = await startAndCaptureOnSuccess(); expect(notifier.state.remainderCount, 1); diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index b9edb47cc2..41f503b561 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -8,9 +8,9 @@ import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/services/cast.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; +import 'package:immich_mobile/services/cast.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/network.service.dart'; From c26a23522a0df4ac9e1d0f439c2eb98400d03363 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 6 Aug 2026 13:53:44 -0500 Subject: [PATCH 13/15] fix: freezed CastManagerState, protoc removal from mise --- mobile/ios/Podfile | 4 +- mobile/ios/Podfile.lock | 4 +- .../lib/models/cast/cast_manager_state.dart | 91 +++---------------- mobile/lib/providers/cast.provider.dart | 5 +- mobile/lib/services/cast.service.dart | 2 +- .../lib/widgets/asset_viewer/cast_dialog.dart | 4 +- mobile/mise.toml | 2 +- 7 files changed, 22 insertions(+), 90 deletions(-) diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index 5426319802..f697d17411 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -32,7 +32,7 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - + # share_handler addition start target 'ShareExtension' do inherit! :search_paths @@ -49,7 +49,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 5aff9cb1cd..db2e58f1c9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -58,6 +58,6 @@ SPEC CHECKSUMS: share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 -PODFILE CHECKSUM: 3bbbe7bbbc538e252ee25bb56e007b3c9e69bd7f +PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c -COCOAPODS: 1.17.0 +COCOAPODS: 1.16.2 diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart index 1269bd8322..3732d9fe05 100644 --- a/mobile/lib/models/cast/cast_manager_state.dart +++ b/mobile/lib/models/cast/cast_manager_state.dart @@ -1,87 +1,20 @@ -import 'dart:convert'; +import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:immich_mobile/utils/option.dart'; +part 'cast_manager_state.freezed.dart'; enum CastDestinationType { googleCast, fCast } enum CastState { idle, playing, paused, buffering } -class CastManagerState { - final bool isCasting; - final String? receiverName; - final CastState castState; - final Duration currentTime; - final Duration duration; +typedef CastDestination = (String name, CastDestinationType type, dynamic device); - const CastManagerState({ - required this.isCasting, - required this.receiverName, - required this.castState, - required this.currentTime, - required this.duration, - }); - - CastManagerState copyWith({ - bool? isCasting, - Option? receiverName, - CastState? castState, - Duration? currentTime, - Duration? duration, - }) { - return CastManagerState( - isCasting: isCasting ?? this.isCasting, - receiverName: receiverName.patch(this.receiverName), - castState: castState ?? this.castState, - currentTime: currentTime ?? this.currentTime, - duration: duration ?? this.duration, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'isCasting': isCasting}); - result.addAll({'receiverName': receiverName}); - result.addAll({'castState': castState}); - result.addAll({'currentTime': currentTime.inSeconds}); - result.addAll({'duration': duration.inSeconds}); - - return result; - } - - factory CastManagerState.fromMap(Map map) { - return CastManagerState( - isCasting: map['isCasting'] ?? false, - receiverName: map['receiverName'], - castState: map['castState'] ?? CastState.idle, - currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0), - duration: Duration(seconds: map['duration']?.toInt() ?? 0), - ); - } - - String toJson() => json.encode(toMap()); - - factory CastManagerState.fromJson(String source) => CastManagerState.fromMap(json.decode(source)); - - @override - String toString() => - 'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is CastManagerState && - other.isCasting == isCasting && - other.receiverName == receiverName && - other.castState == castState && - other.currentTime == currentTime && - other.duration == duration; - } - - @override - int get hashCode => - isCasting.hashCode ^ receiverName.hashCode ^ castState.hashCode ^ currentTime.hashCode ^ duration.hashCode; +@freezed +abstract class CastManagerState with _$CastManagerState { + const factory CastManagerState({ + required bool isCasting, + required String? receiverName, + required CastState castState, + required Duration currentTime, + required Duration duration, + }) = _CastManagerState; } diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 3cf876d2ef..eaa700b46b 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -4,7 +4,6 @@ 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/cast.service.dart'; -import 'package:immich_mobile/utils/option.dart'; final castProvider = StateNotifierProvider( (ref) => CastNotifier(ref.watch(castServiceProvider)), @@ -43,7 +42,7 @@ class CastNotifier extends StateNotifier { } void _onReceiverName(String? receiverName) { - state = state.copyWith(receiverName: Option.fromNullable(receiverName)); + state = state.copyWith(receiverName: receiverName); } void _onCastState(CastState castState) { @@ -58,7 +57,7 @@ class CastNotifier extends StateNotifier { await _castService.connect(device); } - Future> getDevices() { + Future> getDevices() { return _castService.getDevices(); } diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index ad3441bfe6..b2b4120283 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -170,7 +170,7 @@ class CastService { bool hasDisplayCapability(int capabilities) => (capabilities & 0x01) != 0; - Future> getDevices() async { + Future> getDevices() async { final dests = await _castRepository.listDestinations(); final fCastDevices = dests.where((dest) => dest.$1.protocol == ProtocolType.fCast).map((dest) => dest.$1); diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart index bde52bf765..98b67d4d0d 100644 --- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -27,7 +27,7 @@ class CastDialog extends ConsumerWidget { content: SizedBox( width: 250, height: 250, - child: FutureBuilder>( + child: FutureBuilder>( future: ref.watch(castProvider.notifier).getDevices(), builder: (context, snapshot) { if (snapshot.hasError) { @@ -69,7 +69,7 @@ class CastDialog extends ConsumerWidget { child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(), ); } else { - final (deviceName, _, deviceObj) = item as (String, CastDestinationType, dynamic); + final (deviceName, _, deviceObj) = item as CastDestination; return ListTile( title: Text( diff --git a/mobile/mise.toml b/mobile/mise.toml index 02064acf65..fb1b8a23e1 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -1,8 +1,8 @@ [tools] "aqua:flutter/flutter" = "3.44.8" java = "21.0.2" +# FCast casting (https://docs.immich.app/features/casting): fcast_sender_sdk builds its Rust core from source rust = "1.93.1" -protoc = "31.1" [tools."github:CQLabs/homebrew-dcm"] version = "1.37.0" From ae285cc9337eed2d1a480dc9b66651a4f97f3e40 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 7 Aug 2026 14:30:53 -0500 Subject: [PATCH 14/15] fix(mobile): eager cast discovery --- mobile/lib/models/cast/cast_manager_state.dart | 15 ++++++++++++++- mobile/lib/repositories/cast.repository.dart | 18 +++++++----------- mobile/lib/services/cast.service.dart | 13 ++----------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/mobile/lib/models/cast/cast_manager_state.dart b/mobile/lib/models/cast/cast_manager_state.dart index 3732d9fe05..50b95734a2 100644 --- a/mobile/lib/models/cast/cast_manager_state.dart +++ b/mobile/lib/models/cast/cast_manager_state.dart @@ -1,10 +1,23 @@ +import 'package:fcast_sender_sdk/fcast_sender_sdk.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'cast_manager_state.freezed.dart'; enum CastDestinationType { googleCast, fCast } -enum CastState { idle, playing, paused, buffering } +enum CastState { + idle, + playing, + paused, + buffering; + + static CastState fromPlaybackState(PlaybackState state) => switch (state) { + PlaybackState.playing => CastState.playing, + PlaybackState.paused => CastState.paused, + PlaybackState.buffering => CastState.buffering, + PlaybackState.idle || PlaybackState.ended => CastState.idle, + }; +} typedef CastDestination = (String name, CastDestinationType type, dynamic device); diff --git a/mobile/lib/repositories/cast.repository.dart b/mobile/lib/repositories/cast.repository.dart index f2e2207e06..f36ec4d6ad 100644 --- a/mobile/lib/repositories/cast.repository.dart +++ b/mobile/lib/repositories/cast.repository.dart @@ -13,11 +13,13 @@ class CastRepository { final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {}; int _currentDeviceGeneration = 0; - Future? _initialized; + late final Future _initialized; + + void init() { + _initialized = _initialize(); + } Future connect(DeviceInfo deviceInfo) async { - await _ensureInitialized(); - _activeDevice?.disconnect(); final device = _castContext!.createDeviceFromInfo(info: deviceInfo); _activeDevice = device; @@ -68,18 +70,11 @@ class CastRepository { void seekTo(Duration position) => _activeDevice?.seek(timeSeconds: position.inSeconds.toDouble()); Future> listDestinations() async { - final isFirstScan = _initialized == null; - await _ensureInitialized(); - - if (isFirstScan) { - await Future.delayed(const Duration(seconds: 3)); - } + await _initialized; return _discoveredDevices.values.toList(growable: false); } - Future _ensureInitialized() => _initialized ??= _initialize(); - Future _initialize() async { await FCastSenderSdkLib.init(); _castContext = CastContext(); @@ -96,5 +91,6 @@ class CastRepository { }); await discoverer.init(); + await Future.delayed(const Duration(seconds: 3)); } } diff --git a/mobile/lib/services/cast.service.dart b/mobile/lib/services/cast.service.dart index b2b4120283..4dfc25291c 100644 --- a/mobile/lib/services/cast.service.dart +++ b/mobile/lib/services/cast.service.dart @@ -41,6 +41,7 @@ class CastService { CastService(this._castRepository, this._sessionsApiService, this._assetApiRepository) { _castRepository.onConnectionState = _onConnectionState; _castRepository.onDeviceEvent = _onDeviceEvent; + _castRepository.init(); } void _onConnectionState(DeviceConnectionState state) { @@ -71,17 +72,7 @@ class CastService { } void _handlePlaybackState(PlaybackState state) { - switch (state) { - case PlaybackState.playing: - onCastState?.call(CastState.playing); - case PlaybackState.paused: - onCastState?.call(CastState.paused); - case PlaybackState.buffering: - onCastState?.call(CastState.buffering); - case PlaybackState.idle: - case PlaybackState.ended: - onCastState?.call(CastState.idle); - } + onCastState?.call(CastState.fromPlaybackState(state)); } Future connect(dynamic device) async { From 834a86e391afabbf10f3d09db00c563db285b8df Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 10 Aug 2026 12:30:42 -0500 Subject: [PATCH 15/15] fix(mobile): update bottom_bar_test --- .../presentation/widgets/asset_viewer/bottom_bar_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart b/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart index f2463c090d..69a2665760 100644 --- a/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart +++ b/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart @@ -14,7 +14,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/services/gcast.service.dart'; +import 'package:immich_mobile/services/cast.service.dart'; import 'package:immich_mobile/utils/asset_filter.dart'; import 'package:mocktail/mocktail.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -57,7 +57,7 @@ void main() { overrides: [ assetServiceProvider.overrideWithValue(assetService), assetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter({})), - gCastServiceProvider.overrideWithValue(MockGCastService()), + castServiceProvider.overrideWithValue(MockCastService()), inLockedViewProvider.overrideWithValue(true), ownedAssetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter({})), readonlyModeProvider.overrideWith(TestReadOnlyModeNotifier.new),