Compare commits

...

3 Commits

Author SHA1 Message Date
Will
acb5d53ab9 fix: rust & protoc 2026-07-23 11:51:58 -05:00
Will
d9178e2036 chore: documentation 2026-07-23 11:01:07 -05:00
Will
22624cc134 feat: mobile fcast support 2026-07-23 10:28:24 -05:00
15 changed files with 422 additions and 395 deletions

View File

@@ -127,6 +127,15 @@ jobs:
with:
packages: ''
- name: Install Rust and protoc for cargokit
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
"$HOME/.cargo/bin/rustup" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
sudo apt-get update
sudo apt-get install -y protobuf-compiler
echo "PROTOC=$(command -v protoc)" >> "$GITHUB_ENV"
- name: Get Packages
working-directory: ./mobile
run: flutter pub get
@@ -226,6 +235,11 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Install protoc
run: |
brew install protobuf
echo "PROTOC=$(brew --prefix)/bin/protoc" >> "$GITHUB_ENV"
- name: Install Flutter dependencies
working-directory: ./mobile
run: flutter pub get

View File

@@ -6,6 +6,10 @@ Immich supports the Google's Cast protocol so that photos and videos can be cast
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retrieve them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
## Mobile Fcast support
The mobile app also supports FCast for casting media to both FCast receivers and Google Cast devices such as a Google TV or Nest Hub. Devices are automatically discovered over your local network.
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`
<img src={require('./img/gcast-enable.webp').default} width="70%" title='Enable Google Cast Support' />

View File

@@ -27,12 +27,30 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup
def ensure_fcast_sender_sdk_podspec
plugin_ios_dir = File.expand_path(File.join('.symlinks', 'plugins', 'fcast_sender_sdk', 'ios'), __dir__)
original_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk_flutter_plugin.podspec')
expected_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk.podspec')
return unless File.exist?(original_podspec)
contents = File.read(original_podspec)
fixed_contents = contents
.sub("s.name = 'fcast_sender_sdk_flutter_plugin'", "s.name = 'fcast_sender_sdk'")
.gsub('libfcast_sender_sdk_flutter_plugin.a', 'libfcast_sender_sdk.a')
return if File.exist?(expected_podspec) && File.read(expected_podspec) == fixed_contents
File.write(expected_podspec, fixed_contents)
end
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
ensure_fcast_sender_sdk_podspec
# share_handler addition start
target 'ShareExtension' do
inherit! :search_paths
@@ -49,7 +67,7 @@ post_install do |installer|
end
end
end
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)

View File

@@ -1,10 +1,9 @@
PODS:
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- fcast_sender_sdk (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
@@ -24,8 +23,8 @@ PODS:
- share_handler_ios_models (0.0.9)
DEPENDENCIES:
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- fcast_sender_sdk (from `.symlinks/plugins/fcast_sender_sdk/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
@@ -35,10 +34,10 @@ DEPENDENCIES:
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
EXTERNAL SOURCES:
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
fcast_sender_sdk:
:path: ".symlinks/plugins/fcast_sender_sdk/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
@@ -55,8 +54,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
SPEC CHECKSUMS:
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
fcast_sender_sdk: 8bf227a5cbcedaea2e580a633ff0f706f1e90328
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
@@ -65,6 +64,6 @@ SPEC CHECKSUMS:
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c
PODFILE CHECKSUM: 44895462563291c3e4328a856c6482a25165b507
COCOAPODS: 1.16.2
COCOAPODS: 1.17.0

View File

@@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
}
},
{

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

@@ -1,6 +1,6 @@
import 'dart:convert';
enum CastDestinationType { googleCast }
enum CastDestinationType { googleCast, fCast }
enum CastState { idle, playing, paused, buffering }

View File

@@ -1,19 +1,17 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/cast.service.dart';
final castProvider = StateNotifierProvider<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,
@@ -23,11 +21,11 @@ class CastNotifier extends StateNotifier<CastManagerState> {
castState: CastState.idle,
),
) {
_gCastService.onConnectionState = _onConnectionState;
_gCastService.onCurrentTime = _onCurrentTime;
_gCastService.onDuration = _onDuration;
_gCastService.onReceiverName = _onReceiverName;
_gCastService.onCastState = _onCastState;
_castService.onConnectionState = _onConnectionState;
_castService.onCurrentTime = _onCurrentTime;
_castService.onDuration = _onDuration;
_castService.onReceiverName = _onReceiverName;
_castService.onCastState = _onCastState;
}
void _onConnectionState(bool isCasting) {
@@ -51,23 +49,15 @@ class CastNotifier extends StateNotifier<CastManagerState> {
}
void loadMedia(RemoteAsset asset, bool reload) {
_gCastService.loadMedia(asset, reload);
_castService.loadMedia(asset, reload);
}
Future<void> connect(CastDestinationType type, dynamic device) async {
switch (type) {
case CastDestinationType.googleCast:
await _gCastService.connect(device);
break;
}
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<(String, CastDestinationType, dynamic)>> getDevices() {
return _castService.getDevices();
}
void toggle() {
@@ -81,22 +71,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,99 @@
import 'dart:async';
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final castRepositoryProvider = Provider((_) => CastRepository());
class CastRepository {
CastContext? _castContext;
CastingDevice? _device;
void Function(DeviceConnectionState)? onConnectionState;
void Function(DeviceEvent)? onDeviceEvent;
final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {};
int _currentDeviceGeneration = 0;
Future<void>? _initialized;
Future<void> connect(DeviceInfo deviceInfo) async {
await _ensureInitialized();
_device?.disconnect();
final device = _castContext!.createDeviceFromInfo(info: deviceInfo);
_device = device;
final thisDeviceGeneration = ++_currentDeviceGeneration;
device.connect(
eventHandler: DeviceEventHandler(
onEvent: (event) {
if (thisDeviceGeneration != _currentDeviceGeneration) {
return;
}
if (event is DeviceEvent_ConnectionStateChanged) {
onConnectionState?.call(event.newState);
}
onDeviceEvent?.call(event);
},
),
reconnectIntervalMillis: 1000,
);
}
Future<void> disconnect() async {
final device = _device;
if (device == null) {
return;
}
_device = null;
_currentDeviceGeneration++;
if (device.isReady()) {
device.stopPlayback();
await Future.delayed(const Duration(milliseconds: 500));
}
device.disconnect();
onConnectionState?.call(const DeviceConnectionState.disconnected());
}
void loadMedia(LoadRequest request) => _device?.load(request: request);
void play() => _device?.resumePlayback();
void pause() => _device?.pausePlayback();
void stop() => _device?.stopPlayback();
void seekTo(Duration position) => _device?.seek(timeSeconds: position.inMilliseconds / 1000);
Future<List<(DeviceInfo, int?)>> listDestinations() async {
final isFirstScan = _initialized == null;
await _ensureInitialized();
if (isFirstScan) {
await Future.delayed(const Duration(seconds: 3));
}
return _discoveredDevices.values.toList(growable: false);
}
Future<void> _ensureInitialized() => _initialized ??= _initialize();
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();
}
}

View File

@@ -1,68 +0,0 @@
import 'package:cast/device.dart';
import 'package:cast/session.dart';
import 'package:cast/session_manager.dart';
import 'package:cast/discovery_service.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final gCastRepositoryProvider = Provider((_) {
return GCastRepository();
});
class GCastRepository {
CastSession? _castSession;
void Function(CastSessionState)? onCastStatus;
void Function(Map<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,205 @@
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/cast.repository.dart';
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
import 'package:openapi/api.dart';
final castServiceProvider = Provider(
(ref) => CastService(
ref.watch(castRepositoryProvider),
ref.watch(sessionsAPIRepositoryProvider),
ref.watch(assetApiRepositoryProvider),
),
);
class CastService {
final CastRepository _castRepository;
final SessionsAPIRepository _sessionsApiService;
final AssetApiRepository _assetApiRepository;
SessionCreateResponse? sessionKey;
String? currentAssetId;
bool isConnected = false;
void Function(bool)? onConnectionState;
void Function(Duration)? onCurrentTime;
void Function(Duration)? onDuration;
void Function(String)? onReceiverName;
void Function(CastState)? onCastState;
CastService(this._castRepository, this._sessionsApiService, this._assetApiRepository) {
_castRepository.onConnectionState = _onCastStatusCallback;
_castRepository.onDeviceEvent = _onDeviceEventCallback;
}
void _onCastStatusCallback(DeviceConnectionState state) {
if (state is DeviceConnectionState_Connected) {
onConnectionState?.call(true);
isConnected = true;
} else if (state is DeviceConnectionState_Disconnected) {
onConnectionState?.call(false);
isConnected = false;
onReceiverName?.call("");
currentAssetId = null;
}
}
void _onDeviceEventCallback(DeviceEvent event) {
switch (event) {
case DeviceEvent_PlaybackStateChanged():
_handlePlaybackState(event.newPlaybackState);
break;
case DeviceEvent_TimeChanged():
onCurrentTime?.call(Duration(milliseconds: (event.newTime * 1000).toInt()));
break;
case DeviceEvent_DurationChanged():
onDuration?.call(Duration(milliseconds: (event.newDuration * 1000).toInt()));
break;
default:
break;
}
}
void _handlePlaybackState(PlaybackState state) {
switch (state) {
case PlaybackState.playing:
onCastState?.call(CastState.playing);
break;
case PlaybackState.paused:
onCastState?.call(CastState.paused);
break;
case PlaybackState.buffering:
onCastState?.call(CastState.buffering);
break;
case PlaybackState.idle:
onCastState?.call(CastState.idle);
break;
}
}
Future<void> connect(dynamic device) async {
await _castRepository.connect(device);
onReceiverName?.call(device.name);
}
Future<void> disconnect() async {
onReceiverName?.call("");
currentAssetId = null;
await _castRepository.disconnect();
}
bool isSessionValid() {
// check if we already have a session token
// we should always have a expiration date
if (sessionKey == null || sessionKey?.expiresAt == null) {
return false;
}
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
// we want to make sure we have at least 10 seconds remaining in the session
// this is to account for network latency and other delays when sending the request
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
return bufferedExpiration.isAfter(DateTime.now());
}
void loadMedia(RemoteAsset asset, bool reload) async {
if (!isConnected) {
return;
} else if (asset.id == currentAssetId && !reload) {
return;
}
// create a session key
if (!isSessionValid()) {
sessionKey = await _sessionsApiService.createSession(
"Cast",
"Cast",
duration: const Duration(minutes: 15).inSeconds,
);
}
final unauthenticatedUrl = asset.isVideo
? getPlaybackUrlForRemoteId(asset.id)
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
if (mimeType == null) {
return;
}
final request = asset.isVideo
? LoadRequest.video(contentType: mimeType, url: authenticatedURL, resumePosition: 0.0)
: LoadRequest.image(contentType: mimeType, url: authenticatedURL);
_castRepository.loadMedia(request);
currentAssetId = asset.id;
}
void play() {
_castRepository.play();
}
void pause() {
_castRepository.pause();
}
void seekTo(Duration position) {
_castRepository.seekTo(position);
}
void stop() {
_castRepository.stop();
currentAssetId = null;
}
// 0x01 is display capability bitmask
bool isDisplay(int ca) => (ca & 0x01) != 0;
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
final dests = await _castRepository.listDestinations();
final fCastNames = dests
.where((dest) => dest.$1.protocol == ProtocolType.fCast)
.map((dest) => dest.$1.name)
.toSet();
return dests
.where((dest) {
final (device, gcastCaps) = dest;
if (device.protocol == ProtocolType.fCast) {
return true;
}
return isDisplay(gcastCaps ?? 0) && !fCastNames.contains(device.name);
})
.map((dest) {
final device = dest.$1;
final type = device.protocol == ProtocolType.fCast
? CastDestinationType.fCast
: CastDestinationType.googleCast;
return (device.name, type, device as dynamic);
})
.toList(growable: false);
}
}

View File

@@ -1,250 +0,0 @@
import 'dart:async';
import 'package:cast/session.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/gcast.repository.dart';
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
import 'package:openapi/api.dart';
final gCastServiceProvider = Provider(
(ref) => GCastService(
ref.watch(gCastRepositoryProvider),
ref.watch(sessionsAPIRepositoryProvider),
ref.watch(assetApiRepositoryProvider),
),
);
class GCastService {
final GCastRepository _gCastRepository;
final SessionsAPIRepository _sessionsApiService;
final AssetApiRepository _assetApiRepository;
SessionCreateResponse? sessionKey;
String? currentAssetId;
bool isConnected = false;
int? _sessionId;
Timer? _mediaStatusPollingTimer;
void Function(bool)? onConnectionState;
void Function(Duration)? onCurrentTime;
void Function(Duration)? onDuration;
void Function(String)? onReceiverName;
void Function(CastState)? onCastState;
GCastService(this._gCastRepository, this._sessionsApiService, this._assetApiRepository) {
_gCastRepository.onCastStatus = _onCastStatusCallback;
_gCastRepository.onCastMessage = _onCastMessageCallback;
}
void _onCastStatusCallback(CastSessionState state) {
if (state == CastSessionState.connected) {
onConnectionState?.call(true);
isConnected = true;
} else if (state == CastSessionState.closed) {
onConnectionState?.call(false);
isConnected = false;
onReceiverName?.call("");
currentAssetId = null;
}
}
void _onCastMessageCallback(Map<String, dynamic> message) {
switch (message['type']) {
case "MEDIA_STATUS":
_handleMediaStatus(message);
break;
}
}
void _handleMediaStatus(Map<String, dynamic> message) {
final statusList = (message['status'] as List).whereType<Map<String, dynamic>>().toList();
if (statusList.isEmpty) {
return;
}
final status = statusList[0];
switch (status['playerState']) {
case "PLAYING":
onCastState?.call(CastState.playing);
break;
case "PAUSED":
onCastState?.call(CastState.paused);
break;
case "BUFFERING":
onCastState?.call(CastState.buffering);
break;
case "IDLE":
onCastState?.call(CastState.idle);
// stop polling for media status if the video finished playing
if (status["idleReason"] == "FINISHED") {
_mediaStatusPollingTimer?.cancel();
}
break;
}
if (status["media"] != null && status["media"]["duration"] != null) {
final duration = Duration(milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt());
onDuration?.call(duration);
}
if (status["mediaSessionId"] != null) {
_sessionId = status["mediaSessionId"];
}
if (status["currentTime"] != null) {
final currentTime = Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt());
onCurrentTime?.call(currentTime);
}
}
Future<void> connect(dynamic device) async {
await _gCastRepository.connect(device);
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
}
CastDestinationType getType() {
return CastDestinationType.googleCast;
}
Future<bool> initialize() async {
// there is nothing blocking us from using Google Cast that we can check for
return true;
}
Future<void> disconnect() async {
onReceiverName?.call("");
currentAssetId = null;
await _gCastRepository.disconnect();
}
bool isSessionValid() {
// check if we already have a session token
// we should always have a expiration date
if (sessionKey == null || sessionKey?.expiresAt == null) {
return false;
}
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
// we want to make sure we have at least 10 seconds remaining in the session
// this is to account for network latency and other delays when sending the request
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
return bufferedExpiration.isAfter(DateTime.now());
}
void loadMedia(RemoteAsset asset, bool reload) async {
if (!isConnected) {
return;
} else if (asset.id == currentAssetId && !reload) {
return;
}
// create a session key
if (!isSessionValid()) {
sessionKey = await _sessionsApiService.createSession(
"Cast",
"Google Cast",
duration: const Duration(minutes: 15).inSeconds,
);
}
final unauthenticatedUrl = asset.isVideo
? getPlaybackUrlForRemoteId(asset.id)
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
if (mimeType == null) {
return;
}
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "LOAD",
"media": {
"contentId": authenticatedURL,
"streamType": "BUFFERED",
"contentType": mimeType,
"contentUrl": authenticatedURL,
},
"autoplay": true,
});
currentAssetId = asset.id;
// we need to poll for media status since the cast device does not
// send a message when the media is loaded for whatever reason
// only do this on videos
_mediaStatusPollingTimer?.cancel();
if (asset.isVideo) {
_mediaStatusPollingTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
if (isConnected) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "GET_STATUS",
"mediaSessionId": _sessionId,
});
} else {
timer.cancel();
}
});
}
}
void play() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PLAY", "mediaSessionId": _sessionId});
}
void pause() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PAUSE", "mediaSessionId": _sessionId});
}
void seekTo(Duration position) {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "SEEK",
"mediaSessionId": _sessionId,
"currentTime": position.inSeconds,
});
}
void stop() {
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "STOP", "mediaSessionId": _sessionId});
_mediaStatusPollingTimer?.cancel();
currentAssetId = null;
}
// 0x01 is display capability bitmask
bool isDisplay(int ca) => (ca & 0x01) != 0;
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
final dests = await _gCastRepository.listDestinations();
return dests
.map((device) => (device.extras["fn"] ?? "Google Cast", CastDestinationType.googleCast, device))
.where((device) {
final caString = device.$3.extras["ca"];
final caNumber = int.tryParse(caString ?? "0") ?? 0;
return isDisplay(caNumber);
})
.toList(growable: false);
}
}

View File

@@ -69,7 +69,7 @@ class CastDialog extends ConsumerWidget {
child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(),
);
} else {
final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic);
final (deviceName, _, deviceObj) = item as (String, CastDestinationType, dynamic);
return ListTile(
title: Text(
@@ -77,7 +77,7 @@ class CastDialog extends ConsumerWidget {
style: TextStyle(color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null),
),
leading: Icon(
type == CastDestinationType.googleCast ? Icons.cast : Icons.cast_connected,
isCurrentDevice(deviceName) ? Icons.cast_connected : Icons.cast,
color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null,
),
trailing: isCurrentDevice(deviceName)
@@ -95,7 +95,7 @@ class CastDialog extends ConsumerWidget {
}
if (!isCurrentDevice(deviceName)) {
unawaited(ref.read(castProvider.notifier).connect(type, deviceObj));
unawaited(ref.read(castProvider.notifier).connect(deviceObj));
}
},
);

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: "1b112a966302a739d253c8dbc7ea4c1cb3eca6d8110dc59e36d76f7cf0b6edf2"
url: "https://pub.dev"
source: hosted
version: "5.1.11"
version: "6.1.0"
bonsoir_android:
dependency: transitive
description:
name: bonsoir_android
sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b"
sha256: bfa3ab7e2f65473cb369bce639dbdbdc75064848c01ac11b3c2bc3e16031ff3a
url: "https://pub.dev"
source: hosted
version: "5.1.6"
version: "6.0.2"
bonsoir_darwin:
dependency: transitive
description:
name: bonsoir_darwin
sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4"
sha256: d62fd62ed433aa09ec99f71f95dae53ffa0adf788c9051ff3d6b903463045d3c
url: "https://pub.dev"
source: hosted
version: "5.1.3"
version: "6.1.0"
bonsoir_linux:
dependency: transitive
description:
name: bonsoir_linux
sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2
sha256: a49d5f328a197b27a3901b833f92c93366f2cd7085dcb495fa11ee6d9f2509a9
url: "https://pub.dev"
source: hosted
version: "5.1.3"
version: "6.0.3"
bonsoir_platform_interface:
dependency: transitive
description:
name: bonsoir_platform_interface
sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45"
sha256: ba1cc30daaa172dfc76f88e4fee8d090674179439201997ba2b3bd9e1cca84c0
url: "https://pub.dev"
source: hosted
version: "5.1.3"
version: "6.1.0"
bonsoir_windows:
dependency: transitive
description:
name: bonsoir_windows
sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d
sha256: "01aba2516b776eb1deb68845124dc0a41095da108276d4b307bf19c4c3e2d9b1"
url: "https://pub.dev"
source: hosted
version: "5.1.5"
version: "6.0.3"
boolean_selector:
dependency: transitive
description:
@@ -137,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.6"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
build_config:
dependency: transitive
description:
@@ -177,14 +185,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.12.6"
cast:
dependency: "direct main"
description:
name: cast
sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
characters:
dependency: transitive
description:
@@ -418,6 +418,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
fcast_sender_sdk:
dependency: "direct main"
description:
name: fcast_sender_sdk
sha256: "4c4e0f51749a0930e26e42e6a3f456293c7f5f0ffed2ecd1e283c28de5e11b33"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
ffi:
dependency: "direct main"
description:
@@ -569,6 +577,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_rust_bridge:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -667,6 +683,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.2.14"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
@@ -1398,14 +1422,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.5"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
pub_semver:
dependency: transitive
description:

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.3
dev_dependencies:
auto_route_generator: ^10.5.0
@@ -113,7 +113,6 @@ dev_dependencies:
# cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API.
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
dependency_overrides:
bonsoir: ^5.1.11
objective_c:
git:
url: https://github.com/mertalev/native