wip casting

This commit is contained in:
bwees 2025-05-19 14:44:15 -05:00
parent 6c3087b585
commit 4d86773ffe
No known key found for this signature in database
9 changed files with 135 additions and 29 deletions

View File

@ -1778,6 +1778,7 @@
"start_date": "Start date",
"state": "State",
"status": "Status",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",

View File

@ -1,3 +1,4 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/cast_manager_state.dart';
abstract interface class ICastDestinationService {
@ -14,7 +15,7 @@ abstract interface class ICastDestinationService {
void Function(String)? onReceiverName;
void Function(CastState)? onCastState;
void loadMedia(String url, String sessionKey, bool reload);
void loadMedia(Asset asset, bool reload);
void play();
void pause();

View File

@ -20,6 +20,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
@ -355,6 +356,11 @@ class GalleryViewerPage extends HookConsumerWidget {
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(next);
});
// send image to casting if the server has it
if (newAsset.isRemote) {
ref.read(castProvider.notifier).loadMedia(newAsset, false);
}
},
builder: buildAsset,
),

View File

@ -1,6 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/cast_manager_state.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:openapi/api.dart';
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
@ -46,8 +48,8 @@ class CastNotifier extends StateNotifier<CastManagerState> {
state = state.copyWith(castState: castState);
}
void loadMedia(String url, String sessionKey, bool reload) {
_gCastService.loadMedia(url, sessionKey, reload);
void loadMedia(Asset asset, bool reload) {
_gCastService.loadMedia(asset, reload);
}
Future<void> connect(CastDestinationType type, dynamic device) async {
@ -59,16 +61,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
}
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
// return _gCastService.getDevices();
// delay for 2 seconds to simulate loading
await Future.delayed(const Duration(seconds: 2));
return Future<List<(String, CastDestinationType, dynamic)>>.value([
('Google Cast', CastDestinationType.googleCast, null),
('Apple TV', CastDestinationType.googleCast, null),
('Roku', CastDestinationType.googleCast, null),
('Fire TV', CastDestinationType.googleCast, null),
]);
return _gCastService.getDevices();
}
void play() {

View File

@ -29,7 +29,7 @@ class GCastRepository {
});
// open the default receiver
sendMessage({
sendMessage(CastSession.kNamespaceReceiver, {
'type': 'LAUNCH',
'appId': 'CC1AD845',
});
@ -41,12 +41,12 @@ class GCastRepository {
await _castSession?.close();
}
void sendMessage(Map<String, dynamic> message) {
void sendMessage(String namespace, Map<String, dynamic> message) {
if (_castSession == null) {
throw Exception("Cast session is not established");
}
_castSession!.sendMessage(CastSession.kNamespaceReceiver, message);
_castSession!.sendMessage(namespace, message);
}
Future<List<CastDevice>> listDestinations() async {

View File

@ -33,6 +33,7 @@ class ApiService implements Authentication {
late StacksApi stacksApi;
late ViewApi viewApi;
late MemoriesApi memoriesApi;
late SessionsApi sessionsApi;
ApiService() {
// The below line ensures that the api clients are initialized when the service is instantiated
@ -70,6 +71,7 @@ class ApiService implements Authentication {
stacksApi = StacksApi(_apiClient);
viewApi = ViewApi(_apiClient);
memoriesApi = MemoriesApi(_apiClient);
sessionsApi = SessionsApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View File

@ -2,17 +2,30 @@ import 'package:cast/device.dart';
import 'package:cast/session.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart';
import 'package:immich_mobile/models/cast_manager_state.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/gcast.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:openapi/api.dart' as api;
import 'package:uuid/uuid.dart';
final gCastServiceProvider =
Provider((ref) => GCastService(ref.watch(gCastRepositoryProvider)));
final gCastServiceProvider = Provider(
(ref) => GCastService(
ref.watch(gCastRepositoryProvider), ref.watch(apiServiceProvider)),
);
class GCastService implements ICastDestinationService {
final GCastRepository _gCastRepository;
final ApiService _apiService;
api.SessionCreateResponseDto? sessionKey;
String? currentAssetId;
bool isConnected = false;
@override
void Function(bool)? onConnectionState;
@ -25,7 +38,7 @@ class GCastService implements ICastDestinationService {
@override
void Function(CastState)? onCastState;
GCastService(this._gCastRepository) {
GCastService(this._gCastRepository, this._apiService) {
_gCastRepository.onCastStatus = _onCastStatusCallback;
_gCastRepository.onCastMessage = _onCastMessageCallback;
}
@ -33,8 +46,10 @@ class GCastService implements ICastDestinationService {
void _onCastStatusCallback(CastSessionState state) {
if (state == CastSessionState.connected) {
onConnectionState?.call(true);
isConnected = true;
} else if (state == CastSessionState.closed) {
onConnectionState?.call(false);
isConnected = false;
}
}
@ -50,6 +65,8 @@ class GCastService implements ICastDestinationService {
Future<void> connect(CastDevice device) async {
await _gCastRepository.connect(device);
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
}
@override
@ -68,6 +85,8 @@ class GCastService implements ICastDestinationService {
@override
void disconnect() {
_gCastRepository.disconnect();
onReceiverName?.call("");
}
@override
@ -79,8 +98,58 @@ class GCastService implements ICastDestinationService {
}
@override
void loadMedia(String url, String sessionKey, bool reload) {
// TODO: implement loadMedia
void loadMedia(Asset asset, bool reload) async {
print("Casting media: ${asset.remoteId}");
if (!isConnected) {
return;
} else if (asset.remoteId == null) {
return;
} else if (asset.remoteId == currentAssetId && !reload) {
return;
}
// create a session key
sessionKey ??= await _apiService.sessionsApi.createSession(
api.SessionCreateDto(
deviceOS: "Google Cast",
deviceType: "Google Cast",
duration: const Duration(minutes: 15).inSeconds,
),
);
final unauthenticatedUrl = asset.isVideo
? getOriginalUrlForRemoteId(
asset.remoteId!,
)
: getThumbnailUrlForRemoteId(
asset.remoteId!,
type: api.AssetMediaSize.thumbnail,
);
final authenticatedURL =
"$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
// get image mime type
final info = await _apiService.assetsApi.getAssetInfo(asset.remoteId!);
final mimeType = info?.originalMimeType;
if (mimeType == null) {
return;
}
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
"type": "LOAD",
"media": {
"contentId": authenticatedURL,
"streamType": "LIVE",
"contentType": mimeType,
"contentUrl": authenticatedURL,
},
"autoplay": true,
});
print("Sending message: $authenticatedURL");
}
@override
@ -103,7 +172,13 @@ class GCastService implements ICastDestinationService {
final dests = await _gCastRepository.listDestinations();
return dests
.map((device) => (device.name, CastDestinationType.googleCast, device))
.map(
(device) => (
device.extras["fn"] ?? "Google Cast",
CastDestinationType.googleCast,
device
),
)
.toList(growable: false);
}
}

View File

@ -10,6 +10,8 @@ class CastDialog extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final castManager = ref.watch(castProvider);
return AlertDialog(
title: const Text(
"cast",
@ -21,8 +23,6 @@ class CastDialog extends ConsumerWidget {
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
future: ref.read(castProvider.notifier).getDevices(),
builder: (context, snapshot) {
final cast = ref.read(castProvider.notifier);
if (snapshot.hasError) {
return Text(
'Error: ${snapshot.error.toString()}',
@ -50,14 +50,25 @@ class CastDialog extends ConsumerWidget {
final deviceObj = found.$3;
return ListTile(
title: Text(deviceName),
title: Text(
deviceName,
style: TextStyle(
color: castManager.receiverName == deviceName
? context.colorScheme.primary
: null,
),
),
leading: Icon(
type == CastDestinationType.googleCast
? Icons.cast
: Icons.cast_connected,
),
trailing: castManager.isCasting &&
castManager.receiverName == deviceName
? Icon(Icons.check, color: context.colorScheme.primary)
: null,
onTap: () {
cast.connect(type, deviceObj);
ref.read(castProvider.notifier).connect(type, deviceObj);
},
);
},
@ -66,12 +77,23 @@ class CastDialog extends ConsumerWidget {
),
),
actions: [
if (castManager.isCasting)
TextButton(
onPressed: () => ref.read(castProvider.notifier).disconnect(),
child: Text(
"stop_casting",
style: TextStyle(
color: context.colorScheme.secondary,
fontWeight: FontWeight.bold,
),
).tr(),
),
TextButton(
onPressed: () => context.pop(),
child: Text(
"close",
style: TextStyle(
color: context.colorScheme.tertiary,
color: context.colorScheme.primary,
fontWeight: FontWeight.bold,
),
).tr(),

View File

@ -6,6 +6,7 @@ import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
@ -45,6 +46,7 @@ class TopControlAppBar extends HookConsumerWidget {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
final castManager = ref.watch(castProvider);
final comments = album != null &&
album.remoteId != null &&
asset.remoteId != null
@ -174,10 +176,14 @@ class TopControlAppBar extends HookConsumerWidget {
return IconButton(
onPressed: () {
showDialog(
context: context, builder: (context) => const CastDialog());
context: context,
builder: (context) => const CastDialog(),
);
},
icon: Icon(
Icons.cast,
castManager.isCasting
? Icons.cast_connected_rounded
: Icons.cast_rounded,
size: 20.0,
color: Colors.grey[200],
),