diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbfbb6d8..b63d076f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.5.6] - 2021-11-12 + +### Added + +- Viewer: action to add shortcut to media item + +### Changed + +- Albums / Countries / Tags: use a 3 column layout by default + +### Fixed + +- video playback was not using hardware-accelerated codecs on recent devices +- partial fix to deleting/moving file in a clean way on some devices + ## [v1.5.5] - 2021-11-08 ### Added diff --git a/README.md b/README.md index e402b934d..e86f34117 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,25 @@ +
+ +Aves logo + +## Aves + ![Version badge][Version badge] ![Build badge][Build badge] -
-Aves logo +Aves is a gallery and metadata explorer app. It is built for Android, with Flutter. [Get it on Google Play](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) +[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/deckers.thibault.aves) [Get it on GitHub](https://github.com/deckerst/aves/releases/latest) -Aves is a gallery and metadata explorer app. It is built for Android, with Flutter. - -Collection screenshotImage screenshotStats screenshot +
## Features @@ -25,7 +31,9 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**. -Info (basic) screenshotInfo (metadata) screenshotCountries screenshot +## Screenshots + +Collection screenshotImage screenshotStats screenshotInfo (basic) screenshotInfo (metadata) screenshotCountries screenshot ## Permissions @@ -51,7 +59,14 @@ If you want to translate this app in your language and share the result, feel fr ### Donations -Some users have expressed the wish to financially support the project. I haven't set up any sponsorship system, but you can send contributions [here](https://paypal.me/ThibaultDeckers). Thanks! ❤️ +Some users have expressed the wish to financially support the project. Thanks! ❤️ + +[Donate with PayPal](https://paypal.me/ThibaultDeckers) +[Donate using Liberapay](https://liberapay.com/deckerst/donate) ## Project Setup diff --git a/android/app/build.gradle b/android/app/build.gradle index e46719768..bccdc8a54 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -140,7 +140,7 @@ repositories { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1' - implementation 'androidx.core:core-ktx:1.6.0' + implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.exifinterface:exifinterface:1.3.3' implementation 'androidx.multidex:multidex:2.0.1' implementation 'com.caverock:androidsvg-aar:1.4' @@ -152,7 +152,7 @@ dependencies { implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' implementation 'com.github.bumptech.glide:glide:4.12.0' - kapt 'androidx.annotation:annotation:1.2.0' + kapt 'androidx.annotation:annotation:1.3.0' kapt 'com.github.bumptech.glide:compiler:4.12.0' compileOnly rootProject.findProject(':streams_channel') diff --git a/android/app/libs/fijkplayer-full-release.aar b/android/app/libs/fijkplayer-full-release.aar index 53c1adfdf..10490ce31 100644 Binary files a/android/app/libs/fijkplayer-full-release.aar and b/android/app/libs/fijkplayer-full-release.aar differ 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/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 29cca22e4..4aebabf55 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -4,6 +4,8 @@ import android.content.ContentUris import android.content.Context import android.database.Cursor import android.graphics.BitmapFactory +import android.media.MediaCodecInfo +import android.media.MediaCodecList import android.net.Uri import android.os.Build import android.os.Handler @@ -47,6 +49,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { "safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } } "getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) } + "getCodecs" -> safe(call, result, ::getCodecs) "getEnv" -> safe(call, result, ::getEnv) "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) } @@ -83,6 +86,40 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(dirs) } + private fun getCodecs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + val codecs = ArrayList() + + fun getFields(info: MediaCodecInfo): FieldMap { + val fields: FieldMap = hashMapOf( + "name" to info.name, + "isEncoder" to info.isEncoder, + "supportedTypes" to info.supportedTypes.joinToString(", "), + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (info.canonicalName != info.name) fields["canonicalName"] = info.canonicalName + if (info.isAlias) fields["isAlias"] to info.isAlias + if (info.isHardwareAccelerated) fields["isHardwareAccelerated"] to info.isHardwareAccelerated + if (info.isSoftwareOnly) fields["isSoftwareOnly"] to info.isSoftwareOnly + if (info.isVendor) fields["isVendor"] to info.isVendor + } + return fields + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + codecs.addAll(MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.map(::getFields)) + } else { + @Suppress("deprecation") + val count = MediaCodecList.getCodecCount() + for (i in 0 until count) { + @Suppress("deprecation") + val info = MediaCodecList.getCodecInfoAt(i) + codecs.add(getFields(info)) + } + } + + result.success(codecs) + } + private fun getEnv(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(System.getenv()) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 2d3e62f9e..aa7d090e9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -225,18 +225,53 @@ class MediaStoreImageProvider : ImageProvider() { return found } + private fun hasEntry(context: Context, contentUri: Uri): Boolean { + var found = false + val projection = arrayOf(MediaStore.MediaColumns._ID) + try { + val cursor = context.contentResolver.query(contentUri, projection, null, null, null) + if (cursor != null) { + while (cursor.moveToNext()) { + found = true + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e) + } + return found + } + private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { path ?: throw Exception("failed to delete file because path is null") + // the following situations are possible: + // - there is an entry in the Media Store and there is a file on storage + // - there is an entry in the Media Store but there is no longer a file on storage + // - there is no entry in the Media Store but there is a file on storage val file = File(path) - if (file.exists()) { + val fileExists = file.exists() + + if (fileExists) { if (StorageUtils.canEditByFile(activity, path)) { - Log.d(LOG_TAG, "delete file at uri=$uri path=$path") - if (file.delete()) { - scanObsoletePath(activity, path, mimeType) + if (hasEntry(activity, uri)) { + Log.d(LOG_TAG, "delete content at uri=$uri path=$path") + activity.contentResolver.delete(uri, null, null) + } + // in theory, deleting via content resolver should remove the file on storage + // in practice, the file may still be there afterwards + if (file.exists()) { + Log.d(LOG_TAG, "delete file at uri=$uri path=$path") + if (file.delete()) { + // in theory, scanning an obsolete path should remove the entry from the Media Store + // in practice, the entry may still be there afterwards + scanObsoletePath(activity, path, mimeType) + return + } + } else { return } } else if (!isMediaUriPermissionGranted(activity, uri, mimeType) @@ -245,7 +280,7 @@ class MediaStoreImageProvider : ImageProvider() { // if the file is on SD card, calling the content resolver `delete()` // removes the entry from the Media Store but it doesn't delete the file, // even when the app has the permission, so we manually delete the document file - Log.d(LOG_TAG, "delete document at uri=$uri path=$path") + Log.d(LOG_TAG, "delete document (fileExists=$fileExists) at uri=$uri path=$path") val df = StorageUtils.getDocumentFile(activity, path, uri) @Suppress("BlockingMethodInNonBlockingContext") @@ -258,9 +293,12 @@ class MediaStoreImageProvider : ImageProvider() { } try { - Log.d(LOG_TAG, "delete content at uri=$uri path=$path") + Log.d(LOG_TAG, "delete content (fileExists=$fileExists) at uri=$uri path=$path") if (activity.contentResolver.delete(uri, null, null) > 0) return - throw Exception("failed to delete row from content provider") + + if (hasEntry(activity, uri) || file.exists()) { + throw Exception("failed to delete row from content provider") + } } catch (securityException: SecurityException) { // even if the app has access permission granted on the containing directory, // the delete request may yield a `RecoverableSecurityException` on Android 10+ diff --git a/android/build.gradle b/android/build.gradle index 7d6fe6924..a66954e85 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -10,7 +10,7 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // GMS & Firebase Crashlytics are not actually used by all flavors classpath 'com.google.gms:google-services:4.3.10' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.0' } } diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt new file mode 100644 index 000000000..15d681520 --- /dev/null +++ b/fastlane/metadata/android/de/full_description.txt @@ -0,0 +1,5 @@ +Aves kann alle Arten von Bildern und Videos verarbeiten, einschließlich Ihrer typischen JPEGs und MP4s, aber auch exotischere Dinge wie mehrseitige TIFFs, SVGs, alte AVIs und mehr! Es scannt Ihre Mediensammlung, um Bewegungsfotos, Panoramen (auch bekannt als Panoramaaufnahmen), 360°-Videos sowie GeoTIFF-Dateien zu identifizieren. + +Navigation und Suche ist ein wichtiger Bestandteil von Aves. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können. + +Aves lässt sich mit Android (von API 20 bis 31, d. h. von Lollipop bis S) mit Funktionen wie App-Verknüpfungen und globaler Suche integrieren. Es funktioniert auch als Medienbetrachter und -auswahl. \ No newline at end of file diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt new file mode 100644 index 000000000..9f8c85a29 --- /dev/null +++ b/fastlane/metadata/android/de/short_description.txt @@ -0,0 +1 @@ +Galerie und Metadata Explorer \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1060.txt b/fastlane/metadata/android/en-US/changelogs/1060.txt new file mode 100644 index 000000000..257fd5ad9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1060.txt @@ -0,0 +1,10 @@ +In v1.5.6: +- fixed video playback ignoring hardware-accelerated codecs on recent devices +- partially fixed deleted files leaving ghost items on some devices +- you can now create shortcuts to a specific media item, not only collections +In v1.5.5: +- modify items in bulk (rotation, date, metadata removal) +- filter items by title +- enjoy the app in Russian +Note: the video thumbnails are modified. Clearing the app cache may be necessary. +Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 000000000..c7ccffdc9 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,5 @@ +Aves can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like multi-page TIFFs, SVGs, old AVIs and more! It scans your media collection to identify motion photos, panoramas (aka photo spheres), 360° videos, as well as GeoTIFF files. + +Navigation and search is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc. + +Aves integrates with Android (from API 20 to 31, i.e. from Lollipop to S) with features such as app shortcuts and global search handling. It also works as a media viewer and picker. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 000000000..c3eeb06dc Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 000000000..73c4a32c9 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 000000000..1b9e0262c Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 000000000..b2e45ff00 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 000000000..f8e7cee24 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 000000000..dc95c05ab Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 000000000..64a8a9588 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png new file mode 100644 index 000000000..336e6e21d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 000000000..8c9445bd5 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Gallery and metadata explorer \ No newline at end of file diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 5d4d580e6..4a8c98248 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, @@ -17,10 +19,7 @@ enum EntryAction { flip, // vector viewSource, - // motion photo, - viewMotionPhotoVideo, // external - copyToClipboard, edit, open, openMap, @@ -39,10 +38,10 @@ class EntryActions { EntryAction.delete, EntryAction.rename, EntryAction.export, + EntryAction.addShortcut, EntryAction.copyToClipboard, EntryAction.print, EntryAction.viewSource, - EntryAction.viewMotionPhotoVideo, EntryAction.rotateScreen, ]; @@ -63,9 +62,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 +72,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; @@ -90,18 +91,15 @@ extension ExtraEntryAction on EntryAction { // vector case EntryAction.viewSource: return context.l10n.entryActionViewSource; - // motion photo - case EntryAction.viewMotionPhotoVideo: - return context.l10n.entryActionViewMotionPhotoVideo; // external case EntryAction.edit: 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 +127,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 +137,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; @@ -156,14 +156,11 @@ extension ExtraEntryAction on EntryAction { // vector case EntryAction.viewSource: return AIcons.vector; - // motion photo - case EntryAction.viewMotionPhotoVideo: - return AIcons.motionPhoto; // 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/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 52ddd4394..f0c6c7805 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -1,4 +1,51 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + enum EntryInfoAction { + // general editDate, removeMetadata, + // motion photo + viewMotionPhotoVideo, +} + +class EntryInfoActions { + static const all = [ + EntryInfoAction.editDate, + EntryInfoAction.removeMetadata, + EntryInfoAction.viewMotionPhotoVideo, + ]; +} + +extension ExtraEntryInfoAction on EntryInfoAction { + String getText(BuildContext context) { + switch (this) { + // general + case EntryInfoAction.editDate: + return context.l10n.entryInfoActionEditDate; + case EntryInfoAction.removeMetadata: + return context.l10n.entryInfoActionRemoveMetadata; + // motion photo + case EntryInfoAction.viewMotionPhotoVideo: + return context.l10n.entryActionViewMotionPhotoVideo; + } + } + + Widget getIcon() { + return Icon(_getIconData()); + } + + IconData _getIconData() { + switch (this) { + // general + case EntryInfoAction.editDate: + return AIcons.date; + case EntryInfoAction.removeMetadata: + return AIcons.clear; + // motion photo + case EntryInfoAction.viewMotionPhotoVideo: + return AIcons.motionPhoto; + } + } } 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/services/android_debug_service.dart b/lib/services/android_debug_service.dart index 15f63b70d..ee522c45c 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -56,6 +56,16 @@ class AndroidDebugService { return {}; } + static Future> getCodecs() async { + try { + final result = await platform.invokeMethod('getCodecs'); + if (result != null) return (result as List).cast(); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return []; + } + static Future getEnv() async { try { final result = await platform.invokeMethod('getEnv'); diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index a11e37d9b..a81e03bb8 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -8,7 +8,8 @@ import 'package:flutter/widgets.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { - late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath; + late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; + late final Set videoCapturesPaths; Set storageVolumes = {}; Set _packages = {}; List _potentialAppDirs = []; @@ -31,8 +32,13 @@ class AndroidFileUtils { downloadPath = pContext.join(primaryStorage, 'Download'); moviesPath = pContext.join(primaryStorage, 'Movies'); picturesPath = pContext.join(primaryStorage, 'Pictures'); - // from Aves - videoCapturesPath = pContext.join(dcimPath, 'Video Captures'); + avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures'); + videoCapturesPaths = { + // from Samsung + pContext.join(dcimPath, 'Video Captures'), + // from Aves + avesVideoCapturesPath, + }; _initialized = true; } @@ -58,7 +64,7 @@ class AndroidFileUtils { bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('${separator}Screen recordings') || path.endsWith('${separator}ScreenRecords')); - bool isVideoCapturesPath(String path) => path == videoCapturesPath; + bool isVideoCapturesPath(String path) => videoCapturesPaths.contains(path); bool isDownloadPath(String path) => path == downloadPath; 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/common/scaling.dart b/lib/widgets/common/scaling.dart index 88156ebda..ea1e624ca 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -55,14 +55,16 @@ class _GridScaleGestureDetectorState extends State(); + final scrollableContext = widget.scrollableKey.currentContext!; final scrollableBox = scrollableContext.findRenderObject() as RenderBox; - final result = BoxHitTestResult(); - scrollableBox.hitTest(result, position: details.localFocalPoint); - - // find `RenderObject`s at the gesture focal point - U? firstOf(BoxHitTestResult result) => result.path.firstWhereOrNull((el) => el.target is U)?.target as U?; - final renderMetaData = firstOf(result); + final renderMetaData = _getClosestRenderMetadata( + box: scrollableBox, + localFocalPoint: details.localFocalPoint, + spacing: tileExtentController.spacing, + ); // abort if we cannot find an image to show on overlay if (renderMetaData == null) return; _metadata = renderMetaData.metaData; @@ -72,7 +74,6 @@ class _GridScaleGestureDetectorState extends State(); _extentMin = tileExtentController.effectiveExtentMin; _extentMax = tileExtentController.effectiveExtentMax; @@ -138,6 +139,25 @@ class _GridScaleGestureDetectorState extends State 0 && position.dy > 0) { + final result = BoxHitTestResult(); + box.hitTest(result, position: position); + + // find `RenderObject`s at the gesture focal point + U? firstOf(BoxHitTestResult result) => result.path.firstWhereOrNull((el) => el.target is U)?.target as U?; + final renderMetaData = firstOf(result); + if (renderMetaData != null) return renderMetaData; + position = position.translate(-spacing, -spacing); + } + return null; + } } class ScaleOverlay extends StatefulWidget { diff --git a/lib/widgets/debug/android_codecs.dart b/lib/widgets/debug/android_codecs.dart new file mode 100644 index 000000000..f9b1d8289 --- /dev/null +++ b/lib/widgets/debug/android_codecs.dart @@ -0,0 +1,81 @@ +import 'package:aves/services/android_debug_service.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class DebugAndroidCodecSection extends StatefulWidget { + const DebugAndroidCodecSection({Key? key}) : super(key: key); + + @override + _DebugAndroidCodecSectionState createState() => _DebugAndroidCodecSectionState(); +} + +class _DebugAndroidCodecSectionState extends State with AutomaticKeepAliveClientMixin { + late Future> _loader; + final ValueNotifier _queryNotifier = ValueNotifier(''); + + @override + void initState() { + super.initState(); + _loader = AndroidDebugService.getCodecs(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return AvesExpansionTile( + title: 'Android Codecs', + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: FutureBuilder>( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final codecs = snapshot.data!.map((codec) { + return codec.map((k, v) => MapEntry(k.toString(), v.toString())); + }).toList() + ..sort((a, b) => compareAsciiUpperCase(a['supportedTypes'] ?? '', b['supportedTypes'] ?? '')); + final byEncoder = groupBy, bool>(codecs, (v) => v['isEncoder'] == 'true'); + final decoders = byEncoder[false] ?? []; + final encoders = byEncoder[true] ?? []; + Widget _toCodecColumn(List> codecs) => ValueListenableBuilder( + valueListenable: _queryNotifier, + builder: (context, query, child) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: codecs.expand((v) { + final types = v['supportedTypes']; + return (query.isEmpty || types == null || types.contains(query)) + ? [ + InfoRowGroup(info: Map.fromEntries(v.entries.where((kv) => kv.key != 'isEncoder'))), + const Divider(), + ] + : []; + }).toList(), + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QueryBar(queryNotifier: _queryNotifier), + const HighlightTitle(title: 'Decoders'), + _toCodecColumn(decoders), + const HighlightTitle(title: 'Encoders'), + _toCodecColumn(encoders), + ], + ); + }, + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 742455f33..1b4a3ec08 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -4,6 +4,7 @@ import 'package:aves/services/analysis_service.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/debug/android_apps.dart'; +import 'package:aves/widgets/debug/android_codecs.dart'; import 'package:aves/widgets/debug/android_dirs.dart'; import 'package:aves/widgets/debug/android_env.dart'; import 'package:aves/widgets/debug/cache.dart'; @@ -46,6 +47,7 @@ class _AppDebugPageState extends State { children: [ _buildGeneralTabView(), const DebugAndroidAppSection(), + const DebugAndroidCodecSection(), const DebugAndroidDirSection(), const DebugAndroidEnvironmentSection(), const DebugCacheSection(), 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/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 8e36ffe22..135d9e8ba 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -163,7 +163,7 @@ class _FilterGridState extends State> Widget build(BuildContext context) { _tileExtentController ??= TileExtentController( settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!, - columnCountDefault: 2, + columnCountDefault: 3, extentMin: 60, spacing: 8, ); 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..4ff192481 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -18,24 +18,25 @@ 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'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; -import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/printer.dart'; 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 +44,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 +56,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 +76,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.flip: _flip(context, entry); break; + // vector + case EntryAction.viewSource: + _goToSourceViewer(context, entry); + break; + // external case EntryAction.edit: androidAppService.edit(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); @@ -81,31 +96,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 +152,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 +187,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 +294,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/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index f5f9d0d13..da527c6d5 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -7,6 +7,7 @@ import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -15,14 +16,44 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw const EntryInfoActionDelegate(this.entry); + bool isVisible(EntryInfoAction action) { + switch (action) { + // general + case EntryInfoAction.editDate: + case EntryInfoAction.removeMetadata: + return true; + // motion photo + case EntryInfoAction.viewMotionPhotoVideo: + return entry.isMotionPhoto; + } + } + + bool canApply(EntryInfoAction action) { + switch (action) { + // general + case EntryInfoAction.editDate: + return entry.canEditExif; + case EntryInfoAction.removeMetadata: + return entry.canRemoveMetadata; + // motion photo + case EntryInfoAction.viewMotionPhotoVideo: + return true; + } + } + void onActionSelected(BuildContext context, EntryInfoAction action) async { switch (action) { + // general case EntryInfoAction.editDate: await _editDate(context); break; case EntryInfoAction.removeMetadata: await _removeMetadata(context); break; + // motion photo + case EntryInfoAction.viewMotionPhotoVideo: + OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); + break; } } diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index e948e4904..94b78e925 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -25,6 +25,9 @@ class InfoAppBar extends StatelessWidget { @override Widget build(BuildContext context) { + final actionDelegate = EntryInfoActionDelegate(entry); + final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible); + return SliverAppBar( leading: IconButton( // key is expected by test driver @@ -46,24 +49,11 @@ class InfoAppBar extends StatelessWidget { if (entry.canEdit) MenuIconTheme( child: PopupMenuButton( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: EntryInfoAction.editDate, - enabled: entry.canEditExif, - child: MenuRow(text: context.l10n.entryInfoActionEditDate, icon: const Icon(AIcons.date)), - ), - PopupMenuItem( - value: EntryInfoAction.removeMetadata, - enabled: entry.canRemoveMetadata, - child: MenuRow(text: context.l10n.entryInfoActionRemoveMetadata, icon: const Icon(AIcons.clear)), - ), - ]; - }, + itemBuilder: (context) => menuActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))).toList(), onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); - EntryInfoActionDelegate(entry).onActionSelected(context, action); + actionDelegate.onActionSelected(context, action); }, ), ), @@ -73,6 +63,14 @@ class InfoAppBar extends StatelessWidget { ); } + PopupMenuItem _toMenuItem(BuildContext context, EntryInfoAction action, {required bool enabled}) { + return PopupMenuItem( + value: action, + enabled: enabled, + child: MenuRow(text: action.getText(context), icon: action.getIcon()), + ); + } + void _goToSearch(BuildContext context) { showSearch( context: context, diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 695cd6cc0..37f2b33bf 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -32,6 +32,8 @@ class XmpNamespace extends Equatable { switch (namespace) { case XmpBasicNamespace.ns: return XmpBasicNamespace(rawProps); + case XmpContainer.ns: + return XmpContainer(rawProps); case XmpCrsNamespace.ns: return XmpCrsNamespace(rawProps); case XmpDarktableNamespace.ns: diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index 5b7d3ad8e..8081111c0 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -2,7 +2,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; import 'package:tuple/tuple.dart'; abstract class XmpGoogleNamespace extends XmpNamespace { @@ -61,3 +63,25 @@ class XmpGImageNamespace extends XmpGoogleNamespace { @override List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; } + +class XmpContainer extends XmpNamespace { + static const ns = 'Container'; + + static final directoryPattern = RegExp('$ns:Directory\\[(\\d+)\\]/$ns:Item/(.*)'); + + final directories = >{}; + + XmpContainer(Map rawProps) : super(ns, rawProps); + + @override + bool extractData(XmpProp prop) => extractIndexedStruct(prop, directoryPattern, directories); + + @override + List buildFromExtractedData() => [ + if (directories.isNotEmpty) + XmpStructArrayCard( + title: 'Directory Item', + structByIndex: directories, + ), + ]; +} diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 378fc6747..427ed1a7b 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -65,7 +65,7 @@ class ViewerTopOverlay extends StatelessWidget { Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { pageEntry ??= mainEntry; - bool _canDo(EntryAction action) { + bool _isVisible(EntryAction action) { final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry; switch (action) { case EntryAction.toggleFavourite: @@ -84,10 +84,9 @@ class ViewerTopOverlay extends StatelessWidget { return targetEntry.hasGps; case EntryAction.viewSource: return targetEntry.isSvg; - case EntryAction.viewMotionPhotoVideo: - return targetEntry.isMotionPhoto; case EntryAction.rotateScreen: return settings.isRotationLocked; + case EntryAction.addShortcut: case EntryAction.copyToClipboard: case EntryAction.edit: case EntryAction.info: @@ -103,9 +102,9 @@ class ViewerTopOverlay extends StatelessWidget { final buttonRow = Selector( selector: (context, s) => s.isRotationLocked, builder: (context, s, child) { - final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount - 1).toList(); - final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); - final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); + final quickActions = settings.viewerQuickActions.where(_isVisible).take(availableCount - 1).toList(); + final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); + final externalAppActions = EntryActions.externalApp.where(_isVisible).toList(); return _TopOverlayRow( quickActions: quickActions, inAppActions: inAppActions, @@ -208,6 +207,7 @@ class _TopOverlayRow extends StatelessWidget { onPressed: onPressed, ); break; + case EntryAction.addShortcut: case EntryAction.copyToClipboard: case EntryAction.delete: case EntryAction.export: @@ -220,7 +220,6 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.share: case EntryAction.rotateScreen: case EntryAction.viewSource: - case EntryAction.viewMotionPhotoVideo: child = IconButton( icon: action.getIcon() ?? const SizedBox(), onPressed: onPressed, diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 494cfc7c7..b81ff5549 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -72,7 +72,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final positionMillis = controller.currentPosition; final bytes = await controller.captureFrame(); - final destinationAlbum = androidFileUtils.videoCapturesPath; + final destinationAlbum = androidFileUtils.avesVideoCapturesPath; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkFreeSpace(context, bytes.length, destinationAlbum)) return; diff --git a/pubspec.lock b/pubspec.lock index 1e5f37320..01702bd34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -288,7 +288,7 @@ packages: description: path: "." ref: aves - resolved-ref: "44569361c251cc4ced0ff845b02c64ceeaebb957" + resolved-ref: "2aa0f5f08135de00966e9b71e58cddb61f93e81c" url: "git://github.com/deckerst/fijkplayer.git" source: git version: "0.10.0" @@ -447,7 +447,7 @@ packages: name: github url: "https://pub.dartlang.org" source: hosted - version: "8.2.3" + version: "8.2.5" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f43a84841..d09074b41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.5+59 +version: 1.5.6+60 publish_to: none environment: diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 7f651acd6..510e4b891 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,4 +1,9 @@ -Thanks for using Aves! In v1.5.5: +Thanks for using Aves! +In v1.5.6: +- fixed video playback ignoring hardware-accelerated codecs on recent devices +- partially fixed deleted files leaving ghost items on some devices +- you can now create shortcuts to a specific media item, not only collections +In v1.5.5: - modify items in bulk (rotation, date, metadata removal) - filter items by title - enjoy the app in Russian