This commit is contained in:
Will 2026-08-13 02:59:32 +01:00 committed by GitHub
commit 0cbd83f94f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 459 additions and 405 deletions

View file

@ -1,19 +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
## Mobile FCast 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.
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`.
<img src={require('./img/gcast-enable.webp').default} width="70%" title='Enable Google Cast Support' />
## 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).

View file

@ -1,7 +1,4 @@
PODS:
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
@ -24,7 +21,6 @@ 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`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
@ -35,8 +31,6 @@ 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"
Flutter:
@ -55,7 +49,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
SPEC CHECKSUMS:
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100

View file

@ -127,6 +127,7 @@
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
<string>_fcast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>

View file

@ -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);

View file

@ -1,16 +1,31 @@
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 }
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);
@freezed
abstract class CastManagerState with _$CastManagerState {
const factory CastManagerState({
required bool isCasting,
required String receiverName,
required String? receiverName,
required CastState castState,
required Duration currentTime,
required Duration duration,

View file

@ -3,33 +3,30 @@ 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<CastNotifier, CastManagerState>(
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
(ref) => CastNotifier(ref.watch(castServiceProvider)),
);
class CastNotifier extends StateNotifier<CastManagerState> {
// 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,
currentTime: Duration.zero,
duration: Duration.zero,
receiverName: '',
receiverName: null,
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) {
@ -44,7 +41,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
state = state.copyWith(duration: duration);
}
void _onReceiverName(String receiverName) {
void _onReceiverName(String? receiverName) {
state = state.copyWith(receiverName: receiverName);
}
@ -53,22 +50,15 @@ class CastNotifier extends StateNotifier<CastManagerState> {
}
void loadMedia(RemoteAsset asset, bool reload) {
unawaited(_gCastService.loadMedia(asset, reload));
unawaited(_castService.loadMedia(asset, reload));
}
Future<void> connect(CastDestinationType type, dynamic device) async {
switch (type) {
case CastDestinationType.googleCast:
await _gCastService.connect(device);
}
Future<void> connect(dynamic device) async {
await _castService.connect(device);
}
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
if (discovered.isEmpty) {
discovered = await _gCastService.getDevices();
}
return discovered;
Future<List<CastDestination>> getDevices() {
return _castService.getDevices();
}
void toggle() {
@ -82,22 +72,22 @@ class CastNotifier extends StateNotifier<CastManagerState> {
}
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<void> disconnect() async {
await _gCastService.disconnect();
await _castService.disconnect();
}
}

View file

@ -0,0 +1,96 @@
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? _activeDevice;
void Function(DeviceConnectionState)? onConnectionState;
void Function(DeviceEvent)? onDeviceEvent;
final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {};
int _currentDeviceGeneration = 0;
late final Future<void> _initialized;
void init() {
_initialized = _initialize();
}
Future<void> connect(DeviceInfo deviceInfo) async {
_activeDevice?.disconnect();
final device = _castContext!.createDeviceFromInfo(info: deviceInfo);
_activeDevice = device;
_currentDeviceGeneration += 1;
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<void> disconnect() async {
final previousDevice = _activeDevice;
if (previousDevice == null) {
return;
}
_activeDevice = null;
_currentDeviceGeneration += 1;
if (previousDevice.isReady()) {
previousDevice.stopPlayback();
await Future.delayed(const Duration(milliseconds: 500));
}
previousDevice.disconnect();
onConnectionState?.call(const DeviceConnectionState.disconnected());
}
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<List<(DeviceInfo, int?)>> listDestinations() async {
await _initialized;
return _discoveredDevices.values.toList(growable: false);
}
Future<void> _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();
await Future.delayed(const Duration(seconds: 3));
}
}

View file

@ -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<String, dynamic>)? onCastMessage;
Map<String, dynamic>? _receiverStatus;
GCastRepository();
Future<void> 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<void> 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<String, dynamic> message) {
if (_castSession == null) {
throw Exception("Cast session is not established");
}
_castSession!.sendMessage(namespace, message);
}
Future<List<CastDevice>> listDestinations() async {
return await CastDiscoveryService().search(timeout: const Duration(seconds: 3));
}
}

View file

@ -0,0 +1,194 @@
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';
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 = _onConnectionState;
_castRepository.onDeviceEvent = _onDeviceEvent;
_castRepository.init();
}
void _onConnectionState(DeviceConnectionState state) {
if (state is DeviceConnectionState_Connected) {
isConnected = true;
onConnectionState?.call(true);
} else if (state is DeviceConnectionState_Disconnected) {
isConnected = false;
currentAssetId = null;
onConnectionState?.call(false);
onReceiverName?.call(null);
}
}
void _onDeviceEvent(DeviceEvent event) {
switch (event) {
case DeviceEvent_PlaybackStateChanged():
_handlePlaybackState(event.newPlaybackState);
case DeviceEvent_TimeChanged():
onCurrentTime?.call(Duration(seconds: event.newTime.toInt()));
case DeviceEvent_DurationChanged():
onDuration?.call(Duration(seconds: event.newDuration.toInt()));
default:
break;
}
}
void _handlePlaybackState(PlaybackState state) {
onCastState?.call(CastState.fromPlaybackState(state));
}
Future<void> connect(dynamic device) async {
await _castRepository.connect(device);
onReceiverName?.call(device.name);
}
Future<void> disconnect() async {
onReceiverName?.call(null);
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 1 minute remaining in the session
// this is to account for network latency and other delays when sending the request
final bufferedExpiration = tokenExpiration.subtract(kCastSessionRenewalBuffer);
return bufferedExpiration.isAfter(DateTime.now());
}
Future<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(
kCastDeviceType,
kCastDeviceOS,
duration: kCastSessionDuration.inSeconds,
);
}
// get image mime type
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
if (mimeType == null) {
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);
_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;
}
bool hasDisplayCapability(int capabilities) => (capabilities & 0x01) != 0;
Future<List<CastDestination>> getDevices() async {
final dests = await _castRepository.listDestinations();
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) {
final (device, gcastCaps) = dest;
return switch (device.protocol) {
ProtocolType.fCast => true,
ProtocolType.chromecast => hasDisplayCapability(gcastCaps ?? 0) && !hasFCastTwin(device),
};
})
.map((dest) {
final device = dest.$1;
final type = switch (device.protocol) {
ProtocolType.fCast => CastDestinationType.fCast,
ProtocolType.chromecast => CastDestinationType.googleCast,
};
return (device.name, type, device as dynamic);
})
.toList(growable: false);
}
}

View file

@ -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<String, dynamic> message) {
switch (message['type']) {
case "MEDIA_STATUS":
_handleMediaStatus(message);
}
}
void _handleMediaStatus(Map<String, dynamic> message) {
final statusList = (message['status'] as List).whereType<Map<String, dynamic>>().toList();
if (statusList.isEmpty) {
return;
}
final status = statusList[0];
switch (status['playerState']) {
case "PLAYING":
onCastState?.call(CastState.playing);
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<void> connect(dynamic device) async {
await _gCastRepository.connect(device);
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
}
CastDestinationType getType() {
return CastDestinationType.googleCast;
}
Future<bool> initialize() async {
// there is nothing blocking us from using Google Cast that we can check for
return true;
}
Future<void> disconnect() async {
onReceiverName?.call("");
currentAssetId = null;
await _gCastRepository.disconnect();
}
bool isSessionValid() {
// check if we already have a session token
// we should always have a expiration date
if (sessionKey == null || sessionKey?.expiresAt == null) {
return false;
}
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
// we want to make sure we have at least 10 seconds remaining in the session
// this is to account for network latency and other delays when sending the request
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
return bufferedExpiration.isAfter(DateTime.now());
}
Future<void> loadMedia(RemoteAsset asset, bool reload) async {
if (!isConnected) {
return;
} else if (asset.id == currentAssetId && !reload) {
return;
}
// create a session key
if (!isSessionValid()) {
sessionKey = await _sessionsApiService.createSession(
"Cast",
"Google Cast",
duration: const Duration(minutes: 15).inSeconds,
);
}
final unauthenticatedUrl = asset.isVideo
? getPlaybackUrlForRemoteId(asset.id)
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
if (mimeType == null) {
return;
}
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "LOAD",
"media": {
"contentId": authenticatedURL,
"streamType": "BUFFERED",
"contentType": mimeType,
"contentUrl": authenticatedURL,
},
"autoplay": true,
});
currentAssetId = asset.id;
// we need to poll for media status since the cast device does not
// send a message when the media is loaded for whatever reason
// only do this on videos
_mediaStatusPollingTimer?.cancel();
if (asset.isVideo) {
_mediaStatusPollingTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
if (isConnected) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "GET_STATUS",
"mediaSessionId": _sessionId,
});
} else {
timer.cancel();
}
});
}
}
void play() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PLAY", "mediaSessionId": _sessionId});
}
void pause() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PAUSE", "mediaSessionId": _sessionId});
}
void seekTo(Duration position) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "SEEK",
"mediaSessionId": _sessionId,
"currentTime": position.inSeconds,
});
}
void stop() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "STOP", "mediaSessionId": _sessionId});
_mediaStatusPollingTimer?.cancel();
currentAssetId = null;
}
// 0x01 is display capability bitmask
bool isDisplay(int ca) => (ca & 0x01) != 0;
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
final dests = await _gCastRepository.listDestinations();
return dests
.map((device) => (device.extras["fn"] ?? "Google Cast", CastDestinationType.googleCast, device))
.where((device) {
final caString = device.$3.extras["ca"];
final caNumber = int.tryParse(caString ?? "0") ?? 0;
return isDisplay(caNumber);
})
.toList(growable: false);
}
}

View file

@ -27,7 +27,7 @@ class CastDialog extends ConsumerWidget {
content: SizedBox(
width: 250,
height: 250,
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
child: FutureBuilder<List<CastDestination>>(
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)),
);
} else {
final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic);
final (deviceName, _, deviceObj) = item as CastDestination;
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));
}
},
);

View file

@ -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"

View file

@ -1,6 +1,8 @@
[tools]
"aqua:flutter/flutter" = "3.44.9"
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"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"

View file

@ -74,53 +74,53 @@ packages:
source: hosted
version: "9.5.5"
bonsoir:
dependency: "direct overridden"
dependency: transitive
description:
name: bonsoir
sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496"
sha256: "86a8f55539d29c17ac2cb8a16698978aaa9e7c5d14139fb2234dad33b6ecfc33"
url: "https://pub.dev"
source: hosted
version: "5.1.11"
version: "7.1.4"
bonsoir_android:
dependency: transitive
description:
name: bonsoir_android
sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b"
sha256: c6413e82150074d56e71ffe2e54bb1dfe66a59310affc4486d33e9eee6b362e6
url: "https://pub.dev"
source: hosted
version: "5.1.6"
version: "7.1.2"
bonsoir_darwin:
dependency: transitive
description:
name: bonsoir_darwin
sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4"
sha256: "04425a8657e3131683c7966689d4e65e114cb5181de94aac89d2fba84cfaaca4"
url: "https://pub.dev"
source: hosted
version: "5.1.3"
version: "7.1.0"
bonsoir_linux:
dependency: transitive
description:
name: bonsoir_linux
sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2
sha256: "9afae7bb9509c5bd20a0ea2ca02a0f2b1ef09cca1ceb2ea288ed58a0f03d1e11"
url: "https://pub.dev"
source: hosted
version: "5.1.3"
version: "7.1.0"
bonsoir_platform_interface:
dependency: transitive
description:
name: bonsoir_platform_interface
sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45"
sha256: "6c55c786b01ad279f675ae72cea3a885aa746e416d2e8c1f0c46e9d2c06420b0"
url: "https://pub.dev"
source: hosted
version: "5.1.3"
version: "7.0.0"
bonsoir_windows:
dependency: transitive
description:
name: bonsoir_windows
sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d
sha256: aaabbb652fe68d3eb26a9b2cddefaae26cd9ca6ebe737d476ab9ed535c7c2516
url: "https://pub.dev"
source: hosted
version: "5.1.5"
version: "7.3.0"
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: "6416b01466341cb29529773a995ba5c533e70441783a51c610cd60dad0e6ca29"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
ffi:
dependency: "direct main"
description:
@ -569,6 +577,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_rust_bridge:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: d9d819e3f39efe08a68309ff7cadc9d8bcfd65d86319e0c5735cd4f4fee16c4a
url: "https://pub.dev"
source: hosted
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:
@ -1140,6 +1164,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:
@ -1414,14 +1446,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:
@ -1772,6 +1796,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:

View file

@ -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.5
freezed_annotation: ^3.1.0
dev_dependencies:
@ -112,10 +112,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: ^5.1.11
objective_c:
git:
url: https://github.com/mertalev/native

View file

@ -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<BaseAsset>({})),
gCastServiceProvider.overrideWithValue(MockGCastService()),
castServiceProvider.overrideWithValue(MockCastService()),
inLockedViewProvider.overrideWithValue(true),
ownedAssetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter<RemoteAsset>({})),
readonlyModeProvider.overrideWith(TestReadOnlyModeNotifier.new),

View file

@ -10,9 +10,9 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.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/gcast.service.dart';
import 'package:immich_mobile/services/network.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
@ -40,7 +40,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 {}

View file

@ -121,7 +121,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();

View file

@ -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),