mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
feat(mobile): FCast support
This commit is contained in:
parent
093f5c070a
commit
6df9cd8458
20 changed files with 590 additions and 434 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -55,3 +55,10 @@ 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);
|
||||
const Duration kCastConnectTimeout = Duration(seconds: 15);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,45 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
||||
enum CastConnection { idle, connecting, connected, failed }
|
||||
|
||||
enum CastDiscoveryStatus { starting, active, failed }
|
||||
|
||||
typedef CastDestination = (String name, CastDestinationType type, dynamic device);
|
||||
|
||||
typedef CastDiscoveryUpdate = ({CastDiscoveryStatus status, List<CastDestination> devices});
|
||||
|
||||
@freezed
|
||||
abstract class CastManagerState with _$CastManagerState {
|
||||
const CastManagerState._();
|
||||
|
||||
const factory CastManagerState({
|
||||
required bool isCasting,
|
||||
required String receiverName,
|
||||
required CastConnection connection,
|
||||
required String? receiverName,
|
||||
required CastState castState,
|
||||
required Duration currentTime,
|
||||
required Duration duration,
|
||||
required CastDiscoveryStatus discoveryStatus,
|
||||
required List<CastDestination> devices,
|
||||
}) = _CastManagerState;
|
||||
|
||||
bool get isCasting => connection == CastConnection.connected;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,37 +3,48 @@ 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;
|
||||
StreamSubscription<CastDiscoveryUpdate>? _discovery;
|
||||
|
||||
List<(String, CastDestinationType, dynamic)> discovered = List.empty();
|
||||
|
||||
CastNotifier(this._gCastService)
|
||||
CastNotifier(this._castService)
|
||||
: super(
|
||||
const CastManagerState(
|
||||
isCasting: false,
|
||||
connection: CastConnection.idle,
|
||||
currentTime: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
receiverName: '',
|
||||
receiverName: null,
|
||||
castState: CastState.idle,
|
||||
discoveryStatus: CastDiscoveryStatus.starting,
|
||||
devices: [],
|
||||
),
|
||||
) {
|
||||
_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;
|
||||
_discovery = _castService.discovery.listen(_onDiscovery);
|
||||
}
|
||||
|
||||
void _onConnectionState(bool isCasting) {
|
||||
state = state.copyWith(isCasting: isCasting);
|
||||
void _onDiscovery(CastDiscoveryUpdate update) {
|
||||
state = state.copyWith(discoveryStatus: update.status, devices: update.devices);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_discovery?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onConnectionState(CastConnection connection) {
|
||||
state = state.copyWith(connection: connection);
|
||||
}
|
||||
|
||||
void _onCurrentTime(Duration currentTime) {
|
||||
|
|
@ -44,7 +55,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 +64,11 @@ 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<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
if (discovered.isEmpty) {
|
||||
discovered = await _gCastService.getDevices();
|
||||
}
|
||||
|
||||
return discovered;
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _castService.connect(device);
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
|
|
@ -82,22 +82,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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
mobile/lib/repositories/cast.repository.dart
Normal file
135
mobile/lib/repositories/cast.repository.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'dart:async';
|
||||
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
|
||||
typedef CastDiscovery = ({CastDiscoveryStatus status, List<(DeviceInfo, int?)> devices});
|
||||
|
||||
final castRepositoryProvider = Provider((_) => CastRepository());
|
||||
|
||||
class CastRepository {
|
||||
CastContext? _castContext;
|
||||
CastingDevice? _activeDevice;
|
||||
Future<void>? _initialized;
|
||||
|
||||
void Function(DeviceConnectionState)? onConnectionState;
|
||||
void Function(DeviceEvent)? onDeviceEvent;
|
||||
|
||||
final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {};
|
||||
final StreamController<CastDiscovery> _discoveryController = StreamController.broadcast();
|
||||
CastDiscovery _discovery = (status: CastDiscoveryStatus.starting, devices: const []);
|
||||
int _currentDeviceGeneration = 0;
|
||||
|
||||
Stream<CastDiscovery> get discovery => Stream.multi((controller) {
|
||||
controller.add(_discovery);
|
||||
unawaited(controller.addStream(_discoveryController.stream));
|
||||
});
|
||||
|
||||
void init() {
|
||||
unawaited(ensureInitialized());
|
||||
}
|
||||
|
||||
Future<void> ensureInitialized() => _initialized ??= _initialize();
|
||||
|
||||
Future<void> connect(DeviceInfo deviceInfo) async {
|
||||
await ensureInitialized();
|
||||
|
||||
final castContext = _castContext;
|
||||
if (castContext == null) {
|
||||
throw StateError('Cast SDK failed to initialize, cannot connect');
|
||||
}
|
||||
|
||||
_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<void> _initialize() async {
|
||||
try {
|
||||
await FCastSenderSdkLib.init();
|
||||
_castContext = CastContext();
|
||||
|
||||
final discoverer = DeviceDiscoverer();
|
||||
discoverer.eventStreamController.stream.listen(_onDiscoveryEvent);
|
||||
|
||||
await discoverer.init();
|
||||
|
||||
_emitDiscovery(CastDiscoveryStatus.active);
|
||||
} catch (_) {
|
||||
_emitDiscovery(CastDiscoveryStatus.failed);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _emitDiscovery(CastDiscoveryStatus status) {
|
||||
_discovery = (status: status, devices: _discoveredDevices.values.toList(growable: false));
|
||||
_discoveryController.add(_discovery);
|
||||
}
|
||||
|
||||
void _onDiscoveryEvent(dynamic event) {
|
||||
switch (event) {
|
||||
case DiscoveryEventDeviceAdded(:final deviceInfo, :final gcastCaps) ||
|
||||
DiscoveryEventDeviceUpdated(:final deviceInfo, :final gcastCaps):
|
||||
_discoveredDevices[(deviceInfo.name, deviceInfo.protocol)] = (deviceInfo, gcastCaps);
|
||||
case DiscoveryEventDeviceRemoved(:final name):
|
||||
_discoveredDevices.removeWhere((_, value) => _castRemovalMatches(value.$1, name));
|
||||
}
|
||||
|
||||
_emitDiscovery(_discovery.status);
|
||||
}
|
||||
}
|
||||
|
||||
bool _castRemovalMatches(DeviceInfo device, String removedName) {
|
||||
final id = device.txtRecords['id'];
|
||||
|
||||
if (id != null && id.isNotEmpty) {
|
||||
return removedName.endsWith(id);
|
||||
}
|
||||
|
||||
return device.name == removedName;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
222
mobile/lib/services/cast.service.dart
Normal file
222
mobile/lib/services/cast.service.dart
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import 'dart:async';
|
||||
|
||||
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;
|
||||
Timer? _connectTimeout;
|
||||
|
||||
void Function(CastConnection)? 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) {
|
||||
switch (state) {
|
||||
case DeviceConnectionState_Connected():
|
||||
_connectTimeout?.cancel();
|
||||
isConnected = true;
|
||||
|
||||
onConnectionState?.call(CastConnection.connected);
|
||||
case DeviceConnectionState_Connecting() || DeviceConnectionState_Reconnecting():
|
||||
onConnectionState?.call(CastConnection.connecting);
|
||||
case DeviceConnectionState_Disconnected():
|
||||
_connectTimeout?.cancel();
|
||||
isConnected = false;
|
||||
currentAssetId = null;
|
||||
|
||||
onConnectionState?.call(CastConnection.idle);
|
||||
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 {
|
||||
final name = device.name as String;
|
||||
|
||||
_connectTimeout?.cancel();
|
||||
onReceiverName?.call(name);
|
||||
onConnectionState?.call(CastConnection.connecting);
|
||||
|
||||
try {
|
||||
await _castRepository.connect(device);
|
||||
} catch (_) {
|
||||
onConnectionState?.call(CastConnection.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
_connectTimeout = Timer(kCastConnectTimeout, () => unawaited(_giveUpConnecting(name)));
|
||||
}
|
||||
|
||||
Future<void> _giveUpConnecting(String name) async {
|
||||
await _castRepository.disconnect();
|
||||
|
||||
onReceiverName?.call(name);
|
||||
onConnectionState?.call(CastConnection.failed);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_connectTimeout?.cancel();
|
||||
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;
|
||||
|
||||
Stream<CastDiscoveryUpdate> get discovery =>
|
||||
_castRepository.discovery.map((update) => (status: update.status, devices: _toDestinations(update.devices)));
|
||||
|
||||
List<CastDestination> _toDestinations(List<(DeviceInfo, int?)> dests) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,11 @@ class CastDialog extends ConsumerWidget {
|
|||
}
|
||||
|
||||
bool isDeviceConnecting(String deviceName) {
|
||||
return castManager.receiverName == deviceName && !castManager.isCasting;
|
||||
return castManager.receiverName == deviceName && castManager.connection == CastConnection.connecting;
|
||||
}
|
||||
|
||||
bool isDeviceFailed(String deviceName) {
|
||||
return castManager.receiverName == deviceName && castManager.connection == CastConnection.failed;
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
|
|
@ -27,20 +31,16 @@ class CastDialog extends ConsumerWidget {
|
|||
content: SizedBox(
|
||||
width: 250,
|
||||
height: 250,
|
||||
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
|
||||
future: ref.watch(castProvider.notifier).getDevices(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Text(context.t.error_saving_image(error: snapshot.error.toString()));
|
||||
} else if (!snapshot.hasData) {
|
||||
return const SizedBox(height: 48, child: Center(child: CircularProgressIndicator()));
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final devices = castManager.devices;
|
||||
|
||||
if (devices.isEmpty) {
|
||||
return castManager.discoveryStatus == CastDiscoveryStatus.starting
|
||||
? const SizedBox(height: 48, child: Center(child: CircularProgressIndicator()))
|
||||
: Text(context.t.no_cast_devices_found);
|
||||
}
|
||||
|
||||
if (snapshot.data!.isEmpty) {
|
||||
return Text(context.t.no_cast_devices_found);
|
||||
}
|
||||
|
||||
final devices = snapshot.data!;
|
||||
final connected = devices.where((d) => isCurrentDevice(d.$1)).toList();
|
||||
final others = devices.where((d) => !isCurrentDevice(d.$1)).toList();
|
||||
|
||||
|
|
@ -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,13 +77,15 @@ 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)
|
||||
? Icon(Icons.check, color: context.colorScheme.primary)
|
||||
: isDeviceConnecting(deviceName)
|
||||
? const CircularProgressIndicator()
|
||||
: isDeviceFailed(deviceName)
|
||||
? Icon(Icons.error_outline, color: context.colorScheme.error)
|
||||
: null,
|
||||
onTap: () async {
|
||||
if (isDeviceConnecting(deviceName)) {
|
||||
|
|
@ -95,7 +97,7 @@ class CastDialog extends ConsumerWidget {
|
|||
}
|
||||
|
||||
if (!isCurrentDevice(deviceName)) {
|
||||
unawaited(ref.read(castProvider.notifier).connect(type, deviceObj));
|
||||
unawaited(ref.read(castProvider.notifier).connect(deviceObj));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
[tools]
|
||||
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."aqua:flutter/flutter"]
|
||||
version = "3.47.1"
|
||||
|
|
|
|||
|
|
@ -74,53 +74,53 @@ packages:
|
|||
source: hosted
|
||||
version: "9.5.6"
|
||||
bonsoir:
|
||||
dependency: "direct overridden"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir
|
||||
sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496"
|
||||
sha256: "20fed01e4616e625f4bafd9007ff899d231d8d79bebc8564853fa1e3570294a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.11"
|
||||
version: "7.1.5"
|
||||
bonsoir_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_android
|
||||
sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b"
|
||||
sha256: "0dda1164e826553b8ee90a3fd1aabd462d17f19348f542b95f9d5b5455e4157f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.6"
|
||||
version: "7.1.3"
|
||||
bonsoir_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_darwin
|
||||
sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4"
|
||||
sha256: "6f695a0b4465514b75fc1a457746ab6dfc10d7180f0d04810fd9512e6520df69"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.3"
|
||||
version: "7.1.1"
|
||||
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.7"
|
||||
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:
|
||||
|
|
@ -796,10 +820,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "2.2.0"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1109,7 +1133,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: meta
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
|
|
@ -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: "8469a0e32a47a5c84b7fd300de9fd0e8082aac76c37922986324f5082a58350d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
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:
|
||||
|
|
@ -1458,10 +1482,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
version: "1.1.1"
|
||||
riverpod:
|
||||
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:
|
||||
|
|
@ -2029,5 +2061,5 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
dart: ">=3.13.0 <4.0.0"
|
||||
flutter: "3.47.1"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ dependencies:
|
|||
async: ^2.13.1
|
||||
auto_route: ^11.1.0
|
||||
background_downloader: ^9.5.6
|
||||
cast: ^2.1.0
|
||||
collection: ^1.19.1
|
||||
connectivity_plus: ^7.3.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,8 @@ 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
|
||||
meta: 1.19.0
|
||||
objective_c:
|
||||
git:
|
||||
url: https://github.com/mertalev/native
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -39,6 +39,8 @@ void main() {
|
|||
final assetService = MockAssetService();
|
||||
final controller = MockNativeVideoPlayerController();
|
||||
final timeline = MockTimelineService();
|
||||
final castService = MockCastService();
|
||||
when(() => castService.discovery).thenAnswer((_) => const Stream.empty());
|
||||
final updates = StreamController<BaseAsset?>(sync: true);
|
||||
when(() => assetService.watchAsset(searchCopy)).thenAnswer((_) => updates.stream);
|
||||
when(controller.play).thenAnswer((_) async {});
|
||||
|
|
@ -57,7 +59,7 @@ void main() {
|
|||
overrides: [
|
||||
assetServiceProvider.overrideWithValue(assetService),
|
||||
assetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter<BaseAsset>({})),
|
||||
gCastServiceProvider.overrideWithValue(MockGCastService()),
|
||||
castServiceProvider.overrideWithValue(castService),
|
||||
inLockedViewProvider.overrideWithValue(true),
|
||||
ownedAssetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter<RemoteAsset>({})),
|
||||
readonlyModeProvider.overrideWith(TestReadOnlyModeNotifier.new),
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,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();
|
||||
|
||||
|
|
@ -157,6 +157,11 @@ class ServiceMocks {
|
|||
_stubTagService();
|
||||
_stubBackgroundSync();
|
||||
_stubForegroundUpload();
|
||||
_stubCastService();
|
||||
}
|
||||
|
||||
void _stubCastService() {
|
||||
when(() => cast.discovery).thenAnswer((_) => const Stream.empty());
|
||||
}
|
||||
|
||||
void _stubUserService() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/cast.action.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
|
@ -17,8 +18,9 @@ void main() {
|
|||
await context.dispose();
|
||||
});
|
||||
|
||||
void Function(bool) captureConnectionListener() =>
|
||||
verify(() => context.service.cast.onConnectionState = captureAny()).captured.single as void Function(bool);
|
||||
void Function(CastConnection) captureConnectionListener() =>
|
||||
verify(() => context.service.cast.onConnectionState = captureAny()).captured.single
|
||||
as void Function(CastConnection);
|
||||
|
||||
group('CastAction', () {
|
||||
testWidgets('offers to cast when nothing is connected', (tester) async {
|
||||
|
|
@ -30,7 +32,7 @@ void main() {
|
|||
testWidgets('switches to the connected icon once casting starts', (tester) async {
|
||||
await tester.pumpTestWidget(context, const ActionIconButton(action: CastAction()));
|
||||
|
||||
captureConnectionListener()(true);
|
||||
captureConnectionListener()(CastConnection.connected);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.cast_connected_rounded), findsOneWidget);
|
||||
|
|
@ -41,9 +43,9 @@ void main() {
|
|||
await tester.pumpTestWidget(context, const ActionIconButton(action: CastAction()));
|
||||
|
||||
final onConnectionState = captureConnectionListener();
|
||||
onConnectionState(true);
|
||||
onConnectionState(CastConnection.connected);
|
||||
await tester.pump();
|
||||
onConnectionState(false);
|
||||
onConnectionState(CastConnection.idle);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.cast_rounded), findsOneWidget);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,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';
|
||||
|
|
@ -58,7 +58,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),
|
||||
assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue