diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbfbb6d8..03cfcab23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Viewer: action to add shortcut to media item + +### Fixed + +- video playback was not using hardware-accelerated codecs on recent devices + ## [v1.5.5] - 2021-11-08 ### Added diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index d818180d3..2d8dfe057 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -329,7 +329,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val label = call.argument("label") val iconBytes = call.argument("iconBytes") val filters = call.argument>("filters") - if (label == null || filters == null) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (label == null || (filters == null && uri == null)) { result.error("pin-args", "failed because of missing arguments", null) return } @@ -356,12 +357,19 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection) } - val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) - .putExtra("page", "/collection") - .putExtra("filters", filters.toTypedArray()) - // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut - // so we use a joined `String` as fallback - .putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR)) + val intent = when { + uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java) + filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) + .putExtra("page", "/collection") + .putExtra("filters", filters.toTypedArray()) + // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut + // so we use a joined `String` as fallback + .putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR)) + else -> { + result.error("pin-intent", "failed to build intent", null) + return + } + } // multiple shortcuts sharing the same ID cannot be created with different labels or icons // so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 5d4d580e6..ed722f7e8 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -4,6 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; enum EntryAction { + addShortcut, + copyToClipboard, delete, export, info, @@ -20,7 +22,6 @@ enum EntryAction { // motion photo, viewMotionPhotoVideo, // external - copyToClipboard, edit, open, openMap, @@ -39,6 +40,7 @@ class EntryActions { EntryAction.delete, EntryAction.rename, EntryAction.export, + EntryAction.addShortcut, EntryAction.copyToClipboard, EntryAction.print, EntryAction.viewSource, @@ -63,9 +65,8 @@ class EntryActions { extension ExtraEntryAction on EntryAction { String getText(BuildContext context) { switch (this) { - case EntryAction.toggleFavourite: - // different data depending on toggle state - return context.l10n.entryActionAddFavourite; + case EntryAction.addShortcut: + return context.l10n.collectionActionAddShortcut; case EntryAction.copyToClipboard: return context.l10n.entryActionCopyToClipboard; case EntryAction.delete: @@ -74,12 +75,15 @@ extension ExtraEntryAction on EntryAction { return context.l10n.entryActionExport; case EntryAction.info: return context.l10n.entryActionInfo; - case EntryAction.rename: - return context.l10n.entryActionRename; case EntryAction.print: return context.l10n.entryActionPrint; + case EntryAction.rename: + return context.l10n.entryActionRename; case EntryAction.share: return context.l10n.entryActionShare; + case EntryAction.toggleFavourite: + // different data depending on toggle state + return context.l10n.entryActionAddFavourite; // raster case EntryAction.rotateCCW: return context.l10n.entryActionRotateCCW; @@ -98,10 +102,10 @@ extension ExtraEntryAction on EntryAction { return context.l10n.entryActionEdit; case EntryAction.open: return context.l10n.entryActionOpen; - case EntryAction.setAs: - return context.l10n.entryActionSetAs; case EntryAction.openMap: return context.l10n.entryActionOpenMap; + case EntryAction.setAs: + return context.l10n.entryActionSetAs; // platform case EntryAction.rotateScreen: return context.l10n.entryActionRotateScreen; @@ -129,9 +133,8 @@ extension ExtraEntryAction on EntryAction { IconData? getIconData() { switch (this) { - case EntryAction.toggleFavourite: - // different data depending on toggle state - return AIcons.favourite; + case EntryAction.addShortcut: + return AIcons.addShortcut; case EntryAction.copyToClipboard: return AIcons.clipboard; case EntryAction.delete: @@ -140,12 +143,15 @@ extension ExtraEntryAction on EntryAction { return AIcons.saveAs; case EntryAction.info: return AIcons.info; - case EntryAction.rename: - return AIcons.rename; case EntryAction.print: return AIcons.print; + case EntryAction.rename: + return AIcons.rename; case EntryAction.share: return AIcons.share; + case EntryAction.toggleFavourite: + // different data depending on toggle state + return AIcons.favourite; // raster case EntryAction.rotateCCW: return AIcons.rotateLeft; @@ -162,8 +168,8 @@ extension ExtraEntryAction on EntryAction { // external case EntryAction.edit: case EntryAction.open: - case EntryAction.setAs: case EntryAction.openMap: + case EntryAction.setAs: return null; // platform case EntryAction.rotateScreen: diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index ef7defa72..a4685b1de 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -31,7 +31,7 @@ abstract class AndroidAppService { Future canPinToHomeScreen(); - Future pinToHomeScreen(String label, AvesEntry? entry, Set filters); + Future pinToHomeScreen(String label, AvesEntry? coverEntry, {Set? filters, String? uri}); } class PlatformAndroidAppService implements AndroidAppService { @@ -194,17 +194,17 @@ class PlatformAndroidAppService implements AndroidAppService { } @override - Future pinToHomeScreen(String label, AvesEntry? entry, Set filters) async { + Future pinToHomeScreen(String label, AvesEntry? coverEntry, {Set? filters, String? uri}) async { Uint8List? iconBytes; - if (entry != null) { - final size = entry.isVideo ? 0.0 : 256.0; + if (coverEntry != null) { + final size = coverEntry.isVideo ? 0.0 : 256.0; iconBytes = await mediaFileService.getThumbnail( - uri: entry.uri, - mimeType: entry.mimeType, - pageId: entry.pageId, - rotationDegrees: entry.rotationDegrees, - isFlipped: entry.isFlipped, - dateModifiedSecs: entry.dateModifiedSecs, + uri: coverEntry.uri, + mimeType: coverEntry.mimeType, + pageId: coverEntry.pageId, + rotationDegrees: coverEntry.rotationDegrees, + isFlipped: coverEntry.isFlipped, + dateModifiedSecs: coverEntry.dateModifiedSecs, extent: size, ); } @@ -212,7 +212,8 @@ class PlatformAndroidAppService implements AndroidAppService { await platform.invokeMethod('pin', { 'label': label, 'iconBytes': iconBytes, - 'filters': filters.map((filter) => filter.toJson()).toList(), + 'filters': filters?.map((filter) => filter.toJson()).toList(), + 'uri': uri, }); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 4f9acfb95..d8d5029af 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -586,8 +586,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final result = await showDialog>( context: context, builder: (context) => AddShortcutDialog( - collection: collection, defaultName: defaultName ?? '', + collection: collection, ), ); if (result == null) return; @@ -596,6 +596,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final name = result.item2; if (name.isEmpty) return; - unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); + unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters)); } } diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 213b6ee24..54bf19487 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -1,6 +1,5 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -14,13 +13,13 @@ import 'package:tuple/tuple.dart'; import 'aves_dialog.dart'; class AddShortcutDialog extends StatefulWidget { - final CollectionLens collection; + final CollectionLens? collection; final String defaultName; const AddShortcutDialog({ Key? key, - required this.collection, required this.defaultName, + this.collection, }) : super(key: key); @override @@ -32,17 +31,16 @@ class _AddShortcutDialogState extends State { final ValueNotifier _isValidNotifier = ValueNotifier(false); AvesEntry? _coverEntry; - CollectionLens get collection => widget.collection; - - Set get filters => collection.filters; - @override void initState() { super.initState(); - final entries = collection.sortedEntries; - if (entries.isNotEmpty) { - final coverEntries = filters.map(covers.coverContentId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.contentId == id)).whereNotNull(); - _coverEntry = coverEntries.firstOrNull ?? entries.first; + final _collection = widget.collection; + if (_collection != null) { + final entries = _collection.sortedEntries; + if (entries.isNotEmpty) { + final coverEntries = _collection.filters.map(covers.coverContentId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.contentId == id)).whereNotNull(); + _coverEntry = coverEntries.firstOrNull ?? entries.first; + } } _nameController.text = widget.defaultName; _validate(); @@ -123,14 +121,17 @@ class _AddShortcutDialogState extends State { } Future _pickEntry() async { + final _collection = widget.collection; + if (_collection == null) return; + final entry = await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: ItemPickDialog.routeName), builder: (context) => ItemPickDialog( collection: CollectionLens( - source: collection.source, - filters: filters, + source: _collection.source, + filters: _collection.filters, ), ), fullscreenDialog: true, diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index 11e383422..80e59c7f4 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -36,6 +36,7 @@ class ViewerActionEditorPage extends StatelessWidget { EntryAction.delete, EntryAction.rename, EntryAction.export, + EntryAction.addShortcut, EntryAction.copyToClipboard, EntryAction.print, EntryAction.rotateScreen, diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 58a134383..1f957b809 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -18,6 +18,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; @@ -30,12 +31,13 @@ import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) { switch (action) { - case EntryAction.toggleFavourite: - entry.toggleFavourite(); + case EntryAction.addShortcut: + _addShortcut(context, entry); break; case EntryAction.copyToClipboard: androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { @@ -43,10 +45,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }); break; case EntryAction.delete: - _showDeleteDialog(context, entry); + _delete(context, entry); break; case EntryAction.export: - _showExportDialog(context, entry); + _export(context, entry); break; case EntryAction.info: ShowInfoNotification().dispatch(context); @@ -55,8 +57,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix EntryPrinter(entry).print(context); break; case EntryAction.rename: - _showRenameDialog(context, entry); + _rename(context, entry); break; + case EntryAction.share: + androidAppService.shareEntries({entry}).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + break; + case EntryAction.toggleFavourite: + entry.toggleFavourite(); + break; + // raster case EntryAction.rotateCCW: _rotate(context, entry, clockwise: false); break; @@ -66,6 +77,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.flip: _flip(context, entry); break; + // vector + case EntryAction.viewSource: + _goToSourceViewer(context, entry); + break; + // motion photo + case EntryAction.viewMotionPhotoVideo: + OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); + break; + // external case EntryAction.edit: androidAppService.edit(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); @@ -81,31 +101,37 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!success) showNoMatchingAppDialog(context); }); break; - case EntryAction.rotateScreen: - _rotateScreen(context); - break; case EntryAction.setAs: androidAppService.setAs(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; - case EntryAction.share: - androidAppService.shareEntries({entry}).then((success) { - if (!success) showNoMatchingAppDialog(context); - }); - break; - case EntryAction.viewSource: - _goToSourceViewer(context, entry); - break; - case EntryAction.viewMotionPhotoVideo: - OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); + // platform + case EntryAction.rotateScreen: + _rotateScreen(context); break; + // debug case EntryAction.debug: _goToDebug(context, entry); break; } } + Future _addShortcut(BuildContext context, AvesEntry entry) async { + final result = await showDialog>( + context: context, + builder: (context) => AddShortcutDialog( + defaultName: entry.bestTitle ?? '', + ), + ); + if (result == null) return; + + final name = result.item2; + if (name.isEmpty) return; + + unawaited(androidAppService.pinToHomeScreen(name, entry, uri: entry.uri)); + } + Future _flip(BuildContext context, AvesEntry entry) async { if (!await checkStoragePermission(context, {entry})) return; @@ -131,7 +157,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _showDeleteDialog(BuildContext context, AvesEntry entry) async { + Future _delete(BuildContext context, AvesEntry entry) async { final confirmed = await showDialog( context: context, builder: (context) { @@ -166,7 +192,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - Future _showExportDialog(BuildContext context, AvesEntry entry) async { + Future _export(BuildContext context, AvesEntry entry) async { final source = context.read(); if (!source.initialized) { await source.init(); @@ -273,7 +299,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } - Future _showRenameDialog(BuildContext context, AvesEntry entry) async { + Future _rename(BuildContext context, AvesEntry entry) async { final newName = await showDialog( context: context, builder: (context) => RenameEntryDialog(entry: entry), diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 378fc6747..ec701649c 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -88,6 +88,7 @@ class ViewerTopOverlay extends StatelessWidget { return targetEntry.isMotionPhoto; case EntryAction.rotateScreen: return settings.isRotationLocked; + case EntryAction.addShortcut: case EntryAction.copyToClipboard: case EntryAction.edit: case EntryAction.info: @@ -208,6 +209,7 @@ class _TopOverlayRow extends StatelessWidget { onPressed: onPressed, ); break; + case EntryAction.addShortcut: case EntryAction.copyToClipboard: case EntryAction.delete: case EntryAction.export: