Browse Source

Merge branch 'main' into homewidget

Prateek Sunal 1 năm trước cách đây
mục cha
commit
95588c8aef
48 tập tin đã thay đổi với 821 bổ sung174 xóa
  1. 12 0
      CHANGELOG.md
  2. 3 0
      lib/events/pause_video_event.dart
  3. 50 0
      lib/gateways/cast_gw.dart
  4. 1 0
      lib/generated/intl/messages_cs.dart
  5. 2 1
      lib/generated/intl/messages_de.dart
  6. 10 1
      lib/generated/intl/messages_en.dart
  7. 2 1
      lib/generated/intl/messages_es.dart
  8. 2 1
      lib/generated/intl/messages_fr.dart
  9. 2 1
      lib/generated/intl/messages_it.dart
  10. 1 0
      lib/generated/intl/messages_ko.dart
  11. 10 0
      lib/generated/intl/messages_nl.dart
  12. 1 0
      lib/generated/intl/messages_no.dart
  13. 1 0
      lib/generated/intl/messages_pl.dart
  14. 1 0
      lib/generated/intl/messages_pt.dart
  15. 7 0
      lib/generated/intl/messages_zh.dart
  16. 62 2
      lib/generated/l10n.dart
  17. 2 1
      lib/l10n/intl_cs.arb
  18. 3 2
      lib/l10n/intl_de.arb
  19. 9 3
      lib/l10n/intl_en.arb
  20. 3 2
      lib/l10n/intl_es.arb
  21. 3 2
      lib/l10n/intl_fr.arb
  22. 3 2
      lib/l10n/intl_it.arb
  23. 2 1
      lib/l10n/intl_ko.arb
  24. 7 1
      lib/l10n/intl_nl.arb
  25. 2 1
      lib/l10n/intl_no.arb
  26. 2 1
      lib/l10n/intl_pl.arb
  27. 2 1
      lib/l10n/intl_pt.arb
  28. 7 1
      lib/l10n/intl_zh.arb
  29. 17 0
      lib/services/collections_service.dart
  30. 1 4
      lib/services/location_service.dart
  31. 1 1
      lib/services/update_service.dart
  32. 5 0
      lib/ui/components/info_item_widget.dart
  33. 6 0
      lib/ui/map/enable_map.dart
  34. 12 4
      lib/ui/map/map_marker.dart
  35. 68 42
      lib/ui/map/map_screen.dart
  36. 71 46
      lib/ui/map/map_view.dart
  37. 17 16
      lib/ui/map/tile/attribution/map_attribution.dart
  38. 25 4
      lib/ui/map/tile/layers.dart
  39. 31 8
      lib/ui/notification/update/change_log_page.dart
  40. 1 0
      lib/ui/viewer/file/file_details_widget.dart
  41. 8 0
      lib/ui/viewer/file/video_widget_new.dart
  42. 3 0
      lib/ui/viewer/file_details/albums_item_widget.dart
  43. 2 2
      lib/ui/viewer/file_details/file_properties_item_widget.dart
  44. 227 1
      lib/ui/viewer/file_details/location_tags_widget.dart
  45. 14 19
      lib/ui/viewer/gallery/component/group/lazy_group_gallery.dart
  46. 60 0
      lib/ui/viewer/gallery/gallery_app_bar_widget.dart
  47. 39 1
      lib/utils/exif_util.dart
  48. 1 1
      pubspec.yaml

+ 12 - 0
CHANGELOG.md

@@ -1,5 +1,17 @@
 # CHANGELOG
 
+## v0.8.54
+
+### Added
+* #### Map View ✨
+
+    You can now view the location where a photo was clicked. Open a photo and tap the Info button to view its place on the map!
+
+* #### Bug Fixes
+
+    Many a bugs were squashed in this release. If you run into any, please write to team@ente.io, or let us know on Discord! 🙏
+
+
 
 ## v0.7.118
 

+ 3 - 0
lib/events/pause_video_event.dart

@@ -0,0 +1,3 @@
+import "package:photos/events/event.dart";
+
+class PauseVideoEvent extends Event {}

+ 50 - 0
lib/gateways/cast_gw.dart

@@ -0,0 +1,50 @@
+import "package:dio/dio.dart";
+
+class CastGateway {
+  final Dio _enteDio;
+
+  CastGateway(this._enteDio);
+
+  Future<String?> getPublicKey(String deviceCode) async {
+    try {
+      final response = await _enteDio.get(
+        "/cast/device-info/$deviceCode",
+      );
+      return response.data["publicKey"];
+    } catch (e) {
+      if (e is DioError &&
+          e.response != null &&
+          e.response!.statusCode == 404) {
+        return null;
+      }
+      rethrow;
+    }
+  }
+
+  Future<void> publishCastPayload(
+    String code,
+    String castPayload,
+    int collectionID,
+    String castToken,
+  ) {
+    return _enteDio.post(
+      "/cast/cast-data/",
+      data: {
+        "deviceCode": code,
+        "encPayload": castPayload,
+        "collectionID": collectionID,
+        "castToken": castToken,
+      },
+    );
+  }
+
+  Future<void> revokeAllTokens() async {
+    try {
+      await _enteDio.delete(
+        "/cast/revoke-all-tokens/",
+      );
+    } catch (e) {
+      // swallow error
+    }
+  }
+}

+ 1 - 0
lib/generated/intl/messages_cs.dart

@@ -34,6 +34,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage(
                 "Edits to location will only be seen within Ente"),
         "fileTypes": MessageLookupByLibrary.simpleMessage("File types"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "modifyYourQueryOrTrySearchingFor":
             MessageLookupByLibrary.simpleMessage(
                 "Modify your query, or try searching for"),

+ 2 - 1
lib/generated/intl/messages_de.dart

@@ -815,6 +815,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Elemente zeigen die Anzahl der Tage bis zum dauerhaften Löschen an"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Ausgewählte Elemente werden aus diesem Album entfernt"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos": MessageLookupByLibrary.simpleMessage("Fotos behalten"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
         "kindlyHelpUsWithThisInformation":
@@ -845,7 +846,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "loadMessage1": MessageLookupByLibrary.simpleMessage(
             "Du kannst dein Abonnement mit deiner Familie teilen"),
         "loadMessage2": MessageLookupByLibrary.simpleMessage(
-            "Wir haben bereits mehr als 10 Millionen Erinnerungsstücke gesichert"),
+            "Wir haben bereits mehr als 30 Millionen Erinnerungsstücke gesichert"),
         "loadMessage3": MessageLookupByLibrary.simpleMessage(
             "Wir behalten 3 Kopien Ihrer Daten, eine in einem unterirdischen Schutzbunker"),
         "loadMessage4": MessageLookupByLibrary.simpleMessage(

+ 10 - 1
lib/generated/intl/messages_en.dart

@@ -380,6 +380,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "cannotAddMorePhotosAfterBecomingViewer": m7,
         "cannotDeleteSharedFiles":
             MessageLookupByLibrary.simpleMessage("Cannot delete shared files"),
+        "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"),
         "changeEmail": MessageLookupByLibrary.simpleMessage("Change email"),
         "changeLocationOfSelectedItems": MessageLookupByLibrary.simpleMessage(
@@ -552,10 +554,14 @@ class MessageLookup extends MessageLookupByLibrary {
         "details": MessageLookupByLibrary.simpleMessage("Details"),
         "devAccountChanged": MessageLookupByLibrary.simpleMessage(
             "The developer account we use to publish ente on App Store has changed. Because of this, you will need to login again.\n\nOur apologies for the inconvenience, but this was unavoidable."),
+        "deviceCodeHint":
+            MessageLookupByLibrary.simpleMessage("Enter the code"),
         "deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage(
             "Files added to this device album will automatically get uploaded to ente."),
         "deviceLockExplanation": MessageLookupByLibrary.simpleMessage(
             "Disable the device screen lock when ente is in the foreground and there is a backup in progress. This is normally not needed, but may help big uploads and initial imports of large libraries complete faster."),
+        "deviceNotFound":
+            MessageLookupByLibrary.simpleMessage("Device not found"),
         "didYouKnow": MessageLookupByLibrary.simpleMessage("Did you know?"),
         "disableAutoLock":
             MessageLookupByLibrary.simpleMessage("Disable auto lock"),
@@ -784,6 +790,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Items show the number of days remaining before permanent deletion"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Selected items will be removed from this album"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos": MessageLookupByLibrary.simpleMessage("Keep Photos"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
         "kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
@@ -811,7 +818,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "loadMessage1": MessageLookupByLibrary.simpleMessage(
             "You can share your subscription with your family"),
         "loadMessage2": MessageLookupByLibrary.simpleMessage(
-            "We have preserved over 10 million memories so far"),
+            "We have preserved over 30 million memories so far"),
         "loadMessage3": MessageLookupByLibrary.simpleMessage(
             "We keep 3 copies of your data, one in an underground fallout shelter"),
         "loadMessage4": MessageLookupByLibrary.simpleMessage(
@@ -946,6 +953,7 @@ class MessageLookup extends MessageLookupByLibrary {
             "Optional, as short as you like..."),
         "orPickAnExistingOne":
             MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
+        "pair": MessageLookupByLibrary.simpleMessage("Pair"),
         "password": MessageLookupByLibrary.simpleMessage("Password"),
         "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage(
             "Password changed successfully"),
@@ -980,6 +988,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "pickCenterPoint":
             MessageLookupByLibrary.simpleMessage("Pick center point"),
         "pinAlbum": MessageLookupByLibrary.simpleMessage("Pin album"),
+        "playOnTv": MessageLookupByLibrary.simpleMessage("Play album on TV"),
         "playStoreFreeTrialValidTill": m37,
         "playstoreSubscription":
             MessageLookupByLibrary.simpleMessage("PlayStore subscription"),

+ 2 - 1
lib/generated/intl/messages_es.dart

@@ -703,6 +703,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Los artículos muestran el número de días restantes antes de ser borrados permanente"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Los elementos seleccionados serán removidos de este álbum"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos":
             MessageLookupByLibrary.simpleMessage("Conservar las fotos"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
@@ -733,7 +734,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "loadMessage1": MessageLookupByLibrary.simpleMessage(
             "Puedes compartir tu suscripción con tu familia"),
         "loadMessage2": MessageLookupByLibrary.simpleMessage(
-            "Hasta ahora hemos conservado más de 10 millones de recuerdos"),
+            "Hasta ahora hemos conservado más de 30 millones de recuerdos"),
         "loadMessage3": MessageLookupByLibrary.simpleMessage(
             "Guardamos 3 copias de sus datos, una en un refugio subterráneo"),
         "loadMessage4": MessageLookupByLibrary.simpleMessage(

+ 2 - 1
lib/generated/intl/messages_fr.dart

@@ -811,6 +811,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Les éléments montrent le nombre de jours restants avant la suppression définitive"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Les éléments sélectionnés seront supprimés de cet album"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos":
             MessageLookupByLibrary.simpleMessage("Conserver les photos"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
@@ -843,7 +844,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "loadMessage1": MessageLookupByLibrary.simpleMessage(
             "Vous pouvez partager votre abonnement avec votre famille"),
         "loadMessage2": MessageLookupByLibrary.simpleMessage(
-            "Nous avons conservé plus de 10 millions de souvenirs jusqu\'à présent"),
+            "Nous avons conservé plus de 30 millions de souvenirs jusqu\'à présent"),
         "loadMessage3": MessageLookupByLibrary.simpleMessage(
             "Nous conservons 3 copies de vos données, l\'une dans un abri anti-atomique"),
         "loadMessage4": MessageLookupByLibrary.simpleMessage(

+ 2 - 1
lib/generated/intl/messages_it.dart

@@ -780,6 +780,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Gli elementi mostrano il numero di giorni rimanenti prima della cancellazione permanente"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Gli elementi selezionati saranno rimossi da questo album"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos": MessageLookupByLibrary.simpleMessage("Mantieni foto"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
         "kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
@@ -810,7 +811,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "loadMessage1": MessageLookupByLibrary.simpleMessage(
             "Puoi condividere il tuo abbonamento con la tua famiglia"),
         "loadMessage2": MessageLookupByLibrary.simpleMessage(
-            "Fino ad oggi abbiamo conservato oltre 10 milioni di ricordi"),
+            "Fino ad oggi abbiamo conservato oltre 30 milioni di ricordi"),
         "loadMessage3": MessageLookupByLibrary.simpleMessage(
             "Teniamo 3 copie dei tuoi dati, uno in un rifugio sotterraneo antiatomico"),
         "loadMessage4": MessageLookupByLibrary.simpleMessage(

+ 1 - 0
lib/generated/intl/messages_ko.dart

@@ -34,6 +34,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage(
                 "Edits to location will only be seen within Ente"),
         "fileTypes": MessageLookupByLibrary.simpleMessage("File types"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "modifyYourQueryOrTrySearchingFor":
             MessageLookupByLibrary.simpleMessage(
                 "Modify your query, or try searching for"),

+ 10 - 0
lib/generated/intl/messages_nl.dart

@@ -392,6 +392,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "cannotAddMorePhotosAfterBecomingViewer": m7,
         "cannotDeleteSharedFiles": MessageLookupByLibrary.simpleMessage(
             "Kan gedeelde bestanden niet verwijderen"),
+        "castInstruction": MessageLookupByLibrary.simpleMessage(
+            "Bezoek cast.ente.io op het apparaat dat u wilt koppelen.\n\nVoer de code hieronder in om het album op uw TV af te spelen."),
         "centerPoint": MessageLookupByLibrary.simpleMessage("Middelpunt"),
         "changeEmail": MessageLookupByLibrary.simpleMessage("E-mail wijzigen"),
         "changeLocationOfSelectedItems": MessageLookupByLibrary.simpleMessage(
@@ -569,10 +571,14 @@ class MessageLookup extends MessageLookupByLibrary {
         "details": MessageLookupByLibrary.simpleMessage("Details"),
         "devAccountChanged": MessageLookupByLibrary.simpleMessage(
             "Het ontwikkelaarsaccount dat we gebruiken om te publiceren in de App Store is veranderd. Daarom moet je opnieuw inloggen.\n\nOnze excuses voor het ongemak, helaas was dit onvermijdelijk."),
+        "deviceCodeHint":
+            MessageLookupByLibrary.simpleMessage("Voer de code in"),
         "deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage(
             "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar ente."),
         "deviceLockExplanation": MessageLookupByLibrary.simpleMessage(
             "Schakel de schermvergrendeling van het apparaat uit wanneer ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen."),
+        "deviceNotFound":
+            MessageLookupByLibrary.simpleMessage("Apparaat niet gevonden"),
         "didYouKnow": MessageLookupByLibrary.simpleMessage("Wist u dat?"),
         "disableAutoLock": MessageLookupByLibrary.simpleMessage(
             "Automatisch vergrendelen uitschakelen"),
@@ -816,6 +822,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Bestanden tonen het aantal resterende dagen voordat ze permanent worden verwijderd"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Geselecteerde items zullen worden verwijderd uit dit album"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos": MessageLookupByLibrary.simpleMessage("Foto\'s behouden"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
         "kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
@@ -987,6 +994,7 @@ class MessageLookup extends MessageLookupByLibrary {
             "Optioneel, zo kort als je wilt..."),
         "orPickAnExistingOne":
             MessageLookupByLibrary.simpleMessage("Of kies een bestaande"),
+        "pair": MessageLookupByLibrary.simpleMessage("Koppelen"),
         "password": MessageLookupByLibrary.simpleMessage("Wachtwoord"),
         "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage(
             "Wachtwoord succesvol aangepast"),
@@ -1025,6 +1033,8 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Kies middelpunt"),
         "pinAlbum":
             MessageLookupByLibrary.simpleMessage("Album bovenaan vastzetten"),
+        "playOnTv":
+            MessageLookupByLibrary.simpleMessage("Album afspelen op TV"),
         "playStoreFreeTrialValidTill": m37,
         "playstoreSubscription":
             MessageLookupByLibrary.simpleMessage("PlayStore abonnement"),

+ 1 - 0
lib/generated/intl/messages_no.dart

@@ -54,6 +54,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "fileTypes": MessageLookupByLibrary.simpleMessage("File types"),
         "invalidEmailAddress":
             MessageLookupByLibrary.simpleMessage("Ugyldig e-postadresse"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
             "Vær vennlig og hjelp oss med denne informasjonen"),
         "modifyYourQueryOrTrySearchingFor":

+ 1 - 0
lib/generated/intl/messages_pl.dart

@@ -113,6 +113,7 @@ class MessageLookup extends MessageLookupByLibrary {
             "Nieprawidłowy klucz odzyskiwania"),
         "invalidEmailAddress":
             MessageLookupByLibrary.simpleMessage("Nieprawidłowy adres e-mail"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "kindlyHelpUsWithThisInformation":
             MessageLookupByLibrary.simpleMessage("Pomóż nam z tą informacją"),
         "logInLabel": MessageLookupByLibrary.simpleMessage("Zaloguj się"),

+ 1 - 0
lib/generated/intl/messages_pt.dart

@@ -252,6 +252,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Convide seus amigos"),
         "itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
             "Os itens selecionados serão removidos deste álbum"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos": MessageLookupByLibrary.simpleMessage("Manter fotos"),
         "kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
             "Ajude-nos com esta informação"),

+ 7 - 0
lib/generated/intl/messages_zh.dart

@@ -336,6 +336,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "cannotAddMorePhotosAfterBecomingViewer": m7,
         "cannotDeleteSharedFiles":
             MessageLookupByLibrary.simpleMessage("无法删除共享文件"),
+        "castInstruction": MessageLookupByLibrary.simpleMessage(
+            "在您要配对的设备上访问 cast.ente.io。\n输入下面的代码即可在电视上播放相册。"),
         "centerPoint": MessageLookupByLibrary.simpleMessage("中心点"),
         "changeEmail": MessageLookupByLibrary.simpleMessage("修改邮箱"),
         "changeLocationOfSelectedItems":
@@ -468,10 +470,12 @@ class MessageLookup extends MessageLookupByLibrary {
         "details": MessageLookupByLibrary.simpleMessage("详情"),
         "devAccountChanged": MessageLookupByLibrary.simpleMessage(
             "我们用于在 App Store 上发布 ente 的开发者账户已更改。 因此,您将需要重新登录。\n\n对于给您带来的不便,我们深表歉意,但这是不可避免的。"),
+        "deviceCodeHint": MessageLookupByLibrary.simpleMessage("输入代码"),
         "deviceFilesAutoUploading":
             MessageLookupByLibrary.simpleMessage("添加到此设备相册的文件将自动上传到 ente。"),
         "deviceLockExplanation": MessageLookupByLibrary.simpleMessage(
             "当 ente 在前台并且正在进行备份时禁用设备屏幕锁定。 这通常不需要,但可以帮助大型库的大上传和初始导入更快地完成。"),
+        "deviceNotFound": MessageLookupByLibrary.simpleMessage("未发现设备"),
         "didYouKnow": MessageLookupByLibrary.simpleMessage("您知道吗?"),
         "disableAutoLock": MessageLookupByLibrary.simpleMessage("禁用自动锁定"),
         "disableDownloadWarningBody":
@@ -662,6 +666,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("项目显示永久删除前剩余的天数"),
         "itemsWillBeRemovedFromAlbum":
             MessageLookupByLibrary.simpleMessage("所选项目将从此相册中移除"),
+        "joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
         "keepPhotos": MessageLookupByLibrary.simpleMessage("保留照片"),
         "kiloMeterUnit": MessageLookupByLibrary.simpleMessage("公里"),
         "kindlyHelpUsWithThisInformation":
@@ -805,6 +810,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("可选的,按您喜欢的短语..."),
         "orPickAnExistingOne":
             MessageLookupByLibrary.simpleMessage("或者选择一个现有的"),
+        "pair": MessageLookupByLibrary.simpleMessage("配对"),
         "password": MessageLookupByLibrary.simpleMessage("密码"),
         "passwordChangedSuccessfully":
             MessageLookupByLibrary.simpleMessage("密码修改成功"),
@@ -832,6 +838,7 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("您添加的照片将从相册中移除"),
         "pickCenterPoint": MessageLookupByLibrary.simpleMessage("选择中心点"),
         "pinAlbum": MessageLookupByLibrary.simpleMessage("置顶相册"),
+        "playOnTv": MessageLookupByLibrary.simpleMessage("在电视上播放相册"),
         "playStoreFreeTrialValidTill": m37,
         "playstoreSubscription":
             MessageLookupByLibrary.simpleMessage("PlayStore 订阅"),

+ 62 - 2
lib/generated/l10n.dart

@@ -6839,10 +6839,10 @@ class S {
     );
   }
 
-  /// `We have preserved over 10 million memories so far`
+  /// `We have preserved over 30 million memories so far`
   String get loadMessage2 {
     return Intl.message(
-      'We have preserved over 10 million memories so far',
+      'We have preserved over 30 million memories so far',
       name: 'loadMessage2',
       desc: '',
       args: [],
@@ -8307,6 +8307,66 @@ class S {
       args: [],
     );
   }
+
+  /// `Play album on TV`
+  String get playOnTv {
+    return Intl.message(
+      'Play album on TV',
+      name: 'playOnTv',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pair`
+  String get pair {
+    return Intl.message(
+      'Pair',
+      name: 'pair',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Device not found`
+  String get deviceNotFound {
+    return Intl.message(
+      'Device not found',
+      name: 'deviceNotFound',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.`
+  String get castInstruction {
+    return Intl.message(
+      'Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.',
+      name: 'castInstruction',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Enter the code`
+  String get deviceCodeHint {
+    return Intl.message(
+      'Enter the code',
+      name: 'deviceCodeHint',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Join Discord`
+  String get joinDiscord {
+    return Intl.message(
+      'Join Discord',
+      name: 'joinDiscord',
+      desc: '',
+      args: [],
+    );
+  }
 }
 
 class AppLocalizationDelegate extends LocalizationsDelegate<S> {

+ 2 - 1
lib/l10n/intl_cs.arb

@@ -10,5 +10,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 3 - 2
lib/l10n/intl_de.arb

@@ -965,7 +965,7 @@
   "didYouKnow": "Schon gewusst?",
   "loadingMessage": "Fotos werden geladen...",
   "loadMessage1": "Du kannst dein Abonnement mit deiner Familie teilen",
-  "loadMessage2": "Wir haben bereits mehr als 10 Millionen Erinnerungsstücke gesichert",
+  "loadMessage2": "Wir haben bereits mehr als 30 Millionen Erinnerungsstücke gesichert",
   "loadMessage3": "Wir behalten 3 Kopien Ihrer Daten, eine in einem unterirdischen Schutzbunker",
   "loadMessage4": "Alle unsere Apps sind Open-Source",
   "loadMessage5": "Unser Quellcode und unsere Kryptografie wurden extern geprüft",
@@ -1178,5 +1178,6 @@
   "selectALocationFirst": "Wähle zuerst einen Standort",
   "changeLocationOfSelectedItems": "Standort der gewählten Elemente ändern?",
   "editsToLocationWillOnlyBeSeenWithinEnte": "Änderungen des Standorts werden nur in ente sichtbar sein",
-  "cleanUncategorized": "Unkategorisiert leeren"
+  "cleanUncategorized": "Unkategorisiert leeren",
+  "joinDiscord": "Join Discord"
 }

+ 9 - 3
lib/l10n/intl_en.arb

@@ -974,7 +974,7 @@
   "didYouKnow": "Did you know?",
   "loadingMessage": "Loading your photos...",
   "loadMessage1": "You can share your subscription with your family",
-  "loadMessage2": "We have preserved over 10 million memories so far",
+  "loadMessage2": "We have preserved over 30 million memories so far",
   "loadMessage3": "We keep 3 copies of your data, one in an underground fallout shelter",
   "loadMessage4": "All our apps are open source",
   "loadMessage5": "Our source code and cryptography have been externally audited",
@@ -1187,5 +1187,11 @@
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
   "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
-  "cleanUncategorized": "Clean Uncategorized"
-}
+  "cleanUncategorized": "Clean Uncategorized",
+  "playOnTv": "Play album on TV",
+  "pair": "Pair",
+  "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",
+  "joinDiscord": "Join Discord"
+}

+ 3 - 2
lib/l10n/intl_es.arb

@@ -897,7 +897,7 @@
   "didYouKnow": "¿Sabías que?",
   "loadingMessage": "Cargando tus fotos...",
   "loadMessage1": "Puedes compartir tu suscripción con tu familia",
-  "loadMessage2": "Hasta ahora hemos conservado más de 10 millones de recuerdos",
+  "loadMessage2": "Hasta ahora hemos conservado más de 30 millones de recuerdos",
   "loadMessage3": "Guardamos 3 copias de sus datos, una en un refugio subterráneo",
   "loadMessage4": "Todas nuestras aplicaciones son de código abierto",
   "loadMessage5": "Nuestro código fuente y criptografía han sido auditados externamente",
@@ -973,5 +973,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 3 - 2
lib/l10n/intl_fr.arb

@@ -948,7 +948,7 @@
   "didYouKnow": "Le savais-tu ?",
   "loadingMessage": "Chargement de vos photos...",
   "loadMessage1": "Vous pouvez partager votre abonnement avec votre famille",
-  "loadMessage2": "Nous avons conservé plus de 10 millions de souvenirs jusqu'à présent",
+  "loadMessage2": "Nous avons conservé plus de 30 millions de souvenirs jusqu'à présent",
   "loadMessage3": "Nous conservons 3 copies de vos données, l'une dans un abri anti-atomique",
   "loadMessage4": "Toutes nos applications sont open source",
   "loadMessage5": "Notre code source et notre cryptographie ont été audités en externe",
@@ -1154,5 +1154,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 3 - 2
lib/l10n/intl_it.arb

@@ -948,7 +948,7 @@
   "didYouKnow": "Lo sapevi che?",
   "loadingMessage": "Caricando le tue foto...",
   "loadMessage1": "Puoi condividere il tuo abbonamento con la tua famiglia",
-  "loadMessage2": "Fino ad oggi abbiamo conservato oltre 10 milioni di ricordi",
+  "loadMessage2": "Fino ad oggi abbiamo conservato oltre 30 milioni di ricordi",
   "loadMessage3": "Teniamo 3 copie dei tuoi dati, uno in un rifugio sotterraneo antiatomico",
   "loadMessage4": "Tutte le nostre app sono open source",
   "loadMessage5": "Il nostro codice sorgente e la crittografia hanno ricevuto audit esterni",
@@ -1116,5 +1116,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 2 - 1
lib/l10n/intl_ko.arb

@@ -10,5 +10,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 7 - 1
lib/l10n/intl_nl.arb

@@ -1187,5 +1187,11 @@
   "selectALocationFirst": "Selecteer eerst een locatie",
   "changeLocationOfSelectedItems": "Locatie van geselecteerde items wijzigen?",
   "editsToLocationWillOnlyBeSeenWithinEnte": "Bewerkte locatie wordt alleen gezien binnen Ente",
-  "cleanUncategorized": "Ongecategoriseerd opschonen"
+  "cleanUncategorized": "Ongecategoriseerd opschonen",
+  "playOnTv": "Album afspelen op TV",
+  "pair": "Koppelen",
+  "deviceNotFound": "Apparaat niet gevonden",
+  "castInstruction": "Bezoek cast.ente.io op het apparaat dat u wilt koppelen.\n\nVoer de code hieronder in om het album op uw TV af te spelen.",
+  "deviceCodeHint": "Voer de code in",
+  "joinDiscord": "Join Discord"
 }

+ 2 - 1
lib/l10n/intl_no.arb

@@ -24,5 +24,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 2 - 1
lib/l10n/intl_pl.arb

@@ -111,5 +111,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 2 - 1
lib/l10n/intl_pt.arb

@@ -277,5 +277,6 @@
   "selectALocation": "Select a location",
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
-  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente"
+  "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
+  "joinDiscord": "Join Discord"
 }

+ 7 - 1
lib/l10n/intl_zh.arb

@@ -1187,5 +1187,11 @@
   "selectALocationFirst": "首先选择一个位置",
   "changeLocationOfSelectedItems": "确定要更改所选项目的位置吗?",
   "editsToLocationWillOnlyBeSeenWithinEnte": "对位置的编辑只能在 Ente 内看到",
-  "cleanUncategorized": "清除未分类的"
+  "cleanUncategorized": "清除未分类的",
+  "playOnTv": "在电视上播放相册",
+  "pair": "配对",
+  "deviceNotFound": "未发现设备",
+  "castInstruction": "在您要配对的设备上访问 cast.ente.io。\n输入下面的代码即可在电视上播放相册。",
+  "deviceCodeHint": "输入代码",
+  "joinDiscord": "Join Discord"
 }

+ 17 - 0
lib/services/collections_service.dart

@@ -473,6 +473,23 @@ class CollectionsService {
     });
   }
 
+  String getCastData(
+    String castToken,
+    Collection collection,
+    String publicKey,
+  ) {
+    final String payload = jsonEncode({
+      "collectionID": collection.id,
+      "castToken": castToken,
+      "collectionKey": CryptoUtil.bin2base64(getCollectionKey(collection.id)),
+    });
+    final encPayload = CryptoUtil.sealSync(
+      CryptoUtil.base642bin(base64Encode(payload.codeUnits)),
+      CryptoUtil.base642bin(publicKey),
+    );
+    return CryptoUtil.bin2base64(encPayload);
+  }
+
   Future<List<User>> share(
     int collectionID,
     String email,

+ 1 - 4
lib/services/location_service.dart

@@ -13,7 +13,6 @@ import "package:photos/models/local_entity_data.dart";
 import "package:photos/models/location/location.dart";
 import 'package:photos/models/location_tag/location_tag.dart';
 import "package:photos/services/entity_service.dart";
-import "package:photos/services/feature_flag_service.dart";
 import "package:photos/services/remote_assets_service.dart";
 import "package:shared_preferences/shared_preferences.dart";
 
@@ -32,9 +31,7 @@ class LocationService {
 
   void init(SharedPreferences preferences) {
     prefs = preferences;
-    if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
-      _loadCities();
-    }
+    _loadCities();
   }
 
   Future<Iterable<LocalEntity<LocationTag>>> _getStoredLocationTags() async {

+ 1 - 1
lib/services/update_service.dart

@@ -16,7 +16,7 @@ class UpdateService {
   static final UpdateService instance = UpdateService._privateConstructor();
   static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
   static const changeLogVersionKey = "update_change_log_key";
-  static const currentChangeLogVersion = 13;
+  static const currentChangeLogVersion = 14;
 
   LatestVersionInfo? _latestVersion;
   final _logger = Logger("UpdateService");

+ 5 - 0
lib/ui/components/info_item_widget.dart

@@ -8,6 +8,7 @@ class InfoItemWidget extends StatelessWidget {
   final IconData leadingIcon;
   final VoidCallback? editOnTap;
   final String? title;
+  final Widget? endSection;
   final Future<List<Widget>> subtitleSection;
   final bool hasChipButtons;
   final VoidCallback? onTap;
@@ -15,6 +16,7 @@ class InfoItemWidget extends StatelessWidget {
     required this.leadingIcon,
     this.editOnTap,
     this.title,
+    this.endSection,
     required this.subtitleSection,
     this.hasChipButtons = false,
     this.onTap,
@@ -70,6 +72,9 @@ class InfoItemWidget extends StatelessWidget {
         ),
       ),
     ]);
+
+    endSection != null ? children.add(endSection!) : null;
+
     return Row(
       mainAxisAlignment: MainAxisAlignment.spaceBetween,
       crossAxisAlignment: CrossAxisAlignment.start,

+ 6 - 0
lib/ui/map/enable_map.dart

@@ -48,3 +48,9 @@ Future<bool> requestForMapEnable(BuildContext context) async {
   }
   return false;
 }
+
+//For debugging.
+void disableMap() {
+  UserRemoteFlagService.instance
+      .setBoolValue(UserRemoteFlagService.mapEnabled, false);
+}

+ 12 - 4
lib/ui/map/map_marker.dart

@@ -2,20 +2,28 @@ import "package:flutter/material.dart";
 import "package:flutter_map/flutter_map.dart";
 import "package:latlong2/latlong.dart";
 import "package:photos/ui/map/image_marker.dart";
+import "package:photos/ui/map/map_view.dart";
 import "package:photos/ui/map/marker_image.dart";
 
-Marker mapMarker(ImageMarker imageMarker, String key) {
+Marker mapMarker(
+  ImageMarker imageMarker,
+  String key, {
+  Size markerSize = MapView.defaultMarkerSize,
+}) {
   return Marker(
+    //-6.5 is for taking in the height of the MarkerPointer
+    anchorPos: AnchorPos.exactly(Anchor(markerSize.height / 2, -6.5)),
     key: Key(key),
-    width: 75,
-    height: 75,
+    width: markerSize.width,
+    height: markerSize.height,
     point: LatLng(
       imageMarker.latitude,
       imageMarker.longitude,
     ),
     builder: (context) => MarkerImage(
       file: imageMarker.imageFile,
-      seperator: 85,
+      seperator: (MapView.defaultMarkerSize.height + 10) -
+          (MapView.defaultMarkerSize.height - markerSize.height),
     ),
   );
 }

+ 68 - 42
lib/ui/map/map_screen.dart

@@ -2,6 +2,7 @@ import "dart:async";
 import "dart:isolate";
 
 import "package:collection/collection.dart";
+import "package:computer/computer.dart";
 import "package:flutter/foundation.dart";
 import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
@@ -22,10 +23,14 @@ class MapScreen extends StatefulWidget {
   // Add a function parameter where the function returns a Future<List<File>>
 
   final Future<List<EnteFile>> Function() filesFutureFn;
+  final LatLng? center;
+  final double initialZoom;
 
   const MapScreen({
     super.key,
     required this.filesFutureFn,
+    this.center,
+    this.initialZoom = 4.5,
   });
 
   @override
@@ -41,11 +46,10 @@ class _MapScreenState extends State<MapScreen> {
       StreamController<List<EnteFile>>.broadcast();
   MapController mapController = MapController();
   bool isLoading = true;
-  double initialZoom = 4.5;
   double maxZoom = 18.0;
   double minZoom = 2.8;
   int debounceDuration = 500;
-  LatLng center = const LatLng(46.7286, 4.8614);
+  late LatLng center;
   final Logger _logger = Logger("_MapScreenState");
   StreamSubscription? _mapMoveSubscription;
   Isolate? isolate;
@@ -67,6 +71,7 @@ class _MapScreenState extends State<MapScreen> {
 
   Future<void> initialize() async {
     try {
+      center = widget.center ?? const LatLng(46.7286, 4.8614);
       allImages = await widget.filesFutureFn();
       unawaited(processFiles(allImages));
     } catch (e, s) {
@@ -75,47 +80,25 @@ class _MapScreenState extends State<MapScreen> {
   }
 
   Future<void> processFiles(List<EnteFile> files) async {
-    final List<ImageMarker> tempMarkers = [];
-    bool hasAnyLocation = false;
-    EnteFile? mostRecentFile;
-    for (var file in files) {
-      if (file.hasLocation) {
-        if (!Location.isValidRange(
-          latitude: file.location!.latitude!,
-          longitude: file.location!.longitude!,
-        )) {
-          _logger.warning(
-            'Skipping file with invalid location ${file.toString()}',
-          );
-          continue;
-        }
-        hasAnyLocation = true;
-        if (mostRecentFile == null) {
-          mostRecentFile = file;
-        } else {
-          if ((mostRecentFile.creationTime ?? 0) < (file.creationTime ?? 0)) {
-            mostRecentFile = file;
-          }
-        }
+    final result = await Computer.shared().compute(
+      _findRecentFileAndGenerateTempMarkers,
+      param: {"files": files, "center": widget.center},
+    );
 
-        tempMarkers.add(
-          ImageMarker(
-            latitude: file.location!.latitude!,
-            longitude: file.location!.longitude!,
-            imageFile: file,
-          ),
-        );
-      }
-    }
+    final EnteFile? mostRecentFile = result.$1;
+    final List<ImageMarker> tempMarkers = result.$2;
 
-    if (hasAnyLocation) {
-      center = LatLng(
-        mostRecentFile!.location!.latitude!,
-        mostRecentFile.location!.longitude!,
-      );
+    if (tempMarkers.isNotEmpty) {
+      center = widget.center ??
+          LatLng(
+            mostRecentFile!.location!.latitude!,
+            mostRecentFile.location!.longitude!,
+          );
 
       if (kDebugMode) {
-        debugPrint("Info for map: center $center, initialZoom $initialZoom");
+        debugPrint(
+          "Info for map: center $center, initialZoom ${widget.initialZoom}",
+        );
       }
     } else {
       showShortToast(context, S.of(context).noImagesWithLocation);
@@ -127,7 +110,7 @@ class _MapScreenState extends State<MapScreen> {
 
     mapController.move(
       center,
-      initialZoom,
+      widget.initialZoom,
     );
 
     Timer(Duration(milliseconds: debounceDuration), () {
@@ -163,6 +146,50 @@ class _MapScreenState extends State<MapScreen> {
     });
   }
 
+  static (EnteFile?, List<ImageMarker>) _findRecentFileAndGenerateTempMarkers(
+    Map<String, dynamic> args,
+  ) {
+    final Logger logger = Logger("_MapScreenState");
+    final files = args["files"] as List<EnteFile>;
+    final center = args["center"] as LatLng?;
+    final List<ImageMarker> tempMarkers = [];
+    EnteFile? mostRecentFile;
+
+    for (var file in files) {
+      if (file.hasLocation) {
+        if (!Location.isValidRange(
+          latitude: file.location!.latitude!,
+          longitude: file.location!.longitude!,
+        )) {
+          logger.warning(
+            'Skipping file with invalid location ${file.toString()}',
+          );
+          continue;
+        }
+
+        if (center == null) {
+          if (mostRecentFile == null) {
+            mostRecentFile = file;
+          } else {
+            if ((mostRecentFile.creationTime ?? 0) < (file.creationTime ?? 0)) {
+              mostRecentFile = file;
+            }
+          }
+        }
+
+        tempMarkers.add(
+          ImageMarker(
+            latitude: file.location!.latitude!,
+            longitude: file.location!.longitude!,
+            imageFile: file,
+          ),
+        );
+      }
+    }
+
+    return (mostRecentFile, tempMarkers);
+  }
+
   @pragma('vm:entry-point')
   static void _calculateMarkersIsolate(MapIsolate message) async {
     final bounds = message.bounds;
@@ -211,10 +238,9 @@ class _MapScreenState extends State<MapScreen> {
                       imageMarkers: imageMarkers,
                       updateVisibleImages: calculateVisibleMarkers,
                       center: center,
-                      initialZoom: initialZoom,
+                      initialZoom: widget.initialZoom,
                       minZoom: minZoom,
                       maxZoom: maxZoom,
-                      debounceDuration: debounceDuration,
                       bottomSheetDraggableAreaHeight:
                           bottomSheetDraggableAreaHeight,
                     ),

+ 71 - 46
lib/ui/map/map_view.dart

@@ -18,8 +18,13 @@ class MapView extends StatefulWidget {
   final double minZoom;
   final double maxZoom;
   final double initialZoom;
-  final int debounceDuration;
   final double bottomSheetDraggableAreaHeight;
+  final bool showControls;
+  final int interactiveFlags;
+  final VoidCallback? onTap;
+  final Size markerSize;
+  final MapAttributionOptions mapAttributionOptions;
+  static const defaultMarkerSize = Size(75, 75);
 
   const MapView({
     Key? key,
@@ -30,8 +35,12 @@ class MapView extends StatefulWidget {
     required this.minZoom,
     required this.maxZoom,
     required this.initialZoom,
-    required this.debounceDuration,
     required this.bottomSheetDraggableAreaHeight,
+    this.mapAttributionOptions = const MapAttributionOptions(),
+    this.markerSize = MapView.defaultMarkerSize,
+    this.onTap,
+    this.interactiveFlags = InteractiveFlag.all,
+    this.showControls = true,
   }) : super(key: key);
 
   @override
@@ -71,6 +80,11 @@ class _MapViewState extends State<MapView> {
         FlutterMap(
           mapController: widget.controller,
           options: MapOptions(
+            onTap: widget.onTap != null
+                ? (_, __) {
+                    widget.onTap!.call();
+                  }
+                : null,
             center: widget.center,
             minZoom: widget.minZoom,
             maxZoom: widget.maxZoom,
@@ -85,13 +99,16 @@ class _MapViewState extends State<MapView> {
                 onChange(position.bounds!);
               }
             },
+            interactiveFlags: widget.interactiveFlags,
           ),
           nonRotatedChildren: [
             Padding(
               padding: EdgeInsets.only(
                 bottom: widget.bottomSheetDraggableAreaHeight,
               ),
-              child: const OSMFranceTileAttributes(),
+              child: OSMFranceTileAttributes(
+                options: widget.mapAttributionOptions,
+              ),
             ),
           ],
           children: [
@@ -101,7 +118,7 @@ class _MapViewState extends State<MapView> {
                 anchorPos: AnchorPos.align(AnchorAlign.top),
                 maxClusterRadius: 100,
                 showPolygon: false,
-                size: const Size(75, 75),
+                size: widget.markerSize,
                 fitBoundsOptions: const FitBoundsOptions(
                   padding: EdgeInsets.all(80),
                 ),
@@ -133,47 +150,51 @@ class _MapViewState extends State<MapView> {
             ),
           ],
         ),
-        Positioned(
-          top: 4,
-          left: 10,
-          child: SafeArea(
-            child: MapButton(
-              icon: Icons.arrow_back,
-              onPressed: () {
-                Navigator.pop(context);
-              },
-              heroTag: 'back',
-            ),
-          ),
-        ),
-        Positioned(
-          bottom: widget.bottomSheetDraggableAreaHeight + 10,
-          right: 10,
-          child: Column(
-            children: [
-              MapButton(
-                icon: Icons.add,
-                onPressed: () {
-                  widget.controller.move(
-                    widget.controller.center,
-                    widget.controller.zoom + 1,
-                  );
-                },
-                heroTag: 'zoom-in',
-              ),
-              MapButton(
-                icon: Icons.remove,
-                onPressed: () {
-                  widget.controller.move(
-                    widget.controller.center,
-                    widget.controller.zoom - 1,
-                  );
-                },
-                heroTag: 'zoom-out',
-              ),
-            ],
-          ),
-        ),
+        widget.showControls
+            ? Positioned(
+                top: 4,
+                left: 10,
+                child: SafeArea(
+                  child: MapButton(
+                    icon: Icons.arrow_back,
+                    onPressed: () {
+                      Navigator.pop(context);
+                    },
+                    heroTag: 'back',
+                  ),
+                ),
+              )
+            : const SizedBox.shrink(),
+        widget.showControls
+            ? Positioned(
+                bottom: widget.bottomSheetDraggableAreaHeight + 10,
+                right: 10,
+                child: Column(
+                  children: [
+                    MapButton(
+                      icon: Icons.add,
+                      onPressed: () {
+                        widget.controller.move(
+                          widget.controller.center,
+                          widget.controller.zoom + 1,
+                        );
+                      },
+                      heroTag: 'zoom-in',
+                    ),
+                    MapButton(
+                      icon: Icons.remove,
+                      onPressed: () {
+                        widget.controller.move(
+                          widget.controller.center,
+                          widget.controller.zoom - 1,
+                        );
+                      },
+                      heroTag: 'zoom-out',
+                    ),
+                  ],
+                ),
+              )
+            : const SizedBox.shrink(),
       ],
     );
   }
@@ -181,7 +202,11 @@ class _MapViewState extends State<MapView> {
   List<Marker> _buildMakers() {
     return List<Marker>.generate(widget.imageMarkers.length, (index) {
       final imageMarker = widget.imageMarkers[index];
-      return mapMarker(imageMarker, index.toString());
+      return mapMarker(
+        imageMarker,
+        index.toString(),
+        markerSize: widget.markerSize,
+      );
     });
   }
 }

+ 17 - 16
lib/ui/map/tile/attribution/map_attribution.dart

@@ -5,7 +5,9 @@ import "dart:async";
 import "package:flutter/material.dart";
 import "package:flutter_map/plugin_api.dart";
 import "package:photos/extensions/list.dart";
+import "package:photos/theme/colors.dart";
 import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/components/buttons/icon_button_widget.dart";
 
 // Credit: This code is based on the Rich Attribution widget from the flutter_map
 class MapAttributionWidget extends StatefulWidget {
@@ -87,6 +89,8 @@ class MapAttributionWidget extends StatefulWidget {
   ///
   /// Read the documentation on the individual properties for more information
   /// and customizability.
+
+  final double iconSize;
   const MapAttributionWidget({
     super.key,
     required this.attributions,
@@ -99,6 +103,7 @@ class MapAttributionWidget extends StatefulWidget {
     this.showFlutterMapAttribution = true,
     this.animationConfig = const FadeRAWA(),
     this.popupInitialDisplayDuration = Duration.zero,
+    this.iconSize = 20,
   });
 
   @override
@@ -168,27 +173,23 @@ class MapAttributionWidgetState extends State<MapAttributionWidget> {
         duration: widget.animationConfig.buttonDuration,
         child: popupExpanded
             ? (widget.closeButton ??
-                (context, close) => IconButton(
-                      onPressed: close,
-                      icon: Icon(
-                        Icons.cancel_outlined,
-                        color: Theme.of(context).textTheme.titleSmall?.color ??
-                            Colors.black,
-                        size: widget.permanentHeight,
-                      ),
+                (context, close) => IconButtonWidget(
+                      size: widget.iconSize,
+                      onTap: close,
+                      icon: Icons.cancel_outlined,
+                      iconButtonType: IconButtonType.primary,
+                      iconColor: getEnteColorScheme(context).strokeBase,
                     ))(
                 context,
                 () => setState(() => popupExpanded = false),
               )
             : (widget.openButton ??
-                (context, open) => IconButton(
-                      onPressed: open,
-                      tooltip: 'Attributions',
-                      icon: Icon(
-                        Icons.info_outlined,
-                        size: widget.permanentHeight,
-                        color: getEnteColorScheme(context).backgroundElevated,
-                      ),
+                (context, open) => IconButtonWidget(
+                      size: widget.iconSize,
+                      onTap: open,
+                      icon: Icons.info_outlined,
+                      iconButtonType: IconButtonType.primary,
+                      iconColor: strokeBaseLight,
                     ))(
                 context,
                 () {

+ 25 - 4
lib/ui/map/tile/layers.dart

@@ -9,6 +9,18 @@ import "package:url_launcher/url_launcher_string.dart";
 
 const String _userAgent = "io.ente.photos";
 
+class MapAttributionOptions {
+  final double permanentHeight;
+  final BorderRadius popupBorderRadius;
+  final double iconSize;
+
+  const MapAttributionOptions({
+    this.permanentHeight = 24,
+    this.popupBorderRadius = const BorderRadius.all(Radius.circular(12)),
+    this.iconSize = 20,
+  });
+}
+
 class OSMTileLayer extends StatelessWidget {
   const OSMTileLayer({super.key});
 
@@ -42,28 +54,37 @@ class OSMFranceTileLayer extends StatelessWidget {
 }
 
 class OSMFranceTileAttributes extends StatelessWidget {
-  const OSMFranceTileAttributes({super.key});
+  final MapAttributionOptions options;
+  const OSMFranceTileAttributes({
+    this.options = const MapAttributionOptions(),
+    super.key,
+  });
 
   @override
   Widget build(BuildContext context) {
+    final textTheme = getEnteTextTheme(context).tinyBold;
     return MapAttributionWidget(
       alignment: AttributionAlignment.bottomLeft,
       showFlutterMapAttribution: false,
+      permanentHeight: options.permanentHeight,
+      popupBackgroundColor: getEnteColorScheme(context).backgroundElevated,
+      popupBorderRadius: options.popupBorderRadius,
+      iconSize: options.iconSize,
       attributions: [
         TextSourceAttribution(
           S.of(context).openstreetmapContributors,
-          textStyle: getEnteTextTheme(context).smallBold,
+          textStyle: textTheme,
           onTap: () => launchUrlString('https://openstreetmap.org/copyright'),
         ),
         TextSourceAttribution(
           'HOT Tiles',
-          textStyle: getEnteTextTheme(context).smallBold,
+          textStyle: textTheme,
           onTap: () => launchUrl(Uri.parse('https://www.hotosm.org/')),
         ),
         TextSourceAttribution(
           S.of(context).hostedAtOsmFrance,
+          textStyle: textTheme,
           onTap: () => launchUrl(Uri.parse('https://www.openstreetmap.fr/')),
-          textStyle: getEnteTextTheme(context).smallBold,
         ),
       ],
     );

+ 31 - 8
lib/ui/notification/update/change_log_page.dart

@@ -1,3 +1,5 @@
+import "dart:async";
+
 import 'package:flutter/material.dart';
 import "package:photos/generated/l10n.dart";
 import 'package:photos/services/update_service.dart';
@@ -7,6 +9,7 @@ import 'package:photos/ui/components/divider_widget.dart';
 import 'package:photos/ui/components/models/button_type.dart';
 import 'package:photos/ui/components/title_bar_title_widget.dart';
 import 'package:photos/ui/notification/update/change_log_entry.dart';
+import "package:url_launcher/url_launcher_string.dart";
 
 class ChangeLogPage extends StatefulWidget {
   const ChangeLogPage({
@@ -81,13 +84,28 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
                     ButtonWidget(
                       buttonType: ButtonType.trailingIconSecondary,
                       buttonSize: ButtonSize.large,
-                      labelText: S.of(context).rateTheApp,
-                      icon: Icons.favorite_rounded,
+                      labelText: S.of(context).joinDiscord,
+                      icon: Icons.discord_outlined,
                       iconColor: enteColorScheme.primary500,
                       onTap: () async {
-                        await UpdateService.instance.launchReviewUrl();
+                        unawaited(
+                          launchUrlString(
+                            "https://discord.com/invite/z2YVKkycX3",
+                            mode: LaunchMode.externalApplication,
+                          ),
+                        );
                       },
                     ),
+                    // ButtonWidget(
+                    //   buttonType: ButtonType.trailingIconSecondary,
+                    //   buttonSize: ButtonSize.large,
+                    //   labelText: S.of(context).rateTheApp,
+                    //   icon: Icons.favorite_rounded,
+                    //   iconColor: enteColorScheme.primary500,
+                    //   onTap: () async {
+                    //     await UpdateService.instance.launchReviewUrl();
+                    //   },
+                    // ),
                     const SizedBox(height: 8),
                   ],
                 ),
@@ -102,13 +120,18 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
   Widget _getChangeLog() {
     final scrollController = ScrollController();
     final List<ChangeLogEntry> items = [];
-    items.add(
+    items.addAll([
       ChangeLogEntry(
-        "Explore with the new Search Tab ✨",
-        'Introducing a dedicated search tab with distinct sections for effortless discovery.\n'
-            '\nYou can now discover items that come under different Locations, Moments, Contacts, Photo descriptions, Albums and File types with ease.\n',
+        "Map View ✨",
+        'You can now view the location where a photo was clicked.\n'
+            '\nOpen a photo and tap the Info button to view its place on the map!',
       ),
-    );
+      ChangeLogEntry(
+        "Bug Fixes",
+        'Many a bugs were squashed in this release.\n'
+            '\nIf you run into any, please write to team@ente.io, or let us know on Discord! 🙏',
+      ),
+    ]);
 
     return Container(
       padding: const EdgeInsets.only(left: 16),

+ 1 - 0
lib/ui/viewer/file/file_details_widget.dart

@@ -145,6 +145,7 @@ class _FileDetailsWidgetState extends State<FileDetailsWidget> {
         },
       ),
     );
+
     fileDetailsTiles.addAll([
       ValueListenableBuilder(
         valueListenable: hasLocationData,

+ 8 - 0
lib/ui/viewer/file/video_widget_new.dart

@@ -7,6 +7,8 @@ import "package:logging/logging.dart";
 import "package:media_kit/media_kit.dart";
 import "package:media_kit_video/media_kit_video.dart";
 import "package:photos/core/constants.dart";
+import "package:photos/core/event_bus.dart";
+import "package:photos/events/pause_video_event.dart";
 import "package:photos/generated/l10n.dart";
 import "package:photos/models/file/extensions/file_props.dart";
 import "package:photos/models/file/file.dart";
@@ -43,6 +45,7 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
   final _progressNotifier = ValueNotifier<double?>(null);
   late StreamSubscription<bool> playingStreamSubscription;
   bool _isAppInFG = true;
+  late StreamSubscription<PauseVideoEvent> pauseVideoSubscription;
 
   @override
   void initState() {
@@ -83,6 +86,10 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
         widget.playbackCallback!(event);
       }
     });
+
+    pauseVideoSubscription = Bus.instance.on<PauseVideoEvent>().listen((event) {
+      player.pause();
+    });
   }
 
   @override
@@ -96,6 +103,7 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
 
   @override
   void dispose() {
+    pauseVideoSubscription.cancel();
     removeCallBack(widget.file);
     _progressNotifier.dispose();
     WidgetsBinding.instance.removeObserver(this);

+ 3 - 0
lib/ui/viewer/file_details/albums_item_widget.dart

@@ -1,6 +1,8 @@
 import "package:flutter/material.dart";
 import "package:logging/logging.dart";
+import "package:photos/core/event_bus.dart";
 import "package:photos/db/files_db.dart";
+import "package:photos/events/pause_video_event.dart";
 import "package:photos/generated/l10n.dart";
 import 'package:photos/models/collection/collection.dart';
 import 'package:photos/models/collection/collection_items.dart';
@@ -87,6 +89,7 @@ class AlbumsItemWidget extends StatelessWidget {
               if (c.isHidden()) {
                 return;
               }
+              Bus.instance.fire(PauseVideoEvent());
               routeToPage(
                 context,
                 CollectionPage(

+ 2 - 2
lib/ui/viewer/file_details/file_properties_item_widget.dart

@@ -51,13 +51,13 @@ class _FilePropertiesItemWidgetState extends State<FilePropertiesItemWidget> {
     final StringBuffer dimString = StringBuffer();
     if (widget.exifData["resolution"] != null &&
         widget.exifData["megaPixels"] != null) {
-      dimString.write('${widget.exifData["megaPixels"]}MP ');
+      dimString.write('${widget.exifData["megaPixels"]}MP   ');
       dimString.write('${widget.exifData["resolution"]}');
     } else if (widget.file.hasDimensions) {
       final double megaPixels =
           (widget.file.width * widget.file.height) / 1000000;
       final double roundedMegaPixels = (megaPixels * 10).round() / 10.0;
-      dimString.write('${roundedMegaPixels.toStringAsFixed(1)}MP ');
+      dimString.write('${roundedMegaPixels.toStringAsFixed(1)}MP   ');
       dimString.write('${widget.file.width} x ${widget.file.height}');
     }
     final subSectionWidgets = <Widget>[];

+ 227 - 1
lib/ui/viewer/file_details/location_tags_widget.dart

@@ -1,15 +1,28 @@
 import "dart:async";
+import "dart:ui";
 
 import "package:flutter/material.dart";
+import "package:flutter_animate/flutter_animate.dart";
+import "package:flutter_map/flutter_map.dart";
+import "package:latlong2/latlong.dart";
+
 import "package:photos/core/event_bus.dart";
 import "package:photos/events/location_tag_updated_event.dart";
 import "package:photos/generated/l10n.dart";
 import "package:photos/models/file/file.dart";
 import "package:photos/services/location_service.dart";
+import "package:photos/services/search_service.dart";
+import "package:photos/services/user_remote_flag_service.dart";
 import "package:photos/states/location_screen_state.dart";
 import "package:photos/theme/ente_theme.dart";
 import "package:photos/ui/components/buttons/chip_button_widget.dart";
 import "package:photos/ui/components/info_item_widget.dart";
+import "package:photos/ui/map/enable_map.dart";
+import "package:photos/ui/map/image_marker.dart";
+import "package:photos/ui/map/map_screen.dart";
+import "package:photos/ui/map/map_view.dart";
+import "package:photos/ui/map/tile/layers.dart";
+
 import 'package:photos/ui/viewer/location/add_location_sheet.dart';
 import "package:photos/ui/viewer/location/location_screen.dart";
 import "package:photos/utils/navigation_util.dart";
@@ -29,13 +42,19 @@ class _LocationTagsWidgetState extends State<LocationTagsWidget> {
   late Future<List<Widget>> locationTagChips;
   late StreamSubscription<LocationTagUpdatedEvent> _locTagUpdateListener;
   VoidCallback? onTap;
+  bool _loadedLocationTags = false;
+
   @override
   void initState() {
-    locationTagChips = _getLocationTags();
+    locationTagChips = _getLocationTags().then((value) {
+      _loadedLocationTags = true;
+      return value;
+    });
     _locTagUpdateListener =
         Bus.instance.on<LocationTagUpdatedEvent>().listen((event) {
       locationTagChips = _getLocationTags();
     });
+
     super.initState();
   }
 
@@ -58,6 +77,9 @@ class _LocationTagsWidgetState extends State<LocationTagsWidget> {
         subtitleSection: locationTagChips,
         hasChipButtons: hasChipButtons ?? true,
         onTap: onTap,
+        endSection: _loadedLocationTags
+            ? InfoMap(widget.file)
+            : const SizedBox.shrink(),
 
         /// to be used when state issues are fixed when location is updated
         // editOnTap: widget.file.ownerID == Configuration.instance.getUserID()!
@@ -83,6 +105,7 @@ class _LocationTagsWidgetState extends State<LocationTagsWidget> {
   }
 
   Future<List<Widget>> _getLocationTags() async {
+    // await Future.delayed(const Duration(seconds: 1));
     final locationTags = await LocationService.instance
         .enclosingLocationTags(widget.file.location!);
     if (locationTags.isEmpty) {
@@ -139,3 +162,206 @@ class _LocationTagsWidgetState extends State<LocationTagsWidget> {
     }
   }
 }
+
+class InfoMap extends StatefulWidget {
+  final EnteFile file;
+  const InfoMap(this.file, {super.key});
+
+  @override
+  State<InfoMap> createState() => _InfoMapState();
+}
+
+class _InfoMapState extends State<InfoMap> {
+  final _mapController = MapController();
+  late bool _hasEnabledMap;
+  late double _fileLat;
+  late double _fileLng;
+  static const _enabledMapZoom = 12.0;
+  static const _disabledMapZoom = 9.0;
+  bool _tappedToOpenMap = false;
+  final _past250msAfterInit = ValueNotifier(false);
+
+  @override
+  void initState() {
+    super.initState();
+    _hasEnabledMap = UserRemoteFlagService.instance
+        .getCachedBoolValue(UserRemoteFlagService.mapEnabled);
+    _fileLat = widget.file.location!.latitude!;
+    _fileLng = widget.file.location!.longitude!;
+
+    Future.delayed(const Duration(milliseconds: 250), () {
+      _past250msAfterInit.value = true;
+    });
+  }
+
+  @override
+  void dispose() {
+    _mapController.dispose();
+    _past250msAfterInit.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(top: 8),
+      child: ClipRRect(
+        clipBehavior: Clip.antiAliasWithSaveLayer,
+        borderRadius: const BorderRadius.all(Radius.circular(8)),
+        child: SizedBox(
+          height: 124,
+          child: _hasEnabledMap
+              ? Stack(
+                  clipBehavior: Clip.none,
+                  key: ValueKey(_hasEnabledMap),
+                  children: [
+                    MapView(
+                      updateVisibleImages: () {},
+                      imageMarkers: [
+                        ImageMarker(
+                          imageFile: widget.file,
+                          latitude: _fileLat,
+                          longitude: _fileLng,
+                        ),
+                      ],
+                      controller: _mapController,
+                      center: LatLng(
+                        _fileLat,
+                        _fileLng,
+                      ),
+                      minZoom: _enabledMapZoom,
+                      maxZoom: _enabledMapZoom,
+                      initialZoom: _enabledMapZoom,
+                      bottomSheetDraggableAreaHeight: 0,
+                      showControls: false,
+                      interactiveFlags: InteractiveFlag.none,
+                      mapAttributionOptions: MapAttributionOptions(
+                        permanentHeight: 16,
+                        popupBorderRadius: BorderRadius.circular(4),
+                        iconSize: 16,
+                      ),
+                      onTap: enabledMapOnTap,
+                      markerSize: const Size(45, 45),
+                    ),
+                    IgnorePointer(
+                      child: Container(
+                        decoration: BoxDecoration(
+                          borderRadius: BorderRadius.circular(8),
+                          border: Border.all(
+                            color: getEnteColorScheme(context).strokeFaint,
+                          ),
+                        ),
+                      ),
+                    ),
+                  ],
+                )
+              : ValueListenableBuilder(
+                  valueListenable: _past250msAfterInit,
+                  builder: (context, value, _) {
+                    return value
+                        ? Stack(
+                            key: ValueKey(_hasEnabledMap),
+                            clipBehavior: Clip.none,
+                            children: [
+                              MapView(
+                                updateVisibleImages: () {},
+                                imageMarkers: const [],
+                                controller: _mapController,
+                                center: const LatLng(
+                                  13.041599,
+                                  77.594566,
+                                ),
+                                minZoom: _disabledMapZoom,
+                                maxZoom: _disabledMapZoom,
+                                initialZoom: _disabledMapZoom,
+                                bottomSheetDraggableAreaHeight: 0,
+                                showControls: false,
+                                interactiveFlags: InteractiveFlag.none,
+                                mapAttributionOptions:
+                                    const MapAttributionOptions(
+                                  iconSize: 0,
+                                ),
+                              ),
+                              BackdropFilter(
+                                filter: ImageFilter.blur(
+                                  sigmaX: 2.8,
+                                  sigmaY: 2.8,
+                                ),
+                                child: Container(
+                                  color: getEnteColorScheme(context)
+                                      .backgroundElevated
+                                      .withOpacity(0.5),
+                                ),
+                              ),
+                              Container(
+                                decoration: BoxDecoration(
+                                  borderRadius: BorderRadius.circular(8),
+                                  border: Border.all(
+                                    color:
+                                        getEnteColorScheme(context).strokeFaint,
+                                  ),
+                                ),
+                              ),
+                              GestureDetector(
+                                behavior: HitTestBehavior.opaque,
+                                onTap: () async {
+                                  unawaited(
+                                    requestForMapEnable(context).then((value) {
+                                      if (value) {
+                                        setState(() {
+                                          _hasEnabledMap = true;
+                                        });
+                                      }
+                                    }),
+                                  );
+                                },
+                                child: Center(
+                                  child: Text(
+                                    S.of(context).enableMaps,
+                                    style: getEnteTextTheme(context).small,
+                                  ),
+                                ),
+                              ),
+                            ],
+                          ).animate().fadeIn(
+                              duration: const Duration(milliseconds: 90),
+                              curve: Curves.easeIn,
+                            )
+                        : const SizedBox.shrink();
+                  },
+                ),
+        ),
+      ),
+    ).animate(target: _tappedToOpenMap ? 1 : 0).scaleXY(
+          end: 1.025,
+          duration: const Duration(milliseconds: 220),
+          curve: Curves.easeInOut,
+        );
+  }
+
+  void enabledMapOnTap() async {
+    setState(() {
+      _tappedToOpenMap = true;
+    });
+    unawaited(
+      Navigator.of(context)
+          .push(
+        MaterialPageRoute(
+          builder: (context) => MapScreen(
+            filesFutureFn: SearchService.instance.getAllFiles,
+            center: LatLng(
+              _fileLat,
+              _fileLng,
+            ),
+            initialZoom: 16,
+          ),
+        ),
+      )
+          .then((value) {
+        setState(() {
+          _tappedToOpenMap = false;
+        });
+      }),
+    );
+  }
+}

+ 14 - 19
lib/ui/viewer/gallery/component/group/lazy_group_gallery.dart

@@ -56,8 +56,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
 
   late Logger _logger;
 
-  late List<EnteFile> _files;
-  Set<EnteFile>? _filesAsSet;
+  late List<EnteFile> _filesInGroup;
   late StreamSubscription<FilesUpdatedEvent>? _reloadEventSubscription;
   late StreamSubscription<int> _currentIndexSubscription;
   bool? _shouldRender;
@@ -65,7 +64,8 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
   @override
   void initState() {
     super.initState();
-    _areAllFromGroupSelectedNotifier = ValueNotifier(_areAllFromGroupSelected());
+    _areAllFromGroupSelectedNotifier =
+        ValueNotifier(_areAllFromGroupSelected());
 
     widget.selectedFiles?.addListener(_selectedFilesListener);
     _showSelectAllButtonNotifier = ValueNotifier(widget.showSelectAllByDefault);
@@ -75,7 +75,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
   void _init() {
     _logger = Logger("LazyLoading_${widget.logTag}");
     _shouldRender = true;
-    _files = widget.files;
+    _filesInGroup = widget.files;
     _areAllFromGroupSelectedNotifier.value = _areAllFromGroupSelected();
     _reloadEventSubscription = widget.reloadEvent?.listen((e) => _onReload(e));
 
@@ -91,11 +91,6 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
     });
   }
 
-  Set<EnteFile> get _setOfFiles {
-    _filesAsSet ??= _files.toSet();
-    return _filesAsSet!;
-  }
-
   bool _areAllFromGroupSelected() {
     if (widget.selectedFiles != null &&
         widget.selectedFiles!.files.length >= widget.files.length) {
@@ -106,11 +101,11 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
   }
 
   Future _onReload(FilesUpdatedEvent event) async {
-    if (_files.isEmpty) {
+    if (_filesInGroup.isEmpty) {
       return;
     }
     final DateTime groupDate =
-        DateTime.fromMicrosecondsSinceEpoch(_files[0].creationTime!);
+        DateTime.fromMicrosecondsSinceEpoch(_filesInGroup[0].creationTime!);
     // iterate over  files and check if any of the belongs to this group
     final anyCandidateForGroup = event.updatedFiles.any((file) {
       final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
@@ -152,7 +147,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
         final galleryState = context.findAncestorStateOfType<GalleryState>();
         if (galleryState?.mounted ?? false) {
           galleryState!.setState(() {});
-          _files = result.files;
+          _filesInGroup = result.files;
         }
       } else if (kDebugMode) {
         debugPrint("Unexpected event ${event.type.name}");
@@ -172,7 +167,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
   @override
   void didUpdateWidget(LazyGroupGallery oldWidget) {
     super.didUpdateWidget(oldWidget);
-    if (!listEquals(_files, widget.files)) {
+    if (!listEquals(_filesInGroup, widget.files)) {
       _reloadEventSubscription?.cancel();
       _init();
     }
@@ -180,7 +175,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
 
   @override
   Widget build(BuildContext context) {
-    if (_files.isEmpty) {
+    if (_filesInGroup.isEmpty) {
       return const SizedBox.shrink();
     }
     return Column(
@@ -190,7 +185,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
           children: [
             if (widget.enableFileGrouping)
               GroupHeaderWidget(
-                timestamp: _files[0].creationTime!,
+                timestamp: _filesInGroup[0].creationTime!,
                 gridSize: widget.photoGridSize,
               ),
             Expanded(child: Container()),
@@ -226,7 +221,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
                               ),
                               onTap: () {
                                 widget.selectedFiles?.toggleGroupSelection(
-                                  _setOfFiles,
+                                  _filesInGroup.toSet(),
                                 );
                               },
                             );
@@ -237,7 +232,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
         _shouldRender!
             ? GroupGallery(
                 photoGridSize: widget.photoGridSize,
-                files: _files,
+                files: _filesInGroup,
                 tag: widget.tag,
                 asyncLoader: widget.asyncLoader,
                 selectedFiles: widget.selectedFiles,
@@ -246,7 +241,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
             // todo: perf eval should we have separate PlaceHolder for Groups
             //  instead of creating a large cached view
             : PlaceHolderGridViewWidget(
-                _files.length,
+                _filesInGroup.length,
                 widget.photoGridSize,
               ),
       ],
@@ -256,7 +251,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
   void _selectedFilesListener() {
     if (widget.selectedFiles == null) return;
     _areAllFromGroupSelectedNotifier.value =
-        widget.selectedFiles!.files.containsAll(_setOfFiles);
+        widget.selectedFiles!.files.containsAll(_filesInGroup.toSet());
 
     //Can remove this if we decide to show select all by default for all galleries
     if (widget.selectedFiles!.files.isEmpty && !widget.showSelectAllByDefault) {

+ 60 - 0
lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -6,10 +6,14 @@ import "package:flutter/cupertino.dart";
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:photos/core/configuration.dart';
+import "package:photos/core/constants.dart";
 import 'package:photos/core/event_bus.dart';
+import "package:photos/core/network/network.dart";
 import "package:photos/db/files_db.dart";
 import 'package:photos/events/subscription_purchased_event.dart';
+import "package:photos/gateways/cast_gw.dart";
 import "package:photos/generated/l10n.dart";
+import "package:photos/l10n/l10n.dart";
 import 'package:photos/models/backup_status.dart';
 import 'package:photos/models/collection/collection.dart';
 import 'package:photos/models/device_collection.dart';
@@ -36,6 +40,7 @@ import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/magic_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:photos/utils/toast_util.dart';
+import "package:uuid/uuid.dart";
 
 class GalleryAppBarWidget extends StatefulWidget {
   final GalleryType type;
@@ -64,6 +69,7 @@ enum AlbumPopupAction {
   ownedArchive,
   sharedArchive,
   ownedHide,
+  playOnTv,
   sort,
   leave,
   freeUpSpace,
@@ -472,6 +478,22 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
         ),
       );
     }
+    if (widget.collection != null && isInternalUser) {
+      items.add(
+        PopupMenuItem(
+          value: AlbumPopupAction.playOnTv,
+          child: Row(
+            children: [
+              const Icon(Icons.tv_outlined),
+              const Padding(
+                padding: EdgeInsets.all(8),
+              ),
+              Text(context.l10n.playOnTv),
+            ],
+          ),
+        ),
+      );
+    }
 
     if (galleryType.canDelete()) {
       items.add(
@@ -579,6 +601,8 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
               await _removeQuickLink();
             } else if (value == AlbumPopupAction.leave) {
               await _leaveAlbum(context);
+            } else if (value == AlbumPopupAction.playOnTv) {
+              await castAlbum();
             } else if (value == AlbumPopupAction.freeUpSpace) {
               await _deleteBackedUpFiles(context);
             } else if (value == AlbumPopupAction.setCover) {
@@ -797,4 +821,40 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     );
     setState(() {});
   }
+
+  Future<void> castAlbum() async {
+    final gw = CastGateway(NetworkClient.instance.enteDio);
+    // stop any existing cast session
+    gw.revokeAllTokens().ignore();
+    await showTextInputDialog(
+      context,
+      title: context.l10n.playOnTv,
+      body: S.of(context).castInstruction,
+      submitButtonLabel: S.of(context).pair,
+      textInputType: TextInputType.streetAddress,
+      hintText: context.l10n.deviceCodeHint,
+      onSubmit: (String text) async {
+        try {
+          String code = text.trim();
+          final String? publicKey = await gw.getPublicKey(code);
+          if (publicKey == null) {
+            showToast(context, S.of(context).deviceNotFound);
+            return;
+          }
+          final String castToken = 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);
+        }
+      },
+    );
+  }
 }

+ 39 - 1
lib/utils/exif_util.dart

@@ -11,6 +11,11 @@ import 'package:photos/utils/file_util.dart';
 
 const kDateTimeOriginal = "EXIF DateTimeOriginal";
 const kImageDateTime = "Image DateTime";
+const kExifOffSetKeys = [
+  "EXIF OffsetTime",
+  "EXIF OffsetTimeOriginal",
+  "EXIF OffsetTimeDigitized",
+];
 const kExifDateTimePattern = "yyyy:MM:dd HH:mm:ss";
 const kEmptyExifDateTime = "0000:00:00 00:00:00";
 
@@ -56,7 +61,14 @@ Future<DateTime?> getCreationTimeFromEXIF(
             ? exif[kImageDateTime]!.printable
             : null;
     if (exifTime != null && exifTime != kEmptyExifDateTime) {
-      return DateFormat(kExifDateTimePattern).parse(exifTime);
+      String? exifOffsetTime;
+      for (final key in kExifOffSetKeys) {
+        if (exif.containsKey(key)) {
+          exifOffsetTime = exif[key]!.printable;
+          break;
+        }
+      }
+      return getDateTimeInDeviceTimezone(exifTime, exifOffsetTime);
     }
   } catch (e) {
     _logger.severe("failed to getCreationTimeFromEXIF", e);
@@ -64,6 +76,32 @@ Future<DateTime?> getCreationTimeFromEXIF(
   return null;
 }
 
+DateTime getDateTimeInDeviceTimezone(String exifTime, String? offsetString) {
+  final DateTime result = DateFormat(kExifDateTimePattern).parse(exifTime);
+  if (offsetString == null) {
+    return result;
+  }
+  try {
+    final List<String> splitHHMM = offsetString.split(":");
+    // Parse the offset from the photo's time zone
+    final int offsetHours = int.parse(splitHHMM[0]);
+    final int offsetMinutes =
+        int.parse(splitHHMM[1]) * (offsetHours.isNegative ? -1 : 1);
+    // Adjust the date for the offset to get the photo's correct UTC time
+    final photoUtcDate =
+        result.add(Duration(hours: -offsetHours, minutes: -offsetMinutes));
+    // Getting the current device's time zone offset from UTC
+    final now = DateTime.now();
+    final localOffset = now.timeZoneOffset;
+    // Adjusting the photo's UTC time to the device's local time
+    final deviceLocalTime = photoUtcDate.add(localOffset);
+    return deviceLocalTime;
+  } catch (e, s) {
+    _logger.severe("tz offset adjust failed $offsetString", e, s);
+  }
+  return result;
+}
+
 Location? locationFromExif(Map<String, IfdTag> exif) {
   try {
     return gpsDataFromExif(exif).toLocationObj();

+ 1 - 1
pubspec.yaml

@@ -12,7 +12,7 @@ description: ente photos application
 # Read more about iOS versioning at
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 
-version: 0.8.49+569
+version: 0.8.55+575
 publish_to: none
 
 environment: