mirror of
https://github.com/immich-app/immich.git
synced 2025-05-30 19:54:52 -04:00
wip casting
This commit is contained in:
parent
6c3087b585
commit
4d86773ffe
@ -1778,6 +1778,7 @@
|
|||||||
"start_date": "Start date",
|
"start_date": "Start date",
|
||||||
"state": "State",
|
"state": "State",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
"stop_casting": "Stop casting",
|
||||||
"stop_motion_photo": "Stop Motion Photo",
|
"stop_motion_photo": "Stop Motion Photo",
|
||||||
"stop_photo_sharing": "Stop sharing your photos?",
|
"stop_photo_sharing": "Stop sharing your photos?",
|
||||||
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/models/cast_manager_state.dart';
|
import 'package:immich_mobile/models/cast_manager_state.dart';
|
||||||
|
|
||||||
abstract interface class ICastDestinationService {
|
abstract interface class ICastDestinationService {
|
||||||
@ -14,7 +15,7 @@ abstract interface class ICastDestinationService {
|
|||||||
void Function(String)? onReceiverName;
|
void Function(String)? onReceiverName;
|
||||||
void Function(CastState)? onCastState;
|
void Function(CastState)? onCastState;
|
||||||
|
|
||||||
void loadMedia(String url, String sessionKey, bool reload);
|
void loadMedia(Asset asset, bool reload);
|
||||||
|
|
||||||
void play();
|
void play();
|
||||||
void pause();
|
void pause();
|
||||||
|
@ -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/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.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/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/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.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), () {
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
precacheNextImage(next);
|
precacheNextImage(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// send image to casting if the server has it
|
||||||
|
if (newAsset.isRemote) {
|
||||||
|
ref.read(castProvider.notifier).loadMedia(newAsset, false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
builder: buildAsset,
|
builder: buildAsset,
|
||||||
),
|
),
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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/models/cast_manager_state.dart';
|
||||||
import 'package:immich_mobile/services/gcast.service.dart';
|
import 'package:immich_mobile/services/gcast.service.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
||||||
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
||||||
@ -46,8 +48,8 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
|||||||
state = state.copyWith(castState: castState);
|
state = state.copyWith(castState: castState);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadMedia(String url, String sessionKey, bool reload) {
|
void loadMedia(Asset asset, bool reload) {
|
||||||
_gCastService.loadMedia(url, sessionKey, reload);
|
_gCastService.loadMedia(asset, reload);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connect(CastDestinationType type, dynamic device) async {
|
Future<void> connect(CastDestinationType type, dynamic device) async {
|
||||||
@ -59,16 +61,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||||
// return _gCastService.getDevices();
|
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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
|
@ -29,7 +29,7 @@ class GCastRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// open the default receiver
|
// open the default receiver
|
||||||
sendMessage({
|
sendMessage(CastSession.kNamespaceReceiver, {
|
||||||
'type': 'LAUNCH',
|
'type': 'LAUNCH',
|
||||||
'appId': 'CC1AD845',
|
'appId': 'CC1AD845',
|
||||||
});
|
});
|
||||||
@ -41,12 +41,12 @@ class GCastRepository {
|
|||||||
await _castSession?.close();
|
await _castSession?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendMessage(Map<String, dynamic> message) {
|
void sendMessage(String namespace, Map<String, dynamic> message) {
|
||||||
if (_castSession == null) {
|
if (_castSession == null) {
|
||||||
throw Exception("Cast session is not established");
|
throw Exception("Cast session is not established");
|
||||||
}
|
}
|
||||||
|
|
||||||
_castSession!.sendMessage(CastSession.kNamespaceReceiver, message);
|
_castSession!.sendMessage(namespace, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<CastDevice>> listDestinations() async {
|
Future<List<CastDevice>> listDestinations() async {
|
||||||
|
@ -33,6 +33,7 @@ class ApiService implements Authentication {
|
|||||||
late StacksApi stacksApi;
|
late StacksApi stacksApi;
|
||||||
late ViewApi viewApi;
|
late ViewApi viewApi;
|
||||||
late MemoriesApi memoriesApi;
|
late MemoriesApi memoriesApi;
|
||||||
|
late SessionsApi sessionsApi;
|
||||||
|
|
||||||
ApiService() {
|
ApiService() {
|
||||||
// The below line ensures that the api clients are initialized when the service is instantiated
|
// 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);
|
stacksApi = StacksApi(_apiClient);
|
||||||
viewApi = ViewApi(_apiClient);
|
viewApi = ViewApi(_apiClient);
|
||||||
memoriesApi = MemoriesApi(_apiClient);
|
memoriesApi = MemoriesApi(_apiClient);
|
||||||
|
sessionsApi = SessionsApi(_apiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> resolveAndSetEndpoint(String serverUrl) async {
|
Future<String> resolveAndSetEndpoint(String serverUrl) async {
|
||||||
|
@ -2,17 +2,30 @@ import 'package:cast/device.dart';
|
|||||||
import 'package:cast/session.dart';
|
import 'package:cast/session.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart';
|
import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart';
|
||||||
import 'package:immich_mobile/models/cast_manager_state.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/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:immich_mobile/utils/url_helper.dart';
|
||||||
|
import 'package:openapi/api.dart' as api;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
final gCastServiceProvider =
|
final gCastServiceProvider = Provider(
|
||||||
Provider((ref) => GCastService(ref.watch(gCastRepositoryProvider)));
|
(ref) => GCastService(
|
||||||
|
ref.watch(gCastRepositoryProvider), ref.watch(apiServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
class GCastService implements ICastDestinationService {
|
class GCastService implements ICastDestinationService {
|
||||||
final GCastRepository _gCastRepository;
|
final GCastRepository _gCastRepository;
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
api.SessionCreateResponseDto? sessionKey;
|
||||||
|
String? currentAssetId;
|
||||||
|
bool isConnected = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void Function(bool)? onConnectionState;
|
void Function(bool)? onConnectionState;
|
||||||
@ -25,7 +38,7 @@ class GCastService implements ICastDestinationService {
|
|||||||
@override
|
@override
|
||||||
void Function(CastState)? onCastState;
|
void Function(CastState)? onCastState;
|
||||||
|
|
||||||
GCastService(this._gCastRepository) {
|
GCastService(this._gCastRepository, this._apiService) {
|
||||||
_gCastRepository.onCastStatus = _onCastStatusCallback;
|
_gCastRepository.onCastStatus = _onCastStatusCallback;
|
||||||
_gCastRepository.onCastMessage = _onCastMessageCallback;
|
_gCastRepository.onCastMessage = _onCastMessageCallback;
|
||||||
}
|
}
|
||||||
@ -33,8 +46,10 @@ class GCastService implements ICastDestinationService {
|
|||||||
void _onCastStatusCallback(CastSessionState state) {
|
void _onCastStatusCallback(CastSessionState state) {
|
||||||
if (state == CastSessionState.connected) {
|
if (state == CastSessionState.connected) {
|
||||||
onConnectionState?.call(true);
|
onConnectionState?.call(true);
|
||||||
|
isConnected = true;
|
||||||
} else if (state == CastSessionState.closed) {
|
} else if (state == CastSessionState.closed) {
|
||||||
onConnectionState?.call(false);
|
onConnectionState?.call(false);
|
||||||
|
isConnected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +65,8 @@ class GCastService implements ICastDestinationService {
|
|||||||
|
|
||||||
Future<void> connect(CastDevice device) async {
|
Future<void> connect(CastDevice device) async {
|
||||||
await _gCastRepository.connect(device);
|
await _gCastRepository.connect(device);
|
||||||
|
|
||||||
|
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -68,6 +85,8 @@ class GCastService implements ICastDestinationService {
|
|||||||
@override
|
@override
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
_gCastRepository.disconnect();
|
_gCastRepository.disconnect();
|
||||||
|
|
||||||
|
onReceiverName?.call("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -79,8 +98,58 @@ class GCastService implements ICastDestinationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void loadMedia(String url, String sessionKey, bool reload) {
|
void loadMedia(Asset asset, bool reload) async {
|
||||||
// TODO: implement loadMedia
|
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
|
@override
|
||||||
@ -103,7 +172,13 @@ class GCastService implements ICastDestinationService {
|
|||||||
final dests = await _gCastRepository.listDestinations();
|
final dests = await _gCastRepository.listDestinations();
|
||||||
|
|
||||||
return dests
|
return dests
|
||||||
.map((device) => (device.name, CastDestinationType.googleCast, device))
|
.map(
|
||||||
|
(device) => (
|
||||||
|
device.extras["fn"] ?? "Google Cast",
|
||||||
|
CastDestinationType.googleCast,
|
||||||
|
device
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,8 @@ class CastDialog extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final castManager = ref.watch(castProvider);
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"cast",
|
"cast",
|
||||||
@ -21,8 +23,6 @@ class CastDialog extends ConsumerWidget {
|
|||||||
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
|
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
|
||||||
future: ref.read(castProvider.notifier).getDevices(),
|
future: ref.read(castProvider.notifier).getDevices(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final cast = ref.read(castProvider.notifier);
|
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
return Text(
|
return Text(
|
||||||
'Error: ${snapshot.error.toString()}',
|
'Error: ${snapshot.error.toString()}',
|
||||||
@ -50,14 +50,25 @@ class CastDialog extends ConsumerWidget {
|
|||||||
final deviceObj = found.$3;
|
final deviceObj = found.$3;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(deviceName),
|
title: Text(
|
||||||
|
deviceName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: castManager.receiverName == deviceName
|
||||||
|
? context.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
type == CastDestinationType.googleCast
|
type == CastDestinationType.googleCast
|
||||||
? Icons.cast
|
? Icons.cast
|
||||||
: Icons.cast_connected,
|
: Icons.cast_connected,
|
||||||
),
|
),
|
||||||
|
trailing: castManager.isCasting &&
|
||||||
|
castManager.receiverName == deviceName
|
||||||
|
? Icon(Icons.check, color: context.colorScheme.primary)
|
||||||
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
cast.connect(type, deviceObj);
|
ref.read(castProvider.notifier).connect(type, deviceObj);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -66,12 +77,23 @@ class CastDialog extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
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(
|
TextButton(
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
child: Text(
|
child: Text(
|
||||||
"close",
|
"close",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: context.colorScheme.tertiary,
|
color: context.colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
|
@ -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/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.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/providers/tab.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.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;
|
const double iconSize = 22.0;
|
||||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||||
final album = ref.watch(currentAlbumProvider);
|
final album = ref.watch(currentAlbumProvider);
|
||||||
|
final castManager = ref.watch(castProvider);
|
||||||
final comments = album != null &&
|
final comments = album != null &&
|
||||||
album.remoteId != null &&
|
album.remoteId != null &&
|
||||||
asset.remoteId != null
|
asset.remoteId != null
|
||||||
@ -174,10 +176,14 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context, builder: (context) => const CastDialog());
|
context: context,
|
||||||
|
builder: (context) => const CastDialog(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.cast,
|
castManager.isCasting
|
||||||
|
? Icons.cast_connected_rounded
|
||||||
|
: Icons.cast_rounded,
|
||||||
size: 20.0,
|
size: 20.0,
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
),
|
),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user