Kaynağa Gözat

[mob][photos] Add support for casting (#1556)

## Description

## Tests
Vishnu Mohandas 1 yıl önce
ebeveyn
işleme
569f7c0c47

+ 9 - 0
mobile/ios/Runner/Info.plist

@@ -105,5 +105,14 @@
 		<true/>
 		<key>UIApplicationSupportsIndirectInputEvents</key>
 		<true/>
+		<key>NSBonjourServices</key>
+                <array>
+                  <string>_googlecast._tcp</string>
+                  <string>F5BCEC64._googlecast._tcp</string>
+                </array>
+
+                <key>NSLocalNetworkUsageDescription</key>
+                <string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi
+                network.</string>
 	</dict>
 </plist>

+ 12 - 4
mobile/lib/gateways/cast_gw.dart

@@ -12,10 +12,14 @@ class CastGateway {
       );
       return response.data["publicKey"];
     } catch (e) {
-      if (e is DioError &&
-          e.response != null &&
-          e.response!.statusCode == 404) {
-        return null;
+      if (e is DioError && e.response != null) {
+        if (e.response!.statusCode == 404) {
+          return null;
+        } else if (e.response!.statusCode == 403) {
+          throw CastIPMismatchException();
+        } else {
+          rethrow;
+        }
       }
       rethrow;
     }
@@ -48,3 +52,7 @@ class CastGateway {
     }
   }
 }
+
+class CastIPMismatchException implements Exception {
+  CastIPMismatchException();
+}

+ 24 - 0
mobile/lib/generated/intl/messages_en.dart

@@ -357,6 +357,13 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Authentication failed, please try again"),
         "authenticationSuccessful":
             MessageLookupByLibrary.simpleMessage("Authentication successful!"),
+        "autoCastDialogBody": MessageLookupByLibrary.simpleMessage(
+            "You\'ll see available Cast devices here."),
+        "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage(
+            "Make sure Local Network permissions are turned on for the Ente Photos app, in Settings."),
+        "autoPair": MessageLookupByLibrary.simpleMessage("Auto pair"),
+        "autoPairGoogle": MessageLookupByLibrary.simpleMessage(
+            "Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos."),
         "available": MessageLookupByLibrary.simpleMessage("Available"),
         "backedUpFolders":
             MessageLookupByLibrary.simpleMessage("Backed up folders"),
@@ -387,6 +394,10 @@ class MessageLookup extends MessageLookupByLibrary {
         "cannotAddMorePhotosAfterBecomingViewer": m9,
         "cannotDeleteSharedFiles":
             MessageLookupByLibrary.simpleMessage("Cannot delete shared files"),
+        "castIPMismatchBody": MessageLookupByLibrary.simpleMessage(
+            "Please make sure you are on the same network as the TV."),
+        "castIPMismatchTitle":
+            MessageLookupByLibrary.simpleMessage("Failed to cast album"),
         "castInstruction": MessageLookupByLibrary.simpleMessage(
             "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV."),
         "centerPoint": MessageLookupByLibrary.simpleMessage("Center point"),
@@ -460,6 +471,8 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Confirm recovery key"),
         "confirmYourRecoveryKey":
             MessageLookupByLibrary.simpleMessage("Confirm your recovery key"),
+        "connectToDevice":
+            MessageLookupByLibrary.simpleMessage("Connect to device"),
         "contactFamilyAdmin": m12,
         "contactSupport":
             MessageLookupByLibrary.simpleMessage("Contact support"),
@@ -904,6 +917,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "manageParticipants": MessageLookupByLibrary.simpleMessage("Manage"),
         "manageSubscription":
             MessageLookupByLibrary.simpleMessage("Manage subscription"),
+        "manualPairDesc": MessageLookupByLibrary.simpleMessage(
+            "Pair with PIN works for any large screen device you want to play your album on."),
         "map": MessageLookupByLibrary.simpleMessage("Map"),
         "maps": MessageLookupByLibrary.simpleMessage("Maps"),
         "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"),
@@ -938,6 +953,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "no": MessageLookupByLibrary.simpleMessage("No"),
         "noAlbumsSharedByYouYet":
             MessageLookupByLibrary.simpleMessage("No albums shared by you yet"),
+        "noDeviceFound":
+            MessageLookupByLibrary.simpleMessage("No device found"),
         "noDeviceLimit": MessageLookupByLibrary.simpleMessage("None"),
         "noDeviceThatCanBeDeleted": MessageLookupByLibrary.simpleMessage(
             "You\'ve no files on this device that can be deleted"),
@@ -984,6 +1001,9 @@ class MessageLookup extends MessageLookupByLibrary {
         "orPickAnExistingOne":
             MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
         "pair": MessageLookupByLibrary.simpleMessage("Pair"),
+        "pairWithPin": MessageLookupByLibrary.simpleMessage("Pair with PIN"),
+        "pairingComplete":
+            MessageLookupByLibrary.simpleMessage("Pairing complete"),
         "passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
         "passkeyAuthTitle":
             MessageLookupByLibrary.simpleMessage("Passkey verification"),
@@ -1330,6 +1350,10 @@ class MessageLookup extends MessageLookupByLibrary {
         "sparkleSuccess": MessageLookupByLibrary.simpleMessage("✨ Success"),
         "startBackup": MessageLookupByLibrary.simpleMessage("Start backup"),
         "status": MessageLookupByLibrary.simpleMessage("Status"),
+        "stopCastingBody": MessageLookupByLibrary.simpleMessage(
+            "Do you want to stop casting?"),
+        "stopCastingTitle":
+            MessageLookupByLibrary.simpleMessage("Stop casting"),
         "storage": MessageLookupByLibrary.simpleMessage("Storage"),
         "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Family"),
         "storageBreakupYou": MessageLookupByLibrary.simpleMessage("You"),

+ 130 - 0
mobile/lib/generated/l10n.dart

@@ -8388,6 +8388,26 @@ class S {
     );
   }
 
+  /// `Auto pair`
+  String get autoPair {
+    return Intl.message(
+      'Auto pair',
+      name: 'autoPair',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pair with PIN`
+  String get pairWithPin {
+    return Intl.message(
+      'Pair with PIN',
+      name: 'pairWithPin',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `Device not found`
   String get deviceNotFound {
     return Intl.message(
@@ -8573,6 +8593,116 @@ class S {
       args: [],
     );
   }
+
+  /// `Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos.`
+  String get autoPairGoogle {
+    return Intl.message(
+      'Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos.',
+      name: 'autoPairGoogle',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pair with PIN works for any large screen device you want to play your album on.`
+  String get manualPairDesc {
+    return Intl.message(
+      'Pair with PIN works for any large screen device you want to play your album on.',
+      name: 'manualPairDesc',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Connect to device`
+  String get connectToDevice {
+    return Intl.message(
+      'Connect to device',
+      name: 'connectToDevice',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `You'll see available Cast devices here.`
+  String get autoCastDialogBody {
+    return Intl.message(
+      'You\'ll see available Cast devices here.',
+      name: 'autoCastDialogBody',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.`
+  String get autoCastiOSPermission {
+    return Intl.message(
+      'Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.',
+      name: 'autoCastiOSPermission',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `No device found`
+  String get noDeviceFound {
+    return Intl.message(
+      'No device found',
+      name: 'noDeviceFound',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Stop casting`
+  String get stopCastingTitle {
+    return Intl.message(
+      'Stop casting',
+      name: 'stopCastingTitle',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Do you want to stop casting?`
+  String get stopCastingBody {
+    return Intl.message(
+      'Do you want to stop casting?',
+      name: 'stopCastingBody',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Failed to cast album`
+  String get castIPMismatchTitle {
+    return Intl.message(
+      'Failed to cast album',
+      name: 'castIPMismatchTitle',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Please make sure you are on the same network as the TV.`
+  String get castIPMismatchBody {
+    return Intl.message(
+      'Please make sure you are on the same network as the TV.',
+      name: 'castIPMismatchBody',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pairing complete`
+  String get pairingComplete {
+    return Intl.message(
+      'Pairing complete',
+      name: 'pairingComplete',
+      desc: '',
+      args: [],
+    );
+  }
 }
 
 class AppLocalizationDelegate extends LocalizationsDelegate<S> {

+ 14 - 1
mobile/lib/l10n/intl_en.arb

@@ -1196,6 +1196,8 @@
   "verifyPasskey": "Verify passkey",
   "playOnTv": "Play album on TV",
   "pair": "Pair",
+  "autoPair": "Auto pair",
+  "pairWithPin": "Pair with PIN",
   "deviceNotFound": "Device not found",
   "castInstruction": "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.",
   "deviceCodeHint": "Enter the code",
@@ -1213,5 +1215,16 @@
   "endpointUpdatedMessage": "Endpoint updated successfully",
   "customEndpoint": "Connected to {endpoint}",
   "createCollaborativeLink": "Create collaborative link",
-  "search": "Search"
+  "search": "Search",
+  "autoPairGoogle": "Auto Pair requires connecting to Google servers and only works with Chromecast supported devices. Google will not receive sensitive data, such as your photos.",
+  "manualPairDesc": "Pair with PIN works for any large screen device you want to play your album on.",
+  "connectToDevice": "Connect to device",
+  "autoCastDialogBody": "You'll see available Cast devices here.",
+  "autoCastiOSPermission": "Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.",
+  "noDeviceFound": "No device found",
+  "stopCastingTitle": "Stop casting",
+  "stopCastingBody": "Do you want to stop casting?",
+  "castIPMismatchTitle": "Failed to cast album",
+  "castIPMismatchBody": "Please make sure you are on the same network as the TV.",
+  "pairingComplete": "Pairing complete"
 }

+ 8 - 0
mobile/lib/service_locator.dart

@@ -1,4 +1,6 @@
 import "package:dio/dio.dart";
+import "package:ente_cast/ente_cast.dart";
+import "package:ente_cast_normal/ente_cast_normal.dart";
 import "package:ente_feature_flag/ente_feature_flag.dart";
 import "package:shared_preferences/shared_preferences.dart";
 
@@ -26,3 +28,9 @@ FlagService get flagService {
   );
   return _flagService!;
 }
+
+CastService? _castService;
+CastService get castService {
+  _castService ??= CastServiceImpl();
+  return _castService!;
+}

+ 128 - 0
mobile/lib/ui/cast/auto.dart

@@ -0,0 +1,128 @@
+import "dart:io";
+
+import "package:ente_cast/ente_cast.dart";
+import "package:flutter/material.dart";
+import "package:photos/generated/l10n.dart";
+import "package:photos/service_locator.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/utils/dialog_util.dart";
+
+class AutoCastDialog extends StatefulWidget {
+  // async method that takes string as input
+  // and returns void
+  final void Function(String) onConnect;
+  AutoCastDialog(
+    this.onConnect, {
+    Key? key,
+  }) : super(key: key) {}
+
+  @override
+  State<AutoCastDialog> createState() => _AutoCastDialogState();
+}
+
+class _AutoCastDialogState extends State<AutoCastDialog> {
+  final bool doesUserExist = true;
+  final Set<Object> _isDeviceTapInProgress = {};
+
+  @override
+  Widget build(BuildContext context) {
+    final textStyle = getEnteTextTheme(context);
+    final AlertDialog alert = AlertDialog(
+      title: Text(
+        S.of(context).connectToDevice,
+        style: textStyle.largeBold,
+      ),
+      content: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Text(
+            S.of(context).autoCastDialogBody,
+            style: textStyle.bodyMuted,
+          ),
+          if (Platform.isIOS)
+            Text(
+              S.of(context).autoCastiOSPermission,
+              style: textStyle.bodyMuted,
+            ),
+          const SizedBox(height: 16),
+          FutureBuilder<List<(String, Object)>>(
+            future: castService.searchDevices(),
+            builder: (context, snapshot) {
+              if (snapshot.hasError) {
+                return Center(
+                  child: Text(
+                    'Error: ${snapshot.error.toString()}',
+                  ),
+                );
+              } else if (!snapshot.hasData) {
+                return const EnteLoadingWidget();
+              }
+
+              if (snapshot.data!.isEmpty) {
+                return Center(child: Text(S.of(context).noDeviceFound));
+              }
+
+              return Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: snapshot.data!.map((result) {
+                  final device = result.$2;
+                  final name = result.$1;
+                  return GestureDetector(
+                    onTap: () async {
+                      if (_isDeviceTapInProgress.contains(device)) {
+                        return;
+                      }
+                      setState(() {
+                        _isDeviceTapInProgress.add(device);
+                      });
+                      try {
+                        await _connectToYourApp(context, device);
+                      } catch (e) {
+                        showGenericErrorDialog(context: context, error: e)
+                            .ignore();
+                      } finally {
+                        setState(() {
+                          _isDeviceTapInProgress.remove(device);
+                        });
+                      }
+                    },
+                    child: Padding(
+                      padding: const EdgeInsets.symmetric(vertical: 8.0),
+                      child: Row(
+                        children: [
+                          Expanded(child: Text(name)),
+                          if (_isDeviceTapInProgress.contains(device))
+                            const EnteLoadingWidget(),
+                        ],
+                      ),
+                    ),
+                  );
+                }).toList(),
+              );
+            },
+          ),
+        ],
+      ),
+    );
+    return alert;
+  }
+
+  Future<void> _connectToYourApp(
+    BuildContext context,
+    Object castDevice,
+  ) async {
+    await castService.connectDevice(
+      context,
+      castDevice,
+      onMessage: (message) {
+        if (message.containsKey(CastMessageType.pairCode)) {
+          final code = message[CastMessageType.pairCode]!['code'];
+          widget.onConnect(code);
+          Navigator.of(context).pop();
+        }
+      },
+    );
+  }
+}

+ 76 - 0
mobile/lib/ui/cast/choose.dart

@@ -0,0 +1,76 @@
+import "package:flutter/material.dart";
+import "package:photos/generated/l10n.dart";
+import "package:photos/l10n/l10n.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/components/buttons/button_widget.dart";
+import "package:photos/ui/components/models/button_type.dart";
+
+class CastChooseDialog extends StatefulWidget {
+  const CastChooseDialog({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<CastChooseDialog> createState() => _CastChooseDialogState();
+}
+
+class _CastChooseDialogState extends State<CastChooseDialog> {
+  final bool doesUserExist = true;
+
+  @override
+  Widget build(BuildContext context) {
+    final textStyle = getEnteTextTheme(context);
+    final AlertDialog alert = AlertDialog(
+      title: Text(
+        context.l10n.playOnTv,
+        style: textStyle.largeBold,
+      ),
+      content: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          const SizedBox(height: 8),
+          Text(
+            S.of(context).autoPairGoogle,
+            style: textStyle.bodyMuted,
+          ),
+          const SizedBox(height: 12),
+          ButtonWidget(
+            labelText: S.of(context).autoPair,
+            icon: Icons.cast_outlined,
+            buttonType: ButtonType.neutral,
+            buttonSize: ButtonSize.large,
+            shouldStickToDarkTheme: true,
+            buttonAction: ButtonAction.first,
+            shouldSurfaceExecutionStates: false,
+            isInAlert: true,
+            onTap: () async {
+              Navigator.of(context).pop(ButtonAction.first);
+            },
+          ),
+          const SizedBox(height: 36),
+          Text(
+            S.of(context).manualPairDesc,
+            style: textStyle.bodyMuted,
+          ),
+          const SizedBox(height: 12),
+          ButtonWidget(
+            labelText: S.of(context).pairWithPin,
+            buttonType: ButtonType.neutral,
+            // icon for pairing with TV manually
+            icon: Icons.tv_outlined,
+            buttonSize: ButtonSize.large,
+            isInAlert: true,
+            onTap: () async {
+              Navigator.of(context).pop(ButtonAction.second);
+            },
+            shouldStickToDarkTheme: true,
+            buttonAction: ButtonAction.second,
+            shouldSurfaceExecutionStates: false,
+          ),
+        ],
+      ),
+    );
+    return alert;
+  }
+}

+ 109 - 21
mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -24,6 +24,8 @@ import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/sync_service.dart';
 import 'package:photos/services/update_service.dart';
 import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
+import "package:photos/ui/cast/auto.dart";
+import "package:photos/ui/cast/choose.dart";
 import "package:photos/ui/common/popup_item.dart";
 import 'package:photos/ui/components/action_sheet_widget.dart';
 import 'package:photos/ui/components/buttons/button_widget.dart';
@@ -320,6 +322,25 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
         ),
       );
     }
+
+    if (widget.collection != null && castService.isSupported) {
+      actions.add(
+        Tooltip(
+          message: "Cast album",
+          child: IconButton(
+            icon: castService.getActiveSessions().isNotEmpty
+                ? const Icon(Icons.cast_connected_rounded)
+                : const Icon(Icons.cast_outlined),
+            onPressed: () async {
+              await _castChoiceDialog();
+              if (mounted) {
+                setState(() {});
+              }
+            },
+          ),
+        ),
+      );
+    }
     final List<EntePopupMenuItem<AlbumPopupAction>> items = [];
     items.addAll([
       if (galleryType.canRename())
@@ -458,7 +479,7 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
             } else if (value == AlbumPopupAction.leave) {
               await _leaveAlbum(context);
             } else if (value == AlbumPopupAction.playOnTv) {
-              await castAlbum();
+              await _castChoiceDialog();
             } else if (value == AlbumPopupAction.freeUpSpace) {
               await _deleteBackedUpFiles(context);
             } else if (value == AlbumPopupAction.setCover) {
@@ -693,10 +714,56 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     setState(() {});
   }
 
-  Future<void> castAlbum() async {
+  Future<void> _castChoiceDialog() async {
     final gw = CastGateway(NetworkClient.instance.enteDio);
+    if (castService.getActiveSessions().isNotEmpty) {
+      await showChoiceDialog(
+        context,
+        title: S.of(context).stopCastingTitle,
+        firstButtonLabel: S.of(context).yes,
+        secondButtonLabel: S.of(context).no,
+        body: S.of(context).stopCastingBody,
+        firstButtonOnTap: () async {
+          gw.revokeAllTokens().ignore();
+          await castService.closeActiveCasts();
+        },
+      );
+      return;
+    }
+
     // stop any existing cast session
     gw.revokeAllTokens().ignore();
+    final result = await showDialog<ButtonAction?>(
+      context: context,
+      barrierDismissible: true,
+      builder: (BuildContext context) {
+        return const CastChooseDialog();
+      },
+    );
+    if (result == null) {
+      return;
+    }
+    // wait to allow the dialog to close
+    await Future.delayed(const Duration(milliseconds: 100));
+    if (result == ButtonAction.first) {
+      await showDialog(
+        context: context,
+        barrierDismissible: true,
+        builder: (BuildContext context) {
+          return AutoCastDialog(
+            (device) async {
+              await _castPair(gw, device);
+            },
+          );
+        },
+      );
+    }
+    if (result == ButtonAction.second) {
+      await _pairWithPin(gw, '');
+    }
+  }
+
+  Future<void> _pairWithPin(CastGateway gw, String code) async {
     await showTextInputDialog(
       context,
       title: context.l10n.playOnTv,
@@ -704,28 +771,49 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
       submitButtonLabel: S.of(context).pair,
       textInputType: TextInputType.streetAddress,
       hintText: context.l10n.deviceCodeHint,
+      showOnlyLoadingState: true,
+      alwaysShowSuccessState: false,
+      initialValue: code,
       onSubmit: (String text) async {
-        try {
-          final code = text.trim();
-          final String? publicKey = await gw.getPublicKey(code);
-          if (publicKey == null) {
-            showToast(context, S.of(context).deviceNotFound);
-            return;
-          }
-          final String castToken = const Uuid().v4().toString();
-          final castPayload = CollectionsService.instance
-              .getCastData(castToken, widget.collection!, publicKey);
-          await gw.publishCastPayload(
-            code,
-            castPayload,
-            widget.collection!.id,
-            castToken,
-          );
-        } catch (e, s) {
-          _logger.severe("Failed to cast album", e, s);
-          await showGenericErrorDialog(context: context, error: e);
+        final bool paired = await _castPair(gw, text);
+        if (!paired) {
+          Future.delayed(Duration.zero, () => _pairWithPin(gw, code));
         }
       },
     );
   }
+
+  Future<bool> _castPair(CastGateway gw, String code) async {
+    try {
+      final String? publicKey = await gw.getPublicKey(code);
+      if (publicKey == null) {
+        showToast(context, S.of(context).deviceNotFound);
+
+        return false;
+      }
+      final String castToken = const Uuid().v4().toString();
+      final castPayload = CollectionsService.instance
+          .getCastData(castToken, widget.collection!, publicKey);
+      await gw.publishCastPayload(
+        code,
+        castPayload,
+        widget.collection!.id,
+        castToken,
+      );
+      showToast(context, S.of(context).pairingComplete);
+      return true;
+    } catch (e, s) {
+      _logger.severe("Failed to cast album", e, s);
+      if (e is CastIPMismatchException) {
+        await showErrorDialog(
+          context,
+          S.of(context).castIPMismatchTitle,
+          S.of(context).castIPMismatchBody,
+        );
+      } else {
+        await showGenericErrorDialog(context: context, error: e);
+      }
+      return false;
+    }
+  }
 }

+ 10 - 0
mobile/plugins/ente_cast/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
+  channel: stable
+
+project_type: plugin

+ 1 - 0
mobile/plugins/ente_cast/analysis_options.yaml

@@ -0,0 +1 @@
+include: ../../analysis_options.yaml

+ 2 - 0
mobile/plugins/ente_cast/lib/ente_cast.dart

@@ -0,0 +1,2 @@
+export 'src/model.dart';
+export 'src/service.dart';

+ 5 - 0
mobile/plugins/ente_cast/lib/src/model.dart

@@ -0,0 +1,5 @@
+// create enum for type of message for cast
+enum CastMessageType {
+  pairCode,
+  alreadyCasting,
+}

+ 18 - 0
mobile/plugins/ente_cast/lib/src/service.dart

@@ -0,0 +1,18 @@
+import "package:ente_cast/src/model.dart";
+import "package:flutter/widgets.dart";
+
+abstract class CastService {
+  bool get isSupported;
+  Future<List<(String, Object)>> searchDevices();
+  Future<void> connectDevice(
+    BuildContext context,
+    Object device, {
+    int? collectionID,
+    // callback that take a map of string, dynamic
+    void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
+  });
+  // returns a map of sessionID to deviceNames
+  Map<String, String> getActiveSessions();
+
+  Future<void> closeActiveCasts();
+}

+ 19 - 0
mobile/plugins/ente_cast/pubspec.yaml

@@ -0,0 +1,19 @@
+name: ente_cast
+version: 0.0.1
+publish_to: none
+
+environment:
+  sdk: '>=3.3.0 <4.0.0'
+
+dependencies:
+  collection:
+  dio: ^4.0.6
+  flutter:
+    sdk: flutter
+  shared_preferences: ^2.0.5
+  stack_trace:
+
+dev_dependencies:
+  flutter_lints:
+
+flutter:

+ 10 - 0
mobile/plugins/ente_cast_none/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
+  channel: stable
+
+project_type: plugin

+ 1 - 0
mobile/plugins/ente_cast_none/analysis_options.yaml

@@ -0,0 +1 @@
+include: ../../analysis_options.yaml

+ 1 - 0
mobile/plugins/ente_cast_none/lib/ente_cast_none.dart

@@ -0,0 +1 @@
+export 'src/service.dart';

+ 35 - 0
mobile/plugins/ente_cast_none/lib/src/service.dart

@@ -0,0 +1,35 @@
+import "package:ente_cast/ente_cast.dart";
+import "package:flutter/widgets.dart";
+
+class CastServiceImpl extends CastService {
+  @override
+  Future<void> connectDevice(
+    BuildContext context,
+    Object device, {
+    int? collectionID,
+    void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
+  }) {
+    throw UnimplementedError();
+  }
+
+  @override
+  bool get isSupported => false;
+
+  @override
+  Future<List<(String, Object)>> searchDevices() {
+    // TODO: implement searchDevices
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<void> closeActiveCasts() {
+    // TODO: implement closeActiveCasts
+    throw UnimplementedError();
+  }
+
+  @override
+  Map<String, String> getActiveSessions() {
+    // TODO: implement getActiveSessions
+    throw UnimplementedError();
+  }
+}

+ 18 - 0
mobile/plugins/ente_cast_none/pubspec.yaml

@@ -0,0 +1,18 @@
+name: ente_cast_none
+version: 0.0.1
+publish_to: none
+
+environment:
+  sdk: '>=3.3.0 <4.0.0'
+
+dependencies:
+  ente_cast:
+    path: ../ente_cast
+  flutter:
+    sdk: flutter
+  stack_trace:
+
+dev_dependencies:
+  flutter_lints:
+
+flutter:

+ 10 - 0
mobile/plugins/ente_cast_normal/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
+  channel: stable
+
+project_type: plugin

+ 1 - 0
mobile/plugins/ente_cast_normal/analysis_options.yaml

@@ -0,0 +1 @@
+include: ../../analysis_options.yaml

+ 1 - 0
mobile/plugins/ente_cast_normal/lib/ente_cast_normal.dart

@@ -0,0 +1 @@
+export 'src/service.dart';

+ 100 - 0
mobile/plugins/ente_cast_normal/lib/src/service.dart

@@ -0,0 +1,100 @@
+import "dart:developer" as dev;
+
+import "package:cast/cast.dart";
+import "package:ente_cast/ente_cast.dart";
+import "package:flutter/material.dart";
+
+class CastServiceImpl extends CastService {
+  final String _appId = 'F5BCEC64';
+  final String _pairRequestNamespace = 'urn:x-cast:pair-request';
+  final Map<int, String> collectionIDToSessions = {};
+
+  @override
+  Future<void> connectDevice(
+    BuildContext context,
+    Object device, {
+    int? collectionID,
+    void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
+  }) async {
+    final CastDevice castDevice = device as CastDevice;
+    final session = await CastSessionManager().startSession(castDevice);
+    session.messageStream.listen((message) {
+      if (message['type'] == "RECEIVER_STATUS") {
+        dev.log(
+          "got RECEIVER_STATUS, Send request to pair",
+          name: "CastServiceImpl",
+        );
+        session.sendMessage(_pairRequestNamespace, {});
+      } else {
+        if (onMessage != null && message.containsKey("code")) {
+          onMessage(
+            {
+              CastMessageType.pairCode: message,
+            },
+          );
+        }
+        print('receive message: $message');
+      }
+    });
+
+    session.stateStream.listen((state) {
+      if (state == CastSessionState.connected) {
+        debugPrint("Send request to pair");
+        session.sendMessage(_pairRequestNamespace, {});
+      } else if (state == CastSessionState.closed) {
+        dev.log('Session closed', name: 'CastServiceImpl');
+      }
+    });
+
+    debugPrint("Send request to launch");
+    session.sendMessage(CastSession.kNamespaceReceiver, {
+      'type': 'LAUNCH',
+      'appId': _appId, // set the appId of your app here
+    });
+    // session.sendMessage('urn:x-cast:pair-request', {});
+  }
+
+  @override
+  Future<List<(String, Object)>> searchDevices() {
+    return CastDiscoveryService().search().then((devices) {
+      return devices.map((device) => (device.name, device)).toList();
+    });
+  }
+
+  @override
+  bool get isSupported => true;
+
+  @override
+  Future<void> closeActiveCasts() {
+    final sessions = CastSessionManager().sessions;
+    for (final session in sessions) {
+      debugPrint("send close message for ${session.sessionId}");
+      Future(() {
+        session.sendMessage(CastSession.kNamespaceConnection, {
+          'type': 'CLOSE',
+        });
+      }).timeout(
+        const Duration(seconds: 5),
+        onTimeout: () {
+          debugPrint('sendMessage timed out after 5 seconds');
+        },
+      );
+      debugPrint("close session ${session.sessionId}");
+      session.close();
+    }
+    CastSessionManager().sessions.clear();
+    return Future.value();
+  }
+
+  @override
+  Map<String, String> getActiveSessions() {
+    final sessions = CastSessionManager().sessions;
+    final Map<String, String> result = {};
+    for (final session in sessions) {
+      if (session.state == CastSessionState.connected) {
+        result[session.sessionId] = session.state.toString();
+      }
+    }
+    return result;
+  }
+}

+ 333 - 0
mobile/plugins/ente_cast_normal/pubspec.lock

@@ -0,0 +1,333 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  async:
+    dependency: transitive
+    description:
+      name: async
+      sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.11.0"
+  cast:
+    dependency: "direct main"
+    description:
+      path: "."
+      ref: multicast_version
+      resolved-ref: "1f39cd4d6efa9363e77b2439f0317bae0c92dda1"
+      url: "https://github.com/guyluz11/flutter_cast.git"
+    source: git
+    version: "2.0.9"
+  characters:
+    dependency: transitive
+    description:
+      name: characters
+      sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.18.0"
+  dio:
+    dependency: transitive
+    description:
+      name: dio
+      sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.6"
+  ente_cast:
+    dependency: "direct main"
+    description:
+      path: "../ente_cast"
+      relative: true
+    source: path
+    version: "0.0.1"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.0"
+  fixnum:
+    dependency: transitive
+    description:
+      name: fixnum
+      sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  flutter:
+    dependency: "direct main"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_lints:
+    dependency: "direct dev"
+    description:
+      name: flutter_lints
+      sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.2"
+  lints:
+    dependency: transitive
+    description:
+      name: lints
+      sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
+  material_color_utilities:
+    dependency: transitive
+    description:
+      name: material_color_utilities
+      sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.8.0"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.11.0"
+  multicast_dns:
+    dependency: transitive
+    description:
+      name: multicast_dns
+      sha256: "316cc47a958d4bd3c67bd238fe8b44fdfb6133bad89cb191c0c3bd3edb14e296"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.2+6"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.1"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  path_provider_windows:
+    dependency: transitive
+    description:
+      name: path_provider_windows
+      sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.1"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.4"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.8"
+  protobuf:
+    dependency: transitive
+    description:
+      name: protobuf
+      sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.0"
+  shared_preferences:
+    dependency: transitive
+    description:
+      name: shared_preferences
+      sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.3"
+  shared_preferences_android:
+    dependency: transitive
+    description:
+      name: shared_preferences_android
+      sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.2"
+  shared_preferences_foundation:
+    dependency: transitive
+    description:
+      name: shared_preferences_foundation
+      sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.5"
+  shared_preferences_linux:
+    dependency: transitive
+    description:
+      name: shared_preferences_linux
+      sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  shared_preferences_platform_interface:
+    dependency: transitive
+    description:
+      name: shared_preferences_platform_interface
+      sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  shared_preferences_web:
+    dependency: transitive
+    description:
+      name: shared_preferences_web
+      sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.0"
+  shared_preferences_windows:
+    dependency: transitive
+    description:
+      name: shared_preferences_windows
+      sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  sky_engine:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.99"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.10.0"
+  stack_trace:
+    dependency: "direct main"
+    description:
+      name: stack_trace
+      sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.11.1"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.0"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.2"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.5.1"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "5.4.0"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.4"
+sdks:
+  dart: ">=3.3.0 <4.0.0"
+  flutter: ">=3.19.0"

+ 22 - 0
mobile/plugins/ente_cast_normal/pubspec.yaml

@@ -0,0 +1,22 @@
+name: ente_cast_normal
+version: 0.0.1
+publish_to: none
+
+environment:
+  sdk: '>=3.3.0 <4.0.0'
+
+dependencies:
+  cast:
+    git:
+      url: https://github.com/guyluz11/flutter_cast.git
+      ref: multicast_version
+  ente_cast:
+    path: ../ente_cast
+  flutter:
+    sdk: flutter
+  stack_trace:
+
+dev_dependencies:
+  flutter_lints:
+
+flutter:

+ 39 - 0
mobile/pubspec.lock

@@ -209,6 +209,15 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.1.1"
+  cast:
+    dependency: transitive
+    description:
+      path: "."
+      ref: multicast_version
+      resolved-ref: "1f39cd4d6efa9363e77b2439f0317bae0c92dda1"
+      url: "https://github.com/guyluz11/flutter_cast.git"
+    source: git
+    version: "2.0.9"
   characters:
     dependency: transitive
     description:
@@ -434,6 +443,20 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.17"
+  ente_cast:
+    dependency: "direct main"
+    description:
+      path: "plugins/ente_cast"
+      relative: true
+    source: path
+    version: "0.0.1"
+  ente_cast_normal:
+    dependency: "direct main"
+    description:
+      path: "plugins/ente_cast_normal"
+      relative: true
+    source: path
+    version: "0.0.1"
   ente_feature_flag:
     dependency: "direct main"
     description:
@@ -1423,6 +1446,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.2"
+  multicast_dns:
+    dependency: transitive
+    description:
+      name: multicast_dns
+      sha256: "316cc47a958d4bd3c67bd238fe8b44fdfb6133bad89cb191c0c3bd3edb14e296"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.2+6"
   nested:
     dependency: transitive
     description:
@@ -1736,6 +1767,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.1.0"
+  protobuf:
+    dependency: transitive
+    description:
+      name: protobuf
+      sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.0"
   provider:
     dependency: "direct main"
     description:

+ 4 - 0
mobile/pubspec.yaml

@@ -47,6 +47,10 @@ dependencies:
   dotted_border: ^2.1.0
   dropdown_button2: ^2.0.0
   email_validator: ^2.0.1
+  ente_cast:
+    path: plugins/ente_cast
+  ente_cast_normal:
+    path: plugins/ente_cast_normal
   ente_feature_flag:
     path: plugins/ente_feature_flag
   equatable: ^2.0.5