diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 26c6de81f..2b32b01fb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -112,6 +112,7 @@ "entryActionEdit": "Edit", "entryActionOpen": "Open with", "entryActionSetAs": "Set as", + "entryActionCast": "Cast", "entryActionOpenMap": "Show in map app", "entryActionRotateScreen": "Rotate screen", "entryActionAddFavourite": "Add to favorites", @@ -518,6 +519,8 @@ "tileLayoutGrid": "Grid", "tileLayoutList": "List", + "castDialogTitle": "Cast Devices", + "coverDialogTabCover": "Cover", "coverDialogTabApp": "App", "coverDialogTabColor": "Color", diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 876e6420d..ca9191812 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -76,6 +76,7 @@ class AIcons { static const addShortcut = Icons.add_to_home_screen_outlined; static const cancel = Icons.cancel_outlined; static const captureFrame = Icons.screenshot_outlined; + static const cast = Icons.cast_outlined; static const clear = Icons.clear_outlined; static const clipboard = Icons.content_copy_outlined; static const convert = Icons.transform_outlined; diff --git a/lib/view/src/actions/entry.dart b/lib/view/src/actions/entry.dart index 356ce01cf..301489473 100644 --- a/lib/view/src/actions/entry.dart +++ b/lib/view/src/actions/entry.dart @@ -47,6 +47,7 @@ extension ExtraEntryActionView on EntryAction { EntryAction.open || EntryAction.openVideo => l10n.entryActionOpen, EntryAction.openMap => l10n.entryActionOpenMap, EntryAction.setAs => l10n.entryActionSetAs, + EntryAction.cast => l10n.entryActionCast, // platform EntryAction.rotateScreen => l10n.entryActionRotateScreen, // metadata @@ -120,6 +121,7 @@ extension ExtraEntryActionView on EntryAction { EntryAction.open || EntryAction.openVideo => AIcons.openOutside, EntryAction.openMap => AIcons.map, EntryAction.setAs => AIcons.setAs, + EntryAction.cast => AIcons.cast, // platform EntryAction.rotateScreen => AIcons.rotateScreen, // metadata diff --git a/lib/widgets/dialogs/cast_dialog.dart b/lib/widgets/dialogs/cast_dialog.dart new file mode 100644 index 000000000..4ac33b993 --- /dev/null +++ b/lib/widgets/dialogs/cast_dialog.dart @@ -0,0 +1,61 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:dlna_dart/dlna.dart'; +import 'package:flutter/material.dart'; + +class CastDialog extends StatefulWidget { + static const routeName = '/dialog/cast'; + + const CastDialog({super.key}); + + @override + State createState() => _CastDialogState(); +} + +class _CastDialogState extends State { + final DLNAManager _dlnaManager = DLNAManager(); + final Map _seenRenderers = {}; + + static const String upnpDeviceTypeMediaRenderer = 'urn:schemas-upnp-org:device:MediaRenderer:1'; + + @override + void initState() { + super.initState(); + + _dlnaManager.start().then((deviceManager) { + deviceManager.devices.stream.listen((devices) { + _seenRenderers.addAll(Map.fromEntries(devices.entries.where((kv) => kv.value.info.deviceType == upnpDeviceTypeMediaRenderer))); + setState(() {}); + }); + }); + } + + @override + void dispose() { + _dlnaManager.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + title: context.l10n.castDialogTitle, + scrollableContent: [ + if (_seenRenderers.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ..._seenRenderers.values.map((dev) => ListTile( + title: Text(dev.info.friendlyName), + onTap: () => Navigator.maybeOf(context)?.pop(dev), + )), + ], + actions: const [ + CancelButton(), + ], + ); + } +} diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 56685c672..9a86970a6 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -108,6 +108,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.copyToClipboard: case EntryAction.open: case EntryAction.setAs: + case EntryAction.cast: return !settings.useTvLayout; case EntryAction.info: case EntryAction.share: @@ -256,6 +257,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix appService.setAs(targetEntry.uri, targetEntry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); + case EntryAction.cast: + const CastNotification(true).dispatch(context); // platform case EntryAction.rotateScreen: _rotateScreen(context); diff --git a/lib/widgets/viewer/controls/cast.dart b/lib/widgets/viewer/controls/cast.dart new file mode 100644 index 000000000..d0219e6b4 --- /dev/null +++ b/lib/widgets/viewer/controls/cast.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/props.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/widgets/dialogs/cast_dialog.dart'; +import 'package:collection/collection.dart'; +import 'package:dlna_dart/dlna.dart'; +import 'package:dlna_dart/xmlParser.dart'; +import 'package:flutter/material.dart'; +import 'package:network_info_plus/network_info_plus.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; + +mixin CastMixin { + DLNADevice? _renderer; + HttpServer? _mediaServer; + + bool get isCasting => _renderer != null && _mediaServer != null; + + Future initCast(BuildContext context, List entries) async { + await stopCast(); + + final renderer = await _selectRenderer(context); + _renderer = renderer; + if (renderer == null) return; + debugPrint('cast: select renderer `${renderer.info.friendlyName}` at ${renderer.info.URLBase}'); + + final ip = await NetworkInfo().getWifiIP(); + if (ip == null) return; + + final handler = const Pipeline().addHandler((request) async { + final id = int.tryParse(request.url.path); + if (id != null) { + final entry = entries.firstWhereOrNull((v) => v.id == id); + if (entry != null) { + final bytes = await mediaFetchService.getImage( + entry.uri, + entry.mimeType, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + pageId: entry.pageId, + sizeBytes: entry.sizeBytes, + ); + debugPrint('cast: send ${bytes.length} bytes for entry=$entry'); + return Response.ok( + bytes, + headers: { + 'Content-Type': entry.mimeType, + }, + ); + } + } + return Response.notFound('no resource for url=${request.url}'); + }); + _mediaServer = await shelf_io.serve(handler, ip, 8080); + debugPrint('cast: serving media on $_serverBaseUrl}'); + } + + Future stopCast() async { + if (isCasting) { + debugPrint('cast: stop'); + } + + await _mediaServer?.close(); + _mediaServer = null; + + await _renderer?.stop(); + _renderer = null; + } + + Future _selectRenderer(BuildContext context) async { + return await showDialog( + context: context, + builder: (context) => const CastDialog(), + routeSettings: const RouteSettings(name: CastDialog.routeName), + ); + } + + Future castEntry(AvesEntry entry) async { + final server = _mediaServer; + final renderer = _renderer; + if (server == null || renderer == null) return; + + debugPrint('cast: set entry=$entry'); + try { + await renderer.setUrl( + '$_serverBaseUrl/${entry.id}', + title: entry.bestTitle ?? '', + type: entry.isVideo ? PlayType.Video : PlayType.Image, + ); + await renderer.play(); + } catch (error, stack) { + await reportService.recordError(error, stack); + } + } + + String? get _serverBaseUrl { + final server = _mediaServer; + return server != null ? 'http://${server.address.host}:${server.port}' : null; + } +} diff --git a/lib/widgets/viewer/controls/controller.dart b/lib/widgets/viewer/controls/controller.dart index e92ad27ab..084cd2ce9 100644 --- a/lib/widgets/viewer/controls/controller.dart +++ b/lib/widgets/viewer/controls/controller.dart @@ -3,13 +3,14 @@ import 'dart:math'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/viewer/controls/cast.dart'; import 'package:aves/widgets/viewer/controls/events.dart'; import 'package:aves_magnifier/aves_magnifier.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -class ViewerController { +class ViewerController with CastMixin { final ValueNotifier entryNotifier = ValueNotifier(null); final ViewerTransition transition; final Duration? autopilotInterval; diff --git a/lib/widgets/viewer/controls/notifications.dart b/lib/widgets/viewer/controls/notifications.dart index a170bb91e..7d5d9006f 100644 --- a/lib/widgets/viewer/controls/notifications.dart +++ b/lib/widgets/viewer/controls/notifications.dart @@ -78,6 +78,16 @@ class VideoActionNotification extends Notification { }); } +@immutable +class CastNotification extends Notification with EquatableMixin { + final bool enabled; + + @override + List get props => [enabled]; + + const CastNotification(this.enabled); +} + @immutable class FilterSelectedNotification extends Notification with EquatableMixin { final CollectionFilter filter; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 064f1ffa1..1f0c5cf3e 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -516,6 +516,8 @@ class _EntryViewerStackState extends State with EntryViewContr bool _handleNotification(dynamic notification) { if (notification is FilterSelectedNotification) { _goToCollection(notification.filter); + } else if (notification is CastNotification) { + _cast(notification.enabled); } else if (notification is FullImageLoadedNotification) { final viewStateController = context.read().getOrCreateController(notification.entry); // microtask so that listeners do not trigger during build @@ -581,6 +583,21 @@ class _EntryViewerStackState extends State with EntryViewContr return true; } + Future _cast(bool enabled) async { + if (enabled) { + final entries = collection?.sortedEntries; + if (entries != null) { + await viewerController.initCast(context, entries); + final entry = entryNotifier.value; + if (entry != null) { + await viewerController.castEntry(entry); + } + } + } else { + await viewerController.stopCast(); + } + } + Future _onVideoAction({ required BuildContext context, required AvesEntry entry, @@ -756,6 +773,13 @@ class _EntryViewerStackState extends State with EntryViewContr _isEntryTracked = false; await pauseVideoControllers(); await initEntryControllers(newEntry); + + if (viewerController.isCasting) { + final entry = entryNotifier.value; + if (entry != null) { + await viewerController.castEntry(entry); + } + } } void _onWillPop() { @@ -817,6 +841,8 @@ class _EntryViewerStackState extends State with EntryViewContr // to be unmounted after the other async steps final theme = Theme.of(context); + await viewerController.stopCast(); + switch (settings.maxBrightness) { case MaxBrightness.never: case MaxBrightness.viewerOnly: diff --git a/plugins/aves_model/lib/src/actions/entry.dart b/plugins/aves_model/lib/src/actions/entry.dart index e0a6b68c9..96369cfc1 100644 --- a/plugins/aves_model/lib/src/actions/entry.dart +++ b/plugins/aves_model/lib/src/actions/entry.dart @@ -33,6 +33,7 @@ enum EntryAction { openVideo, openMap, setAs, + cast, // platform rotateScreen, // metadata @@ -82,6 +83,7 @@ class EntryActions { EntryAction.open, EntryAction.openMap, EntryAction.setAs, + EntryAction.cast, ]; static const pageActions = { diff --git a/pubspec.lock b/pubspec.lock index adad19e3b..ff4437047 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -302,6 +302,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + dlna_dart: + dependency: "direct main" + description: + name: dlna_dart + sha256: ae07c1c53077bbf58756fa589f936968719b0085441981d33e74f82f89d1d281 + url: "https://pub.dev" + source: hosted + version: "0.0.8" dynamic_color: dependency: "direct main" description: @@ -920,6 +928,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + network_info_plus: + dependency: "direct main" + description: + name: network_info_plus + sha256: "2d9e88b9a459e5d4e224f828d26cc38ea140511e89b943116939994324be5c96" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: "881f5029c5edaf19c616c201d3d8b366c5b1384afd5c1da5a49e4345de82fb8b" + url: "https://pub.dev" + source: hosted + version: "1.1.3" nm: dependency: transitive description: @@ -1314,7 +1338,7 @@ packages: source: hosted version: "2.3.2" shelf: - dependency: transitive + dependency: "direct main" description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 diff --git a/pubspec.yaml b/pubspec.yaml index 29bf1016a..d26fc9ddc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -117,6 +117,10 @@ dependencies: volume_controller: xml: + dlna_dart: + network_info_plus: + shelf: + dev_dependencies: flutter_test: sdk: flutter diff --git a/untranslated.json b/untranslated.json index 28f9a5ee4..c8a0eb0dc 100644 --- a/untranslated.json +++ b/untranslated.json @@ -51,6 +51,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -286,6 +287,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -644,6 +646,7 @@ ], "be": [ + "entryActionCast", "binEntriesConfirmationDialogMessage", "deleteEntriesConfirmationDialogMessage", "setCoverDialogCustom", @@ -712,6 +715,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -1077,6 +1081,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -1312,6 +1317,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -1689,6 +1695,7 @@ "entryActionFlip", "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", + "entryActionCast", "videoActionCaptureFrame", "videoActionSelectStreams", "viewerActionLock", @@ -1846,6 +1853,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -2218,22 +2226,28 @@ ], "cs": [ + "entryActionCast", "overlayHistogramLuminance", + "castDialogTitle", "aboutDataUsageClearCache" ], "de": [ + "entryActionCast", "overlayHistogramNone", "overlayHistogramRGB", "overlayHistogramLuminance", + "castDialogTitle", "aboutDataUsageClearCache", "settingsViewerShowHistogram" ], "el": [ + "entryActionCast", "overlayHistogramNone", "overlayHistogramRGB", "overlayHistogramLuminance", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -2246,10 +2260,14 @@ ], "es": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "eu": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], @@ -2262,6 +2280,7 @@ "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", + "entryActionCast", "videoActionPause", "videoActionPlay", "videoActionSelectStreams", @@ -2423,6 +2442,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "appPickDialogNone", "aboutLinkLicense", @@ -2774,6 +2794,7 @@ "clearTooltip", "chipActionFilterIn", "entryActionSetAs", + "entryActionCast", "videoActionUnmute", "videoActionSelectStreams", "filterTypeRawLabel", @@ -2937,6 +2958,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -3309,6 +3331,8 @@ ], "fr": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], @@ -3323,6 +3347,7 @@ "chipActionConfigureVault", "entryActionShareImageOnly", "entryActionShareVideoOnly", + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "entryInfoActionExportMetadata", @@ -3489,6 +3514,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -3930,6 +3956,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -4165,6 +4192,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -4586,6 +4614,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -4821,6 +4850,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -5193,14 +5223,20 @@ ], "hu": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "id": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "it": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], @@ -5211,6 +5247,7 @@ "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "editorActionTransform", @@ -5234,6 +5271,7 @@ "vaultBinUsageDialogMessage", "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -5310,6 +5348,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -5545,6 +5584,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -5917,6 +5957,8 @@ ], "ko": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], @@ -5929,6 +5971,7 @@ "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "editorActionTransform", @@ -5972,6 +6015,7 @@ "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -6082,6 +6126,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -6317,6 +6362,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -6689,6 +6735,7 @@ ], "my": [ + "entryActionCast", "accessibilityAnimationsRemove", "accessibilityAnimationsKeep", "overlayHistogramLuminance", @@ -6696,6 +6743,7 @@ "widgetOpenPageCollection", "widgetOpenPageViewer", "menuActionConfigureView", + "castDialogTitle", "aboutDataUsageClearCache", "newFilterBanner", "settingsDefault", @@ -6799,6 +6847,7 @@ ], "nb": [ + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "editorActionTransform", @@ -6810,6 +6859,7 @@ "settingsVideoEnablePip", "widgetTapUpdateWidget", "patternDialogEnter", + "castDialogTitle", "aboutDataUsageInternal", "aboutDataUsageExternal", "aboutDataUsageClearCache", @@ -6826,6 +6876,7 @@ "chipActionShowCountryStates", "entryActionShareImageOnly", "entryActionShareVideoOnly", + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "editorActionTransform", @@ -6864,6 +6915,7 @@ "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -6905,6 +6957,7 @@ "nn": [ "sourceStateCataloguing", + "entryActionCast", "accessibilityAnimationsKeep", "overlayHistogramNone", "overlayHistogramRGB", @@ -6915,6 +6968,7 @@ "authenticateToUnlockVault", "viewDialogSortSectionTitle", "viewDialogReverseSortOrder", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -7006,6 +7060,7 @@ "entryActionViewMotionPhotoVideo", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -7213,6 +7268,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "appPickDialogTitle", "appPickDialogNone", @@ -7552,16 +7608,21 @@ ], "pl": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "pt": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "ro": [ "saveCopyButtonLabel", "applyTooltip", + "entryActionCast", "editorActionTransform", "editorTransformCrop", "editorTransformRotate", @@ -7577,6 +7638,7 @@ "videoResumptionModeAlways", "widgetTapUpdateWidget", "exportEntryDialogQuality", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -7595,10 +7657,14 @@ ], "ru": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "sk": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], @@ -7672,6 +7738,7 @@ "entryActionEdit", "entryActionOpen", "entryActionSetAs", + "entryActionCast", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -7907,6 +7974,7 @@ "tileLayoutMosaic", "tileLayoutGrid", "tileLayoutList", + "castDialogTitle", "coverDialogTabCover", "coverDialogTabApp", "coverDialogTabColor", @@ -8293,6 +8361,7 @@ "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "editorActionTransform", @@ -8336,6 +8405,7 @@ "editEntryDateDialogShift", "removeEntryMetadataDialogTitle", "tooManyItemsErrorDialogMessage", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -8686,6 +8756,7 @@ "chipActionShowCountryStates", "chipActionCreateVault", "chipActionConfigureVault", + "entryActionCast", "viewerActionLock", "viewerActionUnlock", "editorActionTransform", @@ -8725,6 +8796,7 @@ "vaultBinUsageDialogMessage", "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -8757,16 +8829,21 @@ ], "uk": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "vi": [ + "entryActionCast", + "castDialogTitle", "aboutDataUsageClearCache" ], "zh": [ "saveCopyButtonLabel", "chipActionGoToPlacePage", + "entryActionCast", "editorTransformCrop", "cropAspectRatioFree", "cropAspectRatioOriginal", @@ -8798,6 +8875,7 @@ "exportEntryDialogQuality", "exportEntryDialogWriteMetadata", "tooManyItemsErrorDialogMessage", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache", @@ -8833,8 +8911,10 @@ ], "zh_Hant": [ + "entryActionCast", "overlayHistogramNone", "overlayHistogramLuminance", + "castDialogTitle", "aboutDataUsageSectionTitle", "aboutDataUsageData", "aboutDataUsageCache",