Merge branch 'develop'
15
CHANGELOG.md
|
@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v1.5.5] - 2021-11-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
29
README.md
|
@ -1,19 +1,25 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/aves_logo.svg" alt='Aves logo' width="200" />
|
||||||
|
|
||||||
|
## Aves
|
||||||
|
|
||||||
![Version badge][Version badge]
|
![Version badge][Version badge]
|
||||||
![Build badge][Build badge]
|
![Build badge][Build badge]
|
||||||
|
|
||||||
<br />
|
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
|
||||||
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/aves_logo.svg" alt='Aves logo' width="200" />
|
|
||||||
|
|
||||||
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
||||||
alt='Get it on Google Play'
|
alt='Get it on Google Play'
|
||||||
height="80">](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1)
|
height="80">](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1)
|
||||||
|
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||||
|
alt='Get it on IzzyOnDroid'
|
||||||
|
height="80">](https://apt.izzysoft.de/fdroid/index/apk/deckers.thibault.aves)
|
||||||
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/get-it-on-github.png"
|
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/get-it-on-github.png"
|
||||||
alt='Get it on GitHub'
|
alt='Get it on GitHub'
|
||||||
height="80">](https://github.com/deckerst/aves/releases/latest)
|
height="80">](https://github.com/deckerst/aves/releases/latest)
|
||||||
|
|
||||||
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
|
<div align="left">
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/2-S10-viewer.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/5-S10-stats.png" alt='Stats screenshot' height="400" />
|
|
||||||
|
|
||||||
## Features
|
## 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**.
|
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**.
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/3-S10-info__basic_.png" alt='Info (basic) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/4-S10-info__metadata_.png" alt='Info (metadata) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/6-S10-countries.png" alt='Countries screenshot' height="400" />
|
## Screenshots
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/2-S10-viewer.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/5-S10-stats.png" alt='Stats screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/3-S10-info__basic_.png" alt='Info (basic) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/4-S10-info__metadata_.png" alt='Info (metadata) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/6-S10-countries.png" alt='Countries screenshot' height="400" />
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
@ -51,7 +59,14 @@ If you want to translate this app in your language and share the result, feel fr
|
||||||
|
|
||||||
### Donations
|
### 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! ❤️
|
||||||
|
|
||||||
|
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/paypal-badge-cropped.png"
|
||||||
|
alt='Donate with PayPal'
|
||||||
|
height="40">](https://paypal.me/ThibaultDeckers)
|
||||||
|
[<img src="https://liberapay.com/assets/widgets/donate.svg"
|
||||||
|
alt='Donate using Liberapay'
|
||||||
|
height="40">](https://liberapay.com/deckerst/donate)
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
|
|
|
@ -140,7 +140,7 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
|
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.exifinterface:exifinterface:1.3.3'
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
@ -152,7 +152,7 @@ dependencies {
|
||||||
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
|
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
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'
|
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
||||||
|
|
||||||
compileOnly rootProject.findProject(':streams_channel')
|
compileOnly rootProject.findProject(':streams_channel')
|
||||||
|
|
|
@ -329,7 +329,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
val label = call.argument<String>("label")
|
val label = call.argument<String>("label")
|
||||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||||
val filters = call.argument<List<String>>("filters")
|
val filters = call.argument<List<String>>("filters")
|
||||||
if (label == null || filters == null) {
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
if (label == null || (filters == null && uri == null)) {
|
||||||
result.error("pin-args", "failed because of missing arguments", null)
|
result.error("pin-args", "failed because of missing arguments", null)
|
||||||
return
|
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)
|
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)
|
val intent = when {
|
||||||
.putExtra("page", "/collection")
|
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
||||||
.putExtra("filters", filters.toTypedArray())
|
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
.putExtra("page", "/collection")
|
||||||
// so we use a joined `String` as fallback
|
.putExtra("filters", filters.toTypedArray())
|
||||||
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
|
// 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
|
// 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
|
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
|
||||||
|
|
|
@ -4,6 +4,8 @@ import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaCodecList
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
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() } }
|
"safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } }
|
||||||
|
|
||||||
"getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) }
|
"getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) }
|
||||||
|
"getCodecs" -> safe(call, result, ::getCodecs)
|
||||||
"getEnv" -> safe(call, result, ::getEnv)
|
"getEnv" -> safe(call, result, ::getEnv)
|
||||||
|
|
||||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
|
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
|
||||||
|
@ -83,6 +86,40 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(dirs)
|
result.success(dirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getCodecs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val codecs = ArrayList<FieldMap>()
|
||||||
|
|
||||||
|
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) {
|
private fun getEnv(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(System.getenv())
|
result.success(System.getenv())
|
||||||
}
|
}
|
||||||
|
|
|
@ -225,18 +225,53 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
return found
|
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
|
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
|
||||||
|
|
||||||
// `uri` is a media URI, not a document URI
|
// `uri` is a media URI, not a document URI
|
||||||
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
|
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
|
||||||
path ?: throw Exception("failed to delete file because path is null")
|
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)
|
val file = File(path)
|
||||||
if (file.exists()) {
|
val fileExists = file.exists()
|
||||||
|
|
||||||
|
if (fileExists) {
|
||||||
if (StorageUtils.canEditByFile(activity, path)) {
|
if (StorageUtils.canEditByFile(activity, path)) {
|
||||||
Log.d(LOG_TAG, "delete file at uri=$uri path=$path")
|
if (hasEntry(activity, uri)) {
|
||||||
if (file.delete()) {
|
Log.d(LOG_TAG, "delete content at uri=$uri path=$path")
|
||||||
scanObsoletePath(activity, path, mimeType)
|
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
|
return
|
||||||
}
|
}
|
||||||
} else if (!isMediaUriPermissionGranted(activity, uri, mimeType)
|
} 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()`
|
// 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,
|
// 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
|
// 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)
|
val df = StorageUtils.getDocumentFile(activity, path, uri)
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
@ -258,9 +293,12 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
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) {
|
} catch (securityException: SecurityException) {
|
||||||
// even if the app has access permission granted on the containing directory,
|
// even if the app has access permission granted on the containing directory,
|
||||||
// the delete request may yield a `RecoverableSecurityException` on Android 10+
|
// the delete request may yield a `RecoverableSecurityException` on Android 10+
|
||||||
|
|
|
@ -10,7 +10,7 @@ buildscript {
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
// GMS & Firebase Crashlytics are not actually used by all flavors
|
// GMS & Firebase Crashlytics are not actually used by all flavors
|
||||||
classpath 'com.google.gms:google-services:4.3.10'
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
5
fastlane/metadata/android/de/full_description.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<i>Aves</i> kann alle Arten von Bildern und Videos verarbeiten, einschließlich Ihrer typischen JPEGs und MP4s, aber auch exotischere Dinge wie <b>mehrseitige TIFFs, SVGs, alte AVIs und mehr</b>! Es scannt Ihre Mediensammlung, um <b>Bewegungsfotos</b>, <b>Panoramen</b> (auch bekannt als Panoramaaufnahmen), <b>360°-Videos</b> sowie <b>GeoTIFF-Dateien</b> zu identifizieren.
|
||||||
|
|
||||||
|
<b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können.
|
||||||
|
|
||||||
|
<i>Aves</i> lässt sich mit Android (von <b>API 20 bis 31</b>, d. h. von Lollipop bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.
|
1
fastlane/metadata/android/de/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Galerie und Metadata Explorer
|
10
fastlane/metadata/android/en-US/changelogs/1060.txt
Normal file
|
@ -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
|
5
fastlane/metadata/android/en-US/full_description.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
|
||||||
|
|
||||||
|
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||||
|
|
||||||
|
<i>Aves</i> integrates with Android (from <b>API 20 to 31</b>, i.e. from Lollipop to S) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.
|
BIN
fastlane/metadata/android/en-US/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 8.3 KiB |
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 241 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 470 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 165 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 58 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 327 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Gallery and metadata explorer
|
|
@ -4,6 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
enum EntryAction {
|
enum EntryAction {
|
||||||
|
addShortcut,
|
||||||
|
copyToClipboard,
|
||||||
delete,
|
delete,
|
||||||
export,
|
export,
|
||||||
info,
|
info,
|
||||||
|
@ -17,10 +19,7 @@ enum EntryAction {
|
||||||
flip,
|
flip,
|
||||||
// vector
|
// vector
|
||||||
viewSource,
|
viewSource,
|
||||||
// motion photo,
|
|
||||||
viewMotionPhotoVideo,
|
|
||||||
// external
|
// external
|
||||||
copyToClipboard,
|
|
||||||
edit,
|
edit,
|
||||||
open,
|
open,
|
||||||
openMap,
|
openMap,
|
||||||
|
@ -39,10 +38,10 @@ class EntryActions {
|
||||||
EntryAction.delete,
|
EntryAction.delete,
|
||||||
EntryAction.rename,
|
EntryAction.rename,
|
||||||
EntryAction.export,
|
EntryAction.export,
|
||||||
|
EntryAction.addShortcut,
|
||||||
EntryAction.copyToClipboard,
|
EntryAction.copyToClipboard,
|
||||||
EntryAction.print,
|
EntryAction.print,
|
||||||
EntryAction.viewSource,
|
EntryAction.viewSource,
|
||||||
EntryAction.viewMotionPhotoVideo,
|
|
||||||
EntryAction.rotateScreen,
|
EntryAction.rotateScreen,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -63,9 +62,8 @@ class EntryActions {
|
||||||
extension ExtraEntryAction on EntryAction {
|
extension ExtraEntryAction on EntryAction {
|
||||||
String getText(BuildContext context) {
|
String getText(BuildContext context) {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case EntryAction.toggleFavourite:
|
case EntryAction.addShortcut:
|
||||||
// different data depending on toggle state
|
return context.l10n.collectionActionAddShortcut;
|
||||||
return context.l10n.entryActionAddFavourite;
|
|
||||||
case EntryAction.copyToClipboard:
|
case EntryAction.copyToClipboard:
|
||||||
return context.l10n.entryActionCopyToClipboard;
|
return context.l10n.entryActionCopyToClipboard;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
|
@ -74,12 +72,15 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return context.l10n.entryActionExport;
|
return context.l10n.entryActionExport;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return context.l10n.entryActionInfo;
|
return context.l10n.entryActionInfo;
|
||||||
case EntryAction.rename:
|
|
||||||
return context.l10n.entryActionRename;
|
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return context.l10n.entryActionPrint;
|
return context.l10n.entryActionPrint;
|
||||||
|
case EntryAction.rename:
|
||||||
|
return context.l10n.entryActionRename;
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
return context.l10n.entryActionShare;
|
return context.l10n.entryActionShare;
|
||||||
|
case EntryAction.toggleFavourite:
|
||||||
|
// different data depending on toggle state
|
||||||
|
return context.l10n.entryActionAddFavourite;
|
||||||
// raster
|
// raster
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
return context.l10n.entryActionRotateCCW;
|
return context.l10n.entryActionRotateCCW;
|
||||||
|
@ -90,18 +91,15 @@ extension ExtraEntryAction on EntryAction {
|
||||||
// vector
|
// vector
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
return context.l10n.entryActionViewSource;
|
return context.l10n.entryActionViewSource;
|
||||||
// motion photo
|
|
||||||
case EntryAction.viewMotionPhotoVideo:
|
|
||||||
return context.l10n.entryActionViewMotionPhotoVideo;
|
|
||||||
// external
|
// external
|
||||||
case EntryAction.edit:
|
case EntryAction.edit:
|
||||||
return context.l10n.entryActionEdit;
|
return context.l10n.entryActionEdit;
|
||||||
case EntryAction.open:
|
case EntryAction.open:
|
||||||
return context.l10n.entryActionOpen;
|
return context.l10n.entryActionOpen;
|
||||||
case EntryAction.setAs:
|
|
||||||
return context.l10n.entryActionSetAs;
|
|
||||||
case EntryAction.openMap:
|
case EntryAction.openMap:
|
||||||
return context.l10n.entryActionOpenMap;
|
return context.l10n.entryActionOpenMap;
|
||||||
|
case EntryAction.setAs:
|
||||||
|
return context.l10n.entryActionSetAs;
|
||||||
// platform
|
// platform
|
||||||
case EntryAction.rotateScreen:
|
case EntryAction.rotateScreen:
|
||||||
return context.l10n.entryActionRotateScreen;
|
return context.l10n.entryActionRotateScreen;
|
||||||
|
@ -129,9 +127,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
|
|
||||||
IconData? getIconData() {
|
IconData? getIconData() {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case EntryAction.toggleFavourite:
|
case EntryAction.addShortcut:
|
||||||
// different data depending on toggle state
|
return AIcons.addShortcut;
|
||||||
return AIcons.favourite;
|
|
||||||
case EntryAction.copyToClipboard:
|
case EntryAction.copyToClipboard:
|
||||||
return AIcons.clipboard;
|
return AIcons.clipboard;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
|
@ -140,12 +137,15 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return AIcons.saveAs;
|
return AIcons.saveAs;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return AIcons.info;
|
return AIcons.info;
|
||||||
case EntryAction.rename:
|
|
||||||
return AIcons.rename;
|
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return AIcons.print;
|
return AIcons.print;
|
||||||
|
case EntryAction.rename:
|
||||||
|
return AIcons.rename;
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
return AIcons.share;
|
return AIcons.share;
|
||||||
|
case EntryAction.toggleFavourite:
|
||||||
|
// different data depending on toggle state
|
||||||
|
return AIcons.favourite;
|
||||||
// raster
|
// raster
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
return AIcons.rotateLeft;
|
return AIcons.rotateLeft;
|
||||||
|
@ -156,14 +156,11 @@ extension ExtraEntryAction on EntryAction {
|
||||||
// vector
|
// vector
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
return AIcons.vector;
|
return AIcons.vector;
|
||||||
// motion photo
|
|
||||||
case EntryAction.viewMotionPhotoVideo:
|
|
||||||
return AIcons.motionPhoto;
|
|
||||||
// external
|
// external
|
||||||
case EntryAction.edit:
|
case EntryAction.edit:
|
||||||
case EntryAction.open:
|
case EntryAction.open:
|
||||||
case EntryAction.setAs:
|
|
||||||
case EntryAction.openMap:
|
case EntryAction.openMap:
|
||||||
|
case EntryAction.setAs:
|
||||||
return null;
|
return null;
|
||||||
// platform
|
// platform
|
||||||
case EntryAction.rotateScreen:
|
case EntryAction.rotateScreen:
|
||||||
|
|
|
@ -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 {
|
enum EntryInfoAction {
|
||||||
|
// general
|
||||||
editDate,
|
editDate,
|
||||||
removeMetadata,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ abstract class AndroidAppService {
|
||||||
|
|
||||||
Future<bool> canPinToHomeScreen();
|
Future<bool> canPinToHomeScreen();
|
||||||
|
|
||||||
Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters);
|
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformAndroidAppService implements AndroidAppService {
|
class PlatformAndroidAppService implements AndroidAppService {
|
||||||
|
@ -194,17 +194,17 @@ class PlatformAndroidAppService implements AndroidAppService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
|
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
|
||||||
Uint8List? iconBytes;
|
Uint8List? iconBytes;
|
||||||
if (entry != null) {
|
if (coverEntry != null) {
|
||||||
final size = entry.isVideo ? 0.0 : 256.0;
|
final size = coverEntry.isVideo ? 0.0 : 256.0;
|
||||||
iconBytes = await mediaFileService.getThumbnail(
|
iconBytes = await mediaFileService.getThumbnail(
|
||||||
uri: entry.uri,
|
uri: coverEntry.uri,
|
||||||
mimeType: entry.mimeType,
|
mimeType: coverEntry.mimeType,
|
||||||
pageId: entry.pageId,
|
pageId: coverEntry.pageId,
|
||||||
rotationDegrees: entry.rotationDegrees,
|
rotationDegrees: coverEntry.rotationDegrees,
|
||||||
isFlipped: entry.isFlipped,
|
isFlipped: coverEntry.isFlipped,
|
||||||
dateModifiedSecs: entry.dateModifiedSecs,
|
dateModifiedSecs: coverEntry.dateModifiedSecs,
|
||||||
extent: size,
|
extent: size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -212,7 +212,8 @@ class PlatformAndroidAppService implements AndroidAppService {
|
||||||
await platform.invokeMethod('pin', <String, dynamic>{
|
await platform.invokeMethod('pin', <String, dynamic>{
|
||||||
'label': label,
|
'label': label,
|
||||||
'iconBytes': iconBytes,
|
'iconBytes': iconBytes,
|
||||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
'filters': filters?.map((filter) => filter.toJson()).toList(),
|
||||||
|
'uri': uri,
|
||||||
});
|
});
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
await reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
|
|
|
@ -56,6 +56,16 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<Map>> getCodecs() async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getCodecs');
|
||||||
|
if (result != null) return (result as List).cast<Map>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map> getEnv() async {
|
static Future<Map> getEnv() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getEnv');
|
final result = await platform.invokeMethod('getEnv');
|
||||||
|
|
|
@ -8,7 +8,8 @@ import 'package:flutter/widgets.dart';
|
||||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||||
|
|
||||||
class AndroidFileUtils {
|
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<String> videoCapturesPaths;
|
||||||
Set<StorageVolume> storageVolumes = {};
|
Set<StorageVolume> storageVolumes = {};
|
||||||
Set<Package> _packages = {};
|
Set<Package> _packages = {};
|
||||||
List<String> _potentialAppDirs = [];
|
List<String> _potentialAppDirs = [];
|
||||||
|
@ -31,8 +32,13 @@ class AndroidFileUtils {
|
||||||
downloadPath = pContext.join(primaryStorage, 'Download');
|
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||||
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
||||||
// from Aves
|
avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures');
|
||||||
videoCapturesPath = pContext.join(dcimPath, 'Video Captures');
|
videoCapturesPaths = {
|
||||||
|
// from Samsung
|
||||||
|
pContext.join(dcimPath, 'Video Captures'),
|
||||||
|
// from Aves
|
||||||
|
avesVideoCapturesPath,
|
||||||
|
};
|
||||||
|
|
||||||
_initialized = true;
|
_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 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;
|
bool isDownloadPath(String path) => path == downloadPath;
|
||||||
|
|
||||||
|
|
|
@ -586,8 +586,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AddShortcutDialog(
|
builder: (context) => AddShortcutDialog(
|
||||||
collection: collection,
|
|
||||||
defaultName: defaultName ?? '',
|
defaultName: defaultName ?? '',
|
||||||
|
collection: collection,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
|
@ -596,6 +596,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
final name = result.item2;
|
final name = result.item2;
|
||||||
if (name.isEmpty) return;
|
if (name.isEmpty) return;
|
||||||
|
|
||||||
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters));
|
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,14 +55,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
||||||
// when scaling ends and we apply the new extent, so we prevent this
|
// when scaling ends and we apply the new extent, so we prevent this
|
||||||
// until we scaled and scrolled to the tile in the new grid
|
// until we scaled and scrolled to the tile in the new grid
|
||||||
if (_applyingScale) return;
|
if (_applyingScale) return;
|
||||||
|
|
||||||
|
final tileExtentController = context.read<TileExtentController>();
|
||||||
|
|
||||||
final scrollableContext = widget.scrollableKey.currentContext!;
|
final scrollableContext = widget.scrollableKey.currentContext!;
|
||||||
final scrollableBox = scrollableContext.findRenderObject() as RenderBox;
|
final scrollableBox = scrollableContext.findRenderObject() as RenderBox;
|
||||||
final result = BoxHitTestResult();
|
final renderMetaData = _getClosestRenderMetadata(
|
||||||
scrollableBox.hitTest(result, position: details.localFocalPoint);
|
box: scrollableBox,
|
||||||
|
localFocalPoint: details.localFocalPoint,
|
||||||
// find `RenderObject`s at the gesture focal point
|
spacing: tileExtentController.spacing,
|
||||||
U? firstOf<U>(BoxHitTestResult result) => result.path.firstWhereOrNull((el) => el.target is U)?.target as U?;
|
);
|
||||||
final renderMetaData = firstOf<RenderMetaData>(result);
|
|
||||||
// abort if we cannot find an image to show on overlay
|
// abort if we cannot find an image to show on overlay
|
||||||
if (renderMetaData == null) return;
|
if (renderMetaData == null) return;
|
||||||
_metadata = renderMetaData.metaData;
|
_metadata = renderMetaData.metaData;
|
||||||
|
@ -72,7 +74,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
||||||
// not the same as `MediaQuery.size.width`, because of screen insets/padding
|
// not the same as `MediaQuery.size.width`, because of screen insets/padding
|
||||||
final gridWidth = scrollableBox.size.width;
|
final gridWidth = scrollableBox.size.width;
|
||||||
|
|
||||||
final tileExtentController = context.read<TileExtentController>();
|
|
||||||
_extentMin = tileExtentController.effectiveExtentMin;
|
_extentMin = tileExtentController.effectiveExtentMin;
|
||||||
_extentMax = tileExtentController.effectiveExtentMax;
|
_extentMax = tileExtentController.effectiveExtentMax;
|
||||||
|
|
||||||
|
@ -138,6 +139,25 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RenderMetaData? _getClosestRenderMetadata({
|
||||||
|
required RenderBox box,
|
||||||
|
required Offset localFocalPoint,
|
||||||
|
required double spacing,
|
||||||
|
}) {
|
||||||
|
var position = localFocalPoint;
|
||||||
|
while (position.dx > 0 && position.dy > 0) {
|
||||||
|
final result = BoxHitTestResult();
|
||||||
|
box.hitTest(result, position: position);
|
||||||
|
|
||||||
|
// find `RenderObject`s at the gesture focal point
|
||||||
|
U? firstOf<U>(BoxHitTestResult result) => result.path.firstWhereOrNull((el) => el.target is U)?.target as U?;
|
||||||
|
final renderMetaData = firstOf<RenderMetaData>(result);
|
||||||
|
if (renderMetaData != null) return renderMetaData;
|
||||||
|
position = position.translate(-spacing, -spacing);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScaleOverlay extends StatefulWidget {
|
class ScaleOverlay extends StatefulWidget {
|
||||||
|
|
81
lib/widgets/debug/android_codecs.dart
Normal file
|
@ -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<DebugAndroidCodecSection> with AutomaticKeepAliveClientMixin {
|
||||||
|
late Future<List<Map>> _loader;
|
||||||
|
final ValueNotifier<String> _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<List<Map>>(
|
||||||
|
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<Map<String, String>, bool>(codecs, (v) => v['isEncoder'] == 'true');
|
||||||
|
final decoders = byEncoder[false] ?? [];
|
||||||
|
final encoders = byEncoder[true] ?? [];
|
||||||
|
Widget _toCodecColumn(List<Map<String, String>> codecs) => ValueListenableBuilder<String>(
|
||||||
|
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(),
|
||||||
|
]
|
||||||
|
: <Widget>[];
|
||||||
|
}).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;
|
||||||
|
}
|
|
@ -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/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.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_apps.dart';
|
||||||
|
import 'package:aves/widgets/debug/android_codecs.dart';
|
||||||
import 'package:aves/widgets/debug/android_dirs.dart';
|
import 'package:aves/widgets/debug/android_dirs.dart';
|
||||||
import 'package:aves/widgets/debug/android_env.dart';
|
import 'package:aves/widgets/debug/android_env.dart';
|
||||||
import 'package:aves/widgets/debug/cache.dart';
|
import 'package:aves/widgets/debug/cache.dart';
|
||||||
|
@ -46,6 +47,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
||||||
children: [
|
children: [
|
||||||
_buildGeneralTabView(),
|
_buildGeneralTabView(),
|
||||||
const DebugAndroidAppSection(),
|
const DebugAndroidAppSection(),
|
||||||
|
const DebugAndroidCodecSection(),
|
||||||
const DebugAndroidDirSection(),
|
const DebugAndroidDirSection(),
|
||||||
const DebugAndroidEnvironmentSection(),
|
const DebugAndroidEnvironmentSection(),
|
||||||
const DebugCacheSection(),
|
const DebugCacheSection(),
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:aves/model/covers.dart';
|
import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.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/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.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';
|
import 'aves_dialog.dart';
|
||||||
|
|
||||||
class AddShortcutDialog extends StatefulWidget {
|
class AddShortcutDialog extends StatefulWidget {
|
||||||
final CollectionLens collection;
|
final CollectionLens? collection;
|
||||||
final String defaultName;
|
final String defaultName;
|
||||||
|
|
||||||
const AddShortcutDialog({
|
const AddShortcutDialog({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.collection,
|
|
||||||
required this.defaultName,
|
required this.defaultName,
|
||||||
|
this.collection,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -32,17 +31,16 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
|
||||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||||
AvesEntry? _coverEntry;
|
AvesEntry? _coverEntry;
|
||||||
|
|
||||||
CollectionLens get collection => widget.collection;
|
|
||||||
|
|
||||||
Set<CollectionFilter> get filters => collection.filters;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final entries = collection.sortedEntries;
|
final _collection = widget.collection;
|
||||||
if (entries.isNotEmpty) {
|
if (_collection != null) {
|
||||||
final coverEntries = filters.map(covers.coverContentId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.contentId == id)).whereNotNull();
|
final entries = _collection.sortedEntries;
|
||||||
_coverEntry = coverEntries.firstOrNull ?? entries.first;
|
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;
|
_nameController.text = widget.defaultName;
|
||||||
_validate();
|
_validate();
|
||||||
|
@ -123,14 +121,17 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickEntry() async {
|
Future<void> _pickEntry() async {
|
||||||
|
final _collection = widget.collection;
|
||||||
|
if (_collection == null) return;
|
||||||
|
|
||||||
final entry = await Navigator.push(
|
final entry = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: ItemPickDialog.routeName),
|
settings: const RouteSettings(name: ItemPickDialog.routeName),
|
||||||
builder: (context) => ItemPickDialog(
|
builder: (context) => ItemPickDialog(
|
||||||
collection: CollectionLens(
|
collection: CollectionLens(
|
||||||
source: collection.source,
|
source: _collection.source,
|
||||||
filters: filters,
|
filters: _collection.filters,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
|
|
|
@ -163,7 +163,7 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_tileExtentController ??= TileExtentController(
|
_tileExtentController ??= TileExtentController(
|
||||||
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!,
|
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!,
|
||||||
columnCountDefault: 2,
|
columnCountDefault: 3,
|
||||||
extentMin: 60,
|
extentMin: 60,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
);
|
);
|
||||||
|
|
|
@ -36,6 +36,7 @@ class ViewerActionEditorPage extends StatelessWidget {
|
||||||
EntryAction.delete,
|
EntryAction.delete,
|
||||||
EntryAction.rename,
|
EntryAction.rename,
|
||||||
EntryAction.export,
|
EntryAction.export,
|
||||||
|
EntryAction.addShortcut,
|
||||||
EntryAction.copyToClipboard,
|
EntryAction.copyToClipboard,
|
||||||
EntryAction.print,
|
EntryAction.print,
|
||||||
EntryAction.rotateScreen,
|
EntryAction.rotateScreen,
|
||||||
|
|
|
@ -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/permission_aware.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/size_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/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/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
|
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/rename_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/filter_grids/album_pick.dart';
|
||||||
import 'package:aves/widgets/viewer/debug/debug_page.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/info/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/printer.dart';
|
import 'package:aves/widgets/viewer/printer.dart';
|
||||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
|
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EntryAction.toggleFavourite:
|
case EntryAction.addShortcut:
|
||||||
entry.toggleFavourite();
|
_addShortcut(context, entry);
|
||||||
break;
|
break;
|
||||||
case EntryAction.copyToClipboard:
|
case EntryAction.copyToClipboard:
|
||||||
androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) {
|
androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) {
|
||||||
|
@ -43,10 +44,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
_showDeleteDialog(context, entry);
|
_delete(context, entry);
|
||||||
break;
|
break;
|
||||||
case EntryAction.export:
|
case EntryAction.export:
|
||||||
_showExportDialog(context, entry);
|
_export(context, entry);
|
||||||
break;
|
break;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
ShowInfoNotification().dispatch(context);
|
ShowInfoNotification().dispatch(context);
|
||||||
|
@ -55,8 +56,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
EntryPrinter(entry).print(context);
|
EntryPrinter(entry).print(context);
|
||||||
break;
|
break;
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
_showRenameDialog(context, entry);
|
_rename(context, entry);
|
||||||
break;
|
break;
|
||||||
|
case EntryAction.share:
|
||||||
|
androidAppService.shareEntries({entry}).then((success) {
|
||||||
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case EntryAction.toggleFavourite:
|
||||||
|
entry.toggleFavourite();
|
||||||
|
break;
|
||||||
|
// raster
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
_rotate(context, entry, clockwise: false);
|
_rotate(context, entry, clockwise: false);
|
||||||
break;
|
break;
|
||||||
|
@ -66,6 +76,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
case EntryAction.flip:
|
case EntryAction.flip:
|
||||||
_flip(context, entry);
|
_flip(context, entry);
|
||||||
break;
|
break;
|
||||||
|
// vector
|
||||||
|
case EntryAction.viewSource:
|
||||||
|
_goToSourceViewer(context, entry);
|
||||||
|
break;
|
||||||
|
// external
|
||||||
case EntryAction.edit:
|
case EntryAction.edit:
|
||||||
androidAppService.edit(entry.uri, entry.mimeType).then((success) {
|
androidAppService.edit(entry.uri, entry.mimeType).then((success) {
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
|
@ -81,31 +96,37 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case EntryAction.rotateScreen:
|
|
||||||
_rotateScreen(context);
|
|
||||||
break;
|
|
||||||
case EntryAction.setAs:
|
case EntryAction.setAs:
|
||||||
androidAppService.setAs(entry.uri, entry.mimeType).then((success) {
|
androidAppService.setAs(entry.uri, entry.mimeType).then((success) {
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case EntryAction.share:
|
// platform
|
||||||
androidAppService.shareEntries({entry}).then((success) {
|
case EntryAction.rotateScreen:
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
_rotateScreen(context);
|
||||||
});
|
|
||||||
break;
|
|
||||||
case EntryAction.viewSource:
|
|
||||||
_goToSourceViewer(context, entry);
|
|
||||||
break;
|
|
||||||
case EntryAction.viewMotionPhotoVideo:
|
|
||||||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
|
||||||
break;
|
break;
|
||||||
|
// debug
|
||||||
case EntryAction.debug:
|
case EntryAction.debug:
|
||||||
_goToDebug(context, entry);
|
_goToDebug(context, entry);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _addShortcut(BuildContext context, AvesEntry entry) async {
|
||||||
|
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
||||||
|
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<void> _flip(BuildContext context, AvesEntry entry) async {
|
Future<void> _flip(BuildContext context, AvesEntry entry) async {
|
||||||
if (!await checkStoragePermission(context, {entry})) return;
|
if (!await checkStoragePermission(context, {entry})) return;
|
||||||
|
|
||||||
|
@ -131,7 +152,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showDeleteDialog(BuildContext context, AvesEntry entry) async {
|
Future<void> _delete(BuildContext context, AvesEntry entry) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
@ -166,7 +187,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showExportDialog(BuildContext context, AvesEntry entry) async {
|
Future<void> _export(BuildContext context, AvesEntry entry) async {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
if (!source.initialized) {
|
if (!source.initialized) {
|
||||||
await source.init();
|
await source.init();
|
||||||
|
@ -273,7 +294,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showRenameDialog(BuildContext context, AvesEntry entry) async {
|
Future<void> _rename(BuildContext context, AvesEntry entry) async {
|
||||||
final newName = await showDialog<String>(
|
final newName = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => RenameEntryDialog(entry: entry),
|
builder: (context) => RenameEntryDialog(entry: entry),
|
||||||
|
|
|
@ -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/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -15,14 +16,44 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw
|
||||||
|
|
||||||
const EntryInfoActionDelegate(this.entry);
|
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 {
|
void onActionSelected(BuildContext context, EntryInfoAction action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
await _editDate(context);
|
await _editDate(context);
|
||||||
break;
|
break;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
await _removeMetadata(context);
|
await _removeMetadata(context);
|
||||||
break;
|
break;
|
||||||
|
// motion photo
|
||||||
|
case EntryInfoAction.viewMotionPhotoVideo:
|
||||||
|
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,9 @@ class InfoAppBar extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final actionDelegate = EntryInfoActionDelegate(entry);
|
||||||
|
final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
// key is expected by test driver
|
// key is expected by test driver
|
||||||
|
@ -46,24 +49,11 @@ class InfoAppBar extends StatelessWidget {
|
||||||
if (entry.canEdit)
|
if (entry.canEdit)
|
||||||
MenuIconTheme(
|
MenuIconTheme(
|
||||||
child: PopupMenuButton<EntryInfoAction>(
|
child: PopupMenuButton<EntryInfoAction>(
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) => menuActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))).toList(),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
onSelected: (action) async {
|
onSelected: (action) async {
|
||||||
// wait for the popup menu to hide before proceeding with the action
|
// wait for the popup menu to hide before proceeding with the action
|
||||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||||
EntryInfoActionDelegate(entry).onActionSelected(context, action);
|
actionDelegate.onActionSelected(context, action);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -73,6 +63,14 @@ class InfoAppBar extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PopupMenuItem<EntryInfoAction> _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) {
|
void _goToSearch(BuildContext context) {
|
||||||
showSearch(
|
showSearch(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -32,6 +32,8 @@ class XmpNamespace extends Equatable {
|
||||||
switch (namespace) {
|
switch (namespace) {
|
||||||
case XmpBasicNamespace.ns:
|
case XmpBasicNamespace.ns:
|
||||||
return XmpBasicNamespace(rawProps);
|
return XmpBasicNamespace(rawProps);
|
||||||
|
case XmpContainer.ns:
|
||||||
|
return XmpContainer(rawProps);
|
||||||
case XmpCrsNamespace.ns:
|
case XmpCrsNamespace.ns:
|
||||||
return XmpCrsNamespace(rawProps);
|
return XmpCrsNamespace(rawProps);
|
||||||
case XmpDarktableNamespace.ns:
|
case XmpDarktableNamespace.ns:
|
||||||
|
|
|
@ -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/embedded/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.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_namespaces.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
abstract class XmpGoogleNamespace extends XmpNamespace {
|
abstract class XmpGoogleNamespace extends XmpNamespace {
|
||||||
|
@ -61,3 +63,25 @@ class XmpGImageNamespace extends XmpGoogleNamespace {
|
||||||
@override
|
@override
|
||||||
List<Tuple2<String, String>> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')];
|
List<Tuple2<String, String>> 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 = <int, Map<String, String>>{};
|
||||||
|
|
||||||
|
XmpContainer(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool extractData(XmpProp prop) => extractIndexedStruct(prop, directoryPattern, directories);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Widget> buildFromExtractedData() => [
|
||||||
|
if (directories.isNotEmpty)
|
||||||
|
XmpStructArrayCard(
|
||||||
|
title: 'Directory Item',
|
||||||
|
structByIndex: directories,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
|
@ -65,7 +65,7 @@ class ViewerTopOverlay extends StatelessWidget {
|
||||||
Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
|
Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
|
||||||
pageEntry ??= mainEntry;
|
pageEntry ??= mainEntry;
|
||||||
|
|
||||||
bool _canDo(EntryAction action) {
|
bool _isVisible(EntryAction action) {
|
||||||
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry;
|
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry! : mainEntry;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case EntryAction.toggleFavourite:
|
case EntryAction.toggleFavourite:
|
||||||
|
@ -84,10 +84,9 @@ class ViewerTopOverlay extends StatelessWidget {
|
||||||
return targetEntry.hasGps;
|
return targetEntry.hasGps;
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
return targetEntry.isSvg;
|
return targetEntry.isSvg;
|
||||||
case EntryAction.viewMotionPhotoVideo:
|
|
||||||
return targetEntry.isMotionPhoto;
|
|
||||||
case EntryAction.rotateScreen:
|
case EntryAction.rotateScreen:
|
||||||
return settings.isRotationLocked;
|
return settings.isRotationLocked;
|
||||||
|
case EntryAction.addShortcut:
|
||||||
case EntryAction.copyToClipboard:
|
case EntryAction.copyToClipboard:
|
||||||
case EntryAction.edit:
|
case EntryAction.edit:
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
|
@ -103,9 +102,9 @@ class ViewerTopOverlay extends StatelessWidget {
|
||||||
final buttonRow = Selector<Settings, bool>(
|
final buttonRow = Selector<Settings, bool>(
|
||||||
selector: (context, s) => s.isRotationLocked,
|
selector: (context, s) => s.isRotationLocked,
|
||||||
builder: (context, s, child) {
|
builder: (context, s, child) {
|
||||||
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount - 1).toList();
|
final quickActions = settings.viewerQuickActions.where(_isVisible).take(availableCount - 1).toList();
|
||||||
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
|
||||||
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
final externalAppActions = EntryActions.externalApp.where(_isVisible).toList();
|
||||||
return _TopOverlayRow(
|
return _TopOverlayRow(
|
||||||
quickActions: quickActions,
|
quickActions: quickActions,
|
||||||
inAppActions: inAppActions,
|
inAppActions: inAppActions,
|
||||||
|
@ -208,6 +207,7 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case EntryAction.addShortcut:
|
||||||
case EntryAction.copyToClipboard:
|
case EntryAction.copyToClipboard:
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
case EntryAction.export:
|
case EntryAction.export:
|
||||||
|
@ -220,7 +220,6 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
case EntryAction.rotateScreen:
|
case EntryAction.rotateScreen:
|
||||||
case EntryAction.viewSource:
|
case EntryAction.viewSource:
|
||||||
case EntryAction.viewMotionPhotoVideo:
|
|
||||||
child = IconButton(
|
child = IconButton(
|
||||||
icon: action.getIcon() ?? const SizedBox(),
|
icon: action.getIcon() ?? const SizedBox(),
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
|
|
|
@ -72,7 +72,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
final positionMillis = controller.currentPosition;
|
final positionMillis = controller.currentPosition;
|
||||||
final bytes = await controller.captureFrame();
|
final bytes = await controller.captureFrame();
|
||||||
|
|
||||||
final destinationAlbum = androidFileUtils.videoCapturesPath;
|
final destinationAlbum = androidFileUtils.avesVideoCapturesPath;
|
||||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
||||||
|
|
||||||
if (!await checkFreeSpace(context, bytes.length, destinationAlbum)) return;
|
if (!await checkFreeSpace(context, bytes.length, destinationAlbum)) return;
|
||||||
|
|
|
@ -288,7 +288,7 @@ packages:
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: aves
|
ref: aves
|
||||||
resolved-ref: "44569361c251cc4ced0ff845b02c64ceeaebb957"
|
resolved-ref: "2aa0f5f08135de00966e9b71e58cddb61f93e81c"
|
||||||
url: "git://github.com/deckerst/fijkplayer.git"
|
url: "git://github.com/deckerst/fijkplayer.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.10.0"
|
version: "0.10.0"
|
||||||
|
@ -447,7 +447,7 @@ packages:
|
||||||
name: github
|
name: github
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.2.3"
|
version: "8.2.5"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name: aves
|
name: aves
|
||||||
description: A visual media gallery and metadata explorer app.
|
description: A visual media gallery and metadata explorer app.
|
||||||
repository: https://github.com/deckerst/aves
|
repository: https://github.com/deckerst/aves
|
||||||
version: 1.5.5+59
|
version: 1.5.6+60
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|
|
@ -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)
|
- modify items in bulk (rotation, date, metadata removal)
|
||||||
- filter items by title
|
- filter items by title
|
||||||
- enjoy the app in Russian
|
- enjoy the app in Russian
|
||||||
|
|