Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2024-04-01 23:10:42 +02:00
commit 82f070f8a1
141 changed files with 1617 additions and 706 deletions

@ -1 +1 @@
Subproject commit ba393198430278b6595976de84fe170f553cc728
Subproject commit 300451adae589accbece3490f4396f10bdf15e6e

View file

@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.10.8"></a>[v1.10.8] - 2024-04-01
### Added
- Collection: support for Fairphone burst pattern
- Collection: allow using tags/make/model when bulk renaming
- Video: A-B repeat
- Settings: hidden items can be toggled
### Changed
- opening app from launcher always show home page
- use dates with western arabic numerals for maghreb arabic locales
- album unique names are case insensitive
- upgraded Flutter to stable v3.19.5
### Fixed
- crash when decoding large region
- viewer position drift during scale
- viewer side gesture precedence (next entry by single tap vs zoom by double tap)
## <a id="v1.10.7"></a>[v1.10.7] - 2024-03-12
### Added

View file

@ -2,9 +2,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'com.android.application'
id 'com.google.devtools.ksp' version "$ksp_version"
id 'com.google.devtools.ksp'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dev.flutter.flutter-gradle-plugin'
}
def packageName = "deckers.thibault.aves"
@ -20,11 +21,6 @@ if (localPropertiesFile.exists()) {
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
def flutterVersionName = localProperties.getProperty('flutter.versionName')
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
// Keys
@ -54,8 +50,8 @@ android {
ndkVersion '25.1.8937393'
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
lint {
@ -181,12 +177,12 @@ android {
}
tasks.withType(KotlinCompile).configureEach {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(8)
jvmToolchain(17)
}
flutter {
@ -210,7 +206,7 @@ repositories {
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
implementation "androidx.appcompat:appcompat:1.6.1"
implementation 'androidx.core:core-ktx:1.12.0'
@ -226,7 +222,7 @@ dependencies {
implementation "com.github.bumptech.glide:glide:$glide_version"
implementation 'com.google.android.material:material:1.11.0'
// SLF4J implementation for `mp4parser`
implementation 'org.slf4j:slf4j-simple:2.0.11'
implementation 'org.slf4j:slf4j-simple:2.0.12'
// forked, built by JitPack:
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
@ -240,7 +236,7 @@ dependencies {
// huawei flavor only
huaweiImplementation "com.huawei.agconnect:agconnect-core:$huawei_agconnect_version"
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
kapt 'androidx.annotation:annotation:1.7.1'
ksp "com.github.bumptech.glide:ksp:$glide_version"

View file

@ -36,6 +36,7 @@
<!-- to access media with original metadata with scoped storage (API >=29) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- to provide a foreground service type, as required by Android 14 (API 34) -->
<!-- TODO TLAD [Android 15 (API 35)] use `FOREGROUND_SERVICE_MEDIA_PROCESSING` -->
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
tools:ignore="SystemPermissionTypo" />
@ -72,11 +73,11 @@
<!--
allow install on API 19, despite the `minSdk` declared in dependencies:
- Google Maps is from API 20
- the Security library is from API 21
- FFmpegKit for Flutter is from API 24 (when not LTS)
- Google Maps is from API 20
-->
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps, androidx.security:security-crypto, com.arthenica.ffmpegkit.flutter" />
<uses-sdk tools:overrideLibrary="androidx.security, com.arthenica.ffmpegkit.flutter, io.flutter.plugins.googlemaps" />
<!-- from Android 11, we should define <queries> to make other apps visible to this app -->
<queries>
@ -258,6 +259,7 @@
</receiver>
<!-- anonymous service for analysis worker is specified here to provide service type -->
<!-- TODO TLAD [Android 15 (API 35)] use `mediaProcessing` -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"

View file

@ -176,6 +176,7 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
// from Android 14 (API 34), foreground service type is mandatory
// despite the sample code omitting it at:
// https://developer.android.com/guide/background/persistent/how-to/long-running
// TODO TLAD [Android 15 (API 35)] use `FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING`
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
ForegroundInfo(NOTIFICATION_ID, notification, type)
} else {

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import io.flutter.embedding.engine.FlutterEngine
@ -56,7 +57,7 @@ class HomeWidgetSettingsActivity : MainActivity() {
finish()
}
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
override fun extractIntentData(intent: Intent?): FieldMap {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_WIDGET_SETTINGS,
INTENT_DATA_KEY_WIDGET_ID to appWidgetId,

View file

@ -175,18 +175,6 @@ open class MainActivity : FlutterFragmentActivity() {
}
}
override fun onResume() {
super.onResume()
mediaStoreChangeStreamHandler.onAppResume()
settingsChangeStreamHandler.onAppResume()
}
override fun onPause() {
mediaStoreChangeStreamHandler.onAppPause()
settingsChangeStreamHandler.onAppPause()
super.onPause()
}
override fun onStop() {
Log.i(LOG_TAG, "onStop")
super.onStop()
@ -242,7 +230,7 @@ open class MainActivity : FlutterFragmentActivity() {
private fun onEditResult(resultCode: Int, intent: Intent?) {
val fields: FieldMap? = if (resultCode == RESULT_OK) hashMapOf(
"uri" to intent?.data.toString(),
"uri" to intent?.data?.toString(),
"mimeType" to intent?.type,
) else null
pendingEditIntentHandler?.let { it(fields) }
@ -279,21 +267,19 @@ open class MainActivity : FlutterFragmentActivity() {
}
}
open fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
open fun extractIntentData(intent: Intent?): FieldMap {
when (val action = intent?.action) {
Intent.ACTION_MAIN -> {
if (intent.getBooleanExtra(EXTRA_KEY_SAFE_MODE, false)) {
return hashMapOf(
INTENT_DATA_KEY_SAFE_MODE to true,
)
}
val fields = hashMapOf<String, Any?>(
INTENT_DATA_KEY_LAUNCHER to intent.hasCategory(Intent.CATEGORY_LAUNCHER),
INTENT_DATA_KEY_SAFE_MODE to intent.getBooleanExtra(EXTRA_KEY_SAFE_MODE, false),
)
intent.getStringExtra(EXTRA_KEY_PAGE)?.let { page ->
val filters = extractFiltersFromIntent(intent)
return hashMapOf(
INTENT_DATA_KEY_PAGE to page,
INTENT_DATA_KEY_FILTERS to filters,
)
fields[INTENT_DATA_KEY_PAGE] = page
fields[INTENT_DATA_KEY_FILTERS] = filters
}
return fields
}
Intent.ACTION_VIEW,
@ -496,6 +482,7 @@ open class MainActivity : FlutterFragmentActivity() {
const val INTENT_DATA_KEY_ACTION = "action"
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
const val INTENT_DATA_KEY_FILTERS = "filters"
const val INTENT_DATA_KEY_LAUNCHER = "launcher"
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
const val INTENT_DATA_KEY_PAGE = "page"
const val INTENT_DATA_KEY_QUERY = "query"

View file

@ -1,9 +1,10 @@
package deckers.thibault.aves
import android.content.Intent
import deckers.thibault.aves.model.FieldMap
class ScreenSaverSettingsActivity : MainActivity() {
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
override fun extractIntentData(intent: Intent?): FieldMap {
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SCREEN_SAVER_SETTINGS,
)

View file

@ -14,6 +14,7 @@ import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaCommandStreamHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import deckers.thibault.aves.utils.LogUtils
@ -25,7 +26,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class WallpaperActivity : FlutterFragmentActivity() {
private lateinit var intentDataMap: MutableMap<String, Any?>
private lateinit var intentDataMap: FieldMap
private lateinit var mediaSessionHandler: MediaSessionHandler
override fun onCreate(savedInstanceState: Bundle?) {
@ -103,7 +104,7 @@ class WallpaperActivity : FlutterFragmentActivity() {
}
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
private fun extractIntentData(intent: Intent?): FieldMap {
when (intent?.action) {
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->

View file

@ -36,13 +36,13 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
val provider = getProvider(context, uri)
if (provider == null) {
result.error("getEntry-provider", "failed to find provider for uri=$uri", null)
result.error("getEntry-provider", "failed to find provider for uri=$uri mimeType=$mimeType", null)
return
}
provider.fetchSingle(context, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri mimeType=$mimeType", throwable.message)
})
}

View file

@ -29,6 +29,7 @@ import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.ExifGeoTiffTags
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
@ -110,7 +111,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"getAllMetadata" -> ioScope.launch { safe(call, result, ::getAllMetadata) }
"getCatalogMetadata" -> ioScope.launch { safe(call, result, ::getCatalogMetadata) }
"getFields" -> ioScope.launch { safe(call, result, ::getFields) }
"getOverlayMetadata" -> ioScope.launch { safe(call, result, ::getOverlayMetadata) }
"getGeoTiffInfo" -> ioScope.launch { safe(call, result, ::getGeoTiffInfo) }
"getMultiPageInfo" -> ioScope.launch { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) }
@ -119,6 +120,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentProp) }
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentPropValue) }
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
"getFields" -> ioScope.launch { safe(call, result, ::getFields) }
else -> result.notImplemented()
}
}
@ -815,7 +817,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
@ -1250,6 +1252,71 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(dateMillis)
}
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val fields = call.argument<List<String>>("fields")
if (mimeType == null || uri == null || fields == null) {
result.error("getFields-args", "missing arguments", null)
return
}
val metadataMap = HashMap<String, Any>()
if (fields.isEmpty() || isVideo(mimeType)) {
result.success(metadataMap)
return
}
var foundExif = false
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) {
foundExif = true
val allTags = ExifInterfaceHelper.allTags
fields.forEach { tag ->
allTags[tag]?.let { mapper ->
val tagType = mapper.type
dir.getDescription(tagType)?.let { value -> metadataMap[tag] = value }
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
if (!foundExif && canReadWithExifInterface(mimeType)) {
// fallback to read EXIF via ExifInterface
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
fields.forEach { tag ->
if (exif.hasAttribute(tag)) {
val value = exif.getAttribute(tag)
if (value != null) {
metadataMap[tag] = value
}
}
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e)
}
}
result.success(metadataMap)
}
companion object {
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"

View file

@ -135,7 +135,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
}
val trashDirs = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) }
val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.mapNotNull { file -> file?.path } ?: listOf() }
val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.filterNotNull()?.mapNotNull { file -> file.path } ?: listOf() }
val untrackedPaths = trashItemPaths.filterNot(knownPaths::contains).toList()
result.success(untrackedPaths)

View file

@ -12,7 +12,9 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel
@ -73,7 +75,7 @@ class RegionFetcher internal constructor(
BitmapRegionDecoderCompat.newInstance(input)
}
if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
return
}
currentDecoderRef = LastDecoderRef(uri, newDecoder)
@ -96,14 +98,22 @@ class RegionFetcher internal constructor(
regionRect
}
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * effectiveRect.width() * effectiveRect.height() / sampleSize
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
// decoding a region that large would yield an OOM when creating the bitmap
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
return
}
val bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
} else {
result.error("getRegion-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
}
} catch (e: Exception) {
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}

View file

@ -11,6 +11,7 @@ import com.caverock.androidsvg.RenderOptions
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils
@ -116,8 +117,4 @@ class SvgRegionFetcher internal constructor(
val uri: Uri,
val svg: SVG,
)
companion object {
const val ARGB_8888_BYTE_SIZE = 4
}
}

View file

@ -198,7 +198,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
.setDataAndType(AppAdapterHandler.getShareableUri(activity, Uri.parse(uri)), mimeType)
if (intent.resolveActivity(activity.packageManager) == null) {
error("edit-resolve", "cannot resolve activity for this intent", null)
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
return
}
@ -207,7 +207,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
endOfStream()
}
if (!safeStartActivityForResult(intent, MainActivity.EDIT_REQUEST)) {
error("edit-start", "cannot start activity for this intent", null)
error("edit-start", "cannot start activity for this intent for uri=$uri mimeType=$mimeType", null)
}
}

View file

@ -30,14 +30,6 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
}
init {
onAppResume()
}
fun dispose() {
onAppPause()
}
fun onAppResume() {
Log.i(LOG_TAG, "start listening to Media Store")
context.contentResolver.apply {
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
@ -45,7 +37,7 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
}
}
fun onAppPause() {
fun dispose() {
Log.i(LOG_TAG, "stop listening to Media Store")
context.contentResolver.unregisterContentObserver(contentObserver)
}

View file

@ -62,19 +62,11 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
}
init {
onAppResume()
}
fun dispose() {
onAppPause()
}
fun onAppResume() {
Log.i(LOG_TAG, "start listening to system settings")
context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, contentObserver)
}
fun onAppPause() {
fun dispose() {
Log.i(LOG_TAG, "stop listening to system settings")
context.contentResolver.unregisterContentObserver(contentObserver)
}

View file

@ -20,6 +20,8 @@ object BitmapUtils {
private val freeBaos = ArrayList<ByteArrayOutputStream>()
private val mutex = Mutex()
const val ARGB_8888_BYTE_SIZE = 4
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
val stream: ByteArrayOutputStream
mutex.withLock {

View file

@ -1,8 +1,5 @@
buildscript {
ext {
kotlin_version = '1.9.21'
ksp_version = "$kotlin_version-1.0.15"
agp_version = '8.2.2'
glide_version = '4.16.0'
// AppGallery Connect plugin versions: https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-sdk-changenotes-0000001058732550
huawei_agconnect_version = '1.9.1.300'
@ -22,9 +19,6 @@ buildscript {
}
dependencies {
classpath "com.android.tools.build:gradle:$agp_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
if (useCrashlytics) {
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.4.0'

View file

@ -20,8 +20,8 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

View file

@ -6,13 +6,13 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip

33
android/settings.gradle Normal file
View file

@ -0,0 +1,33 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
settings.ext.kotlin_version = '1.9.21'
settings.ext.ksp_version = "$kotlin_version-1.0.15"
settings.ext.agp_version = '8.3.1'
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version("1.0.0")
id("com.android.application") version("$agp_version") apply(false)
id("org.jetbrains.kotlin.android") version("$kotlin_version") apply(false)
id("com.google.devtools.ksp") version("$ksp_version") apply(false)
id("org.gradle.toolchains.foojay-resolver-convention") version("0.4.0")
}
include(":app")
include(":exifinterface")

View file

@ -1,27 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version ("0.4.0")
}
include(":app")
val localPropertiesFile = File(rootProject.projectDir, "local.properties")
val properties = java.util.Properties()
assert(localPropertiesFile.exists())
localPropertiesFile.reader(Charsets.UTF_8).also { reader -> properties.load(reader) }
val flutterSdkPath: String? = properties.getProperty("flutter.sdk")
assert(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
apply {
from("$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle")
}
include(":exifinterface")

View file

@ -1,5 +0,0 @@
In v1.9.4:
- play your animated AVIF, AV1, and HDR videos
- filter by rating ranges
- judge tonal distributions with the viewer histogram
Full changelog available on GitHub

View file

@ -1,5 +0,0 @@
In v1.9.4:
- play your animated AVIF, AV1, and HDR videos
- filter by rating ranges
- judge tonal distributions with the viewer histogram
Full changelog available on GitHub

View file

@ -1,5 +0,0 @@
In v1.9.5:
- play your animated AVIF, AV1, and HDR videos
- filter by rating ranges
- judge tonal distributions with the viewer histogram
Full changelog available on GitHub

View file

@ -1,5 +0,0 @@
In v1.9.5:
- play your animated AVIF, AV1, and HDR videos
- filter by rating ranges
- judge tonal distributions with the viewer histogram
Full changelog available on GitHub

View file

@ -1,5 +0,0 @@
In v1.9.6:
- play your animated AVIF, AV1, and HDR videos
- filter by rating ranges
- judge tonal distributions with the viewer histogram
Full changelog available on GitHub

View file

@ -1,5 +0,0 @@
In v1.9.6:
- play your animated AVIF, AV1, and HDR videos
- filter by rating ranges
- judge tonal distributions with the viewer histogram
Full changelog available on GitHub

View file

@ -1,3 +0,0 @@
In v1.9.7:
- enjoy the app in Slovak & Vietnamese
Full changelog available on GitHub

View file

@ -1,3 +0,0 @@
In v1.9.7:
- enjoy the app in Slovak & Vietnamese
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.0:
- cast images via DLNA/UPnP
- enjoy the app in Icelandic
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.0:
- cast images via DLNA/UPnP
- enjoy the app in Icelandic
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.1:
- JPEG MPF support
- enjoy the app in Arabic & Belarusian
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.1:
- JPEG MPF support
- enjoy the app in Arabic & Belarusian
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.2:
- JPEG MPF support
- enjoy the app in Arabic & Belarusian
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.2:
- JPEG MPF support
- enjoy the app in Arabic & Belarusian
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.3:
- customize your home page
- analyze your images with the histogram (for real this time)
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.3:
- customize your home page
- analyze your images with the histogram (for real this time)
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.4:
- customize your home page
- analyze your images with the histogram (for real this time)
Full changelog available on GitHub

View file

@ -1,4 +0,0 @@
In v1.10.4:
- customize your home page
- analyze your images with the histogram (for real this time)
Full changelog available on GitHub

View file

@ -1,3 +0,0 @@
In v1.10.5:
- enjoy the app in Catalan
Full changelog available on GitHub

View file

@ -1,3 +0,0 @@
In v1.10.5:
- enjoy the app in Catalan
Full changelog available on GitHub

View file

@ -0,0 +1,4 @@
In v1.10.8:
- rename in bulk using tags
- repeat a section section section of a video
Full changelog available on GitHub

View file

@ -0,0 +1,4 @@
In v1.10.8:
- rename in bulk using tags
- repeat a section section section of a video
Full changelog available on GitHub

View file

@ -43,6 +43,8 @@ extension ExtraMetadataFieldConvert on MetadataField {
case MetadataField.exifGpsTrackRef:
case MetadataField.exifGpsVersionId:
case MetadataField.exifImageDescription:
case MetadataField.exifMake:
case MetadataField.exifModel:
case MetadataField.exifUserComment:
return MetadataType.exif;
case MetadataField.mp4GpsCoordinates:
@ -145,6 +147,10 @@ extension ExtraMetadataFieldConvert on MetadataField {
return 'GPSVersionID';
case MetadataField.exifImageDescription:
return 'ImageDescription';
case MetadataField.exifMake:
return 'Make';
case MetadataField.exifModel:
return 'Model';
case MetadataField.exifUserComment:
return 'UserComment';
default:

View file

@ -1524,5 +1524,13 @@
"collectionActionSetHome": "تعيين كخلفية",
"@collectionActionSetHome": {},
"setHomeCustomCollection": "مجموعة مخصصة",
"@setHomeCustomCollection": {}
"@setHomeCustomCollection": {},
"videoActionABRepeat": "تكرار A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "تعيين نهاية التشغيل",
"@videoRepeatActionSetEnd": {},
"stopTooltip": "توقف",
"@stopTooltip": {},
"videoRepeatActionSetStart": "تعيين بداية التشغيل",
"@videoRepeatActionSetStart": {}
}

View file

@ -5,7 +5,7 @@
"@welcomeTermsToggle": {},
"welcomeOptional": "Неабавязковыя",
"@welcomeOptional": {},
"welcomeMessage": "Сардэчна запрашаем у Aves",
"welcomeMessage": "Сардэчна запрашаем ў Aves",
"@welcomeMessage": {},
"itemCount": "{count, plural, =1{1 элемент} other{{count} элементаў}}",
"@itemCount": {
@ -38,7 +38,7 @@
"@saveTooltip": {},
"doNotAskAgain": "Больш не пытайся",
"@doNotAskAgain": {},
"chipActionGoToCountryPage": "Паказаць у краінах",
"chipActionGoToCountryPage": "Паказаць ў Краінах",
"@chipActionGoToCountryPage": {},
"chipActionFilterOut": "Адфільтраваць",
"@chipActionFilterOut": {},
@ -56,17 +56,17 @@
"@sourceStateCataloguing": {},
"chipActionDelete": "Выдаліць",
"@chipActionDelete": {},
"chipActionGoToAlbumPage": "Паказаць у альбомах",
"chipActionGoToAlbumPage": "Паказаць ў Альбомах",
"@chipActionGoToAlbumPage": {},
"chipActionHide": "Схаваць",
"@chipActionHide": {},
"chipActionCreateVault": "Стварыце сховішча",
"@chipActionCreateVault": {},
"chipActionGoToPlacePage": "Паказаць у месцах",
"chipActionGoToPlacePage": "Паказаць ў Лакацыях",
"@chipActionGoToPlacePage": {},
"chipActionUnpin": "Адмацаваць зверху",
"@chipActionUnpin": {},
"chipActionGoToTagPage": "Паказаць у тэгах",
"chipActionGoToTagPage": "Паказаць ў Тэгах",
"@chipActionGoToTagPage": {},
"chipActionLock": "Заблакаваць",
"@chipActionLock": {},
@ -76,7 +76,7 @@
"@chipActionRename": {},
"chipActionConfigureVault": "Наладзіць сховішча",
"@chipActionConfigureVault": {},
"entryActionCopyToClipboard": "Скапіраваць у буфер абмену",
"entryActionCopyToClipboard": "Скапіяваць ў буфер абмену",
"@entryActionCopyToClipboard": {},
"entryActionDelete": "Выдаліць",
"@entryActionDelete": {},
@ -120,13 +120,13 @@
"@entryActionRotateScreen": {},
"entryActionViewSource": "Паглядзець крыніцу",
"@entryActionViewSource": {},
"entryActionConvertMotionPhotoToStillImage": "Пераўтварыць у нерухомую выяву",
"entryActionConvertMotionPhotoToStillImage": "Пераўтварыць ў нерухомую выяву",
"@entryActionConvertMotionPhotoToStillImage": {},
"entryActionViewMotionPhotoVideo": "Адкрыць відэа",
"@entryActionViewMotionPhotoVideo": {},
"entryActionSetAs": "Ўсталяваць як",
"@entryActionSetAs": {},
"entryActionAddFavourite": "Дадаць у абранае",
"entryActionAddFavourite": "Дадаць ў абранае",
"@entryActionAddFavourite": {},
"videoActionUnmute": "Ўключыць гук",
"@videoActionUnmute": {},
@ -188,11 +188,11 @@
"@entryActionEdit": {},
"entryActionOpen": "Адкрыць з дапамогай",
"@entryActionOpen": {},
"entryActionOpenMap": "Паказаць у праграме карты",
"entryActionOpenMap": "Паказаць ў праграме карты",
"@entryActionOpenMap": {},
"videoActionMute": "Адключыць гук",
"@videoActionMute": {},
"slideshowActionShowInCollection": "Паказаць у калекцыі",
"slideshowActionShowInCollection": "Паказаць ў Калекцыі",
"@slideshowActionShowInCollection": {},
"entryInfoActionEditDate": "Рэдагаваць дату і час",
"@entryInfoActionEditDate": {},
@ -363,7 +363,7 @@
"@vaultLockTypePassword": {},
"settingsVideoEnablePip": "Карцінка ў карцінцы",
"@settingsVideoEnablePip": {},
"videoControlsPlayOutside": "Адкрыць у іншым прайгравальніку",
"videoControlsPlayOutside": "Адкрыць ў іншым прайгравальніку",
"@videoControlsPlayOutside": {},
"videoControlsPlay": "Прайграванне",
"@videoControlsPlay": {},
@ -478,7 +478,7 @@
}
}
},
"addShortcutDialogLabel": "Ярлык хуткага доступу",
"addShortcutDialogLabel": "Назва ярлыка",
"@addShortcutDialogLabel": {},
"addShortcutButtonLabel": "ДАДАЦЬ",
"@addShortcutButtonLabel": {},
@ -507,7 +507,7 @@
"@newAlbumDialogTitle": {},
"newAlbumDialogNameLabel": "Назва альбома",
"@newAlbumDialogNameLabel": {},
"newAlbumDialogNameLabelAlreadyExistsHelper": "Каталог ужо існуе",
"newAlbumDialogNameLabelAlreadyExistsHelper": "Каталог ўжо існуе",
"@newAlbumDialogNameLabelAlreadyExistsHelper": {},
"newAlbumDialogStorageLabel": "Захоўванне:",
"@newAlbumDialogStorageLabel": {},
@ -691,13 +691,13 @@
"@aboutBugReportInstruction": {},
"entryActionCast": "Трансляцыя",
"@entryActionCast": {},
"hideFilterConfirmationDialogMessage": "Адпаведныя фота і відэа будуць схаваны з вашай калекцыі. Вы можаце убачыць іх зноў у наладах «Прыватнасць».\n\nВы ўпэўнены, што хочаце іх схаваць?",
"hideFilterConfirmationDialogMessage": "Адпаведныя фота і відэа будуць схаваны з вашай калекцыі. Вы можаце убачыць іх зноў ў наладах «Прыватнасць».\n\nВы ўпэўнены, што хочаце іх схаваць?",
"@hideFilterConfirmationDialogMessage": {},
"renameEntrySetPagePatternFieldLabel": "Шаблон наймення",
"@renameEntrySetPagePatternFieldLabel": {},
"renameAlbumDialogLabel": "Новая назва",
"@renameAlbumDialogLabel": {},
"renameAlbumDialogLabelAlreadyExistsHelper": "Каталог ужо ёсць",
"renameAlbumDialogLabelAlreadyExistsHelper": "Каталог ўжо ёсць",
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
"aboutBugReportButton": "Адправіць справаздачу",
"@aboutBugReportButton": {},
@ -781,7 +781,7 @@
"@statsPageTitle": {},
"settingsUnitSystemDialogTitle": "Адзінкі вымярэння",
"@settingsUnitSystemDialogTitle": {},
"editEntryDialogCopyFromItem": "Скапіюваць з іншага элемента",
"editEntryDialogCopyFromItem": "Скапіяваць з іншага элемента",
"@editEntryDialogCopyFromItem": {},
"settingsThemeEnableDynamicColor": "Дынамічны колер",
"@settingsThemeEnableDynamicColor": {},
@ -791,13 +791,13 @@
"@renameEntrySetPageInsertTooltip": {},
"settingsThemeBrightnessTile": "Тэма",
"@settingsThemeBrightnessTile": {},
"settingsSystemDefault": "Як у сістэме",
"settingsSystemDefault": "Як ў сістэме",
"@settingsSystemDefault": {},
"settingsCollectionTile": "Калекцыя",
"@settingsCollectionTile": {},
"settingsThemeBrightnessDialogTitle": "Тэма",
"@settingsThemeBrightnessDialogTitle": {},
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэты альбом і элемент у ім?} few{Выдаліць гэты альбом і {count} элементы у ім?} other{Выдаліць гэты альбом і {count} элементаў у ім?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэты альбом і элемент ў ім?} few{Выдаліць гэты альбом і {count} элементы ў ім?} other{Выдаліць гэты альбом і {count} элементаў ў ім?}}",
"@deleteSingleAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
@ -853,7 +853,7 @@
"@tileLayoutMosaic": {},
"collectionDeselectSectionTooltip": "Адмяніць выбар раздзела",
"@collectionDeselectSectionTooltip": {},
"settingsKeepScreenOnTile": "Трымаць экран уключаным",
"settingsKeepScreenOnTile": "Трымаць экран ўключаным",
"@settingsKeepScreenOnTile": {},
"tileLayoutGrid": "Сетка",
"@tileLayoutGrid": {},
@ -895,7 +895,7 @@
"@searchCountriesSectionTitle": {},
"settingsAskEverytime": "Пытацца кожны раз",
"@settingsAskEverytime": {},
"editEntryDateDialogCopyField": "Капіяваць з іншай даты",
"editEntryDateDialogCopyField": "Скапіяваць з іншай даты",
"@editEntryDateDialogCopyField": {},
"searchTagsSectionTitle": "Тэгі",
"@searchTagsSectionTitle": {},
@ -953,7 +953,7 @@
"@albumPickPageTitlePick": {},
"menuActionMap": "Карта",
"@menuActionMap": {},
"collectionActionMove": "Перамясціць у альбом",
"collectionActionMove": "Перамясціць ў альбом",
"@collectionActionMove": {},
"searchAlbumsSectionTitle": "Альбомы",
"@searchAlbumsSectionTitle": {},
@ -1013,9 +1013,9 @@
"@albumPageTitle": {},
"editEntryLocationDialogTitle": "Месцазнаходжанне",
"@editEntryLocationDialogTitle": {},
"albumPickPageTitleCopy": "Капіюваць у альбом",
"albumPickPageTitleCopy": "Скапіяваць ў альбом",
"@albumPickPageTitleCopy": {},
"collectionActionCopy": "Скапіюваць у альбом",
"collectionActionCopy": "Скапіяваць ў альбом",
"@collectionActionCopy": {},
"viewDialogReverseSortOrder": "Адваротны парадак сартавання",
"@viewDialogReverseSortOrder": {},
@ -1033,7 +1033,7 @@
"@tagEmpty": {},
"collectionActionShowTitleSearch": "Паказаць фільтр загалоўка",
"@collectionActionShowTitleSearch": {},
"menuActionSelectAll": "Выбраць усё",
"menuActionSelectAll": "Выбраць ўсё",
"@menuActionSelectAll": {},
"settingsConfirmationTile": "Дыялогі пацверджання",
"@settingsConfirmationTile": {},
@ -1059,7 +1059,7 @@
"@drawerCollectionAnimated": {},
"durationDialogHours": "Гадзіны",
"@durationDialogHours": {},
"settingsKeepScreenOnDialogTitle": "Трымаць экран уключаным",
"settingsKeepScreenOnDialogTitle": "Трымаць экран ўключаным",
"@settingsKeepScreenOnDialogTitle": {},
"drawerPlacePage": "Месцы",
"@drawerPlacePage": {},
@ -1077,7 +1077,7 @@
"@appExportFavourites": {},
"collectionEmptyImages": "Няма выяў",
"@collectionEmptyImages": {},
"albumPickPageTitleExport": "Экспартаваць у альбом",
"albumPickPageTitleExport": "Экспартаваць ў альбом",
"@albumPickPageTitleExport": {},
"settingsActionExportDialogTitle": "Экспарт",
"@settingsActionExportDialogTitle": {},
@ -1149,7 +1149,7 @@
"@coverDialogTabColor": {},
"genericSuccessFeedback": "Гатова!",
"@genericSuccessFeedback": {},
"aboutLicensesShowAllButtonLabel": "Паказаць усе ліцэнзіі",
"aboutLicensesShowAllButtonLabel": "Паказаць ўсе ліцэнзіі",
"@aboutLicensesShowAllButtonLabel": {},
"sortOrderNewestFirst": "Спачатку самае новае",
"@sortOrderNewestFirst": {},
@ -1175,7 +1175,7 @@
"@menuActionStats": {},
"appPickDialogTitle": "Выбраць праграму",
"@appPickDialogTitle": {},
"albumPickPageTitleMove": "Перамясціць у альбом",
"albumPickPageTitleMove": "Перамясціць ў альбом",
"@albumPickPageTitleMove": {},
"coverDialogTabCover": "Вокладка",
"@coverDialogTabCover": {},
@ -1405,7 +1405,7 @@
"@settingsStorageAccessEmpty": {},
"settingsRemoveAnimationsTile": "Выдаліць анімацыі",
"@settingsRemoveAnimationsTile": {},
"settingsStorageAccessBanner": "Некаторыя каталогі патрабуюць відавочнага дазволу на змяненне файлаў у іх. Тут вы можаце прагледзець каталогі, да якіх вы раней далі доступ.",
"settingsStorageAccessBanner": "Некаторыя каталогі патрабуюць відавочнага дазволу на змяненне файлаў ў іх. Тут вы можаце прагледзець каталогі, да якіх вы раней далі доступ.",
"@settingsStorageAccessBanner": {},
"collectionCopySuccessFeedback": "{count, plural, =1{1 элемент скапіяваны} few{{count} элементы скапіявана} other{{count} элементаў скапіявана}}",
"@collectionCopySuccessFeedback": {
@ -1467,7 +1467,7 @@
"@settingsSubtitleThemeTextPositionTile": {},
"settingsVideoBackgroundModeDialogTitle": "Фонавы рэжым",
"@settingsVideoBackgroundModeDialogTitle": {},
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэтыя альбомы і элемент у іх?} few{Выдаліць гэтыя альбомы і {count} элементы у іх?} other{Выдаліць гэтыя альбомы і {count} элементаў у іх?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэтыя альбомы і элемент ў іх?} few{Выдаліць гэтыя альбомы і {count} элементы ў іх?} other{Выдаліць гэтыя альбомы і {count} элементаў ў іх?}}",
"@deleteMultiAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
@ -1524,5 +1524,13 @@
"setHomeCustomCollection": "Ўласная калекцыя",
"@setHomeCustomCollection": {},
"settingsThumbnailShowHdrIcon": "Паказаць значок HDR",
"@settingsThumbnailShowHdrIcon": {}
"@settingsThumbnailShowHdrIcon": {},
"videoRepeatActionSetEnd": "Ўсталяваць канец",
"@videoRepeatActionSetEnd": {},
"stopTooltip": "Спыніць",
"@stopTooltip": {},
"videoActionABRepeat": "Паўтарыць ад А да Б",
"@videoActionABRepeat": {},
"videoRepeatActionSetStart": "Ўсталяваць пачатак",
"@videoRepeatActionSetStart": {}
}

View file

@ -63,6 +63,7 @@
"actionRemove": "Remove",
"resetTooltip": "Reset",
"saveTooltip": "Save",
"stopTooltip": "Stop",
"pickTooltip": "Pick",
"doubleBackExitMessage": "Tap “back” again to exit.",
@ -127,6 +128,10 @@
"videoActionSkip10": "Seek forward 10 seconds",
"videoActionSelectStreams": "Select tracks",
"videoActionSetSpeed": "Playback speed",
"videoActionABRepeat": "A-B repeat",
"videoRepeatActionSetStart": "Set start",
"videoRepeatActionSetEnd": "Set end",
"viewerActionSettings": "Settings",
"viewerActionLock": "Lock viewer",

View file

@ -1366,5 +1366,13 @@
"collectionActionSetHome": "Fijar como inicio",
"@collectionActionSetHome": {},
"setHomeCustomCollection": "Colección personalizada",
"@setHomeCustomCollection": {}
"@setHomeCustomCollection": {},
"videoRepeatActionSetStart": "Fijar el inicio",
"@videoRepeatActionSetStart": {},
"stopTooltip": "Parar",
"@stopTooltip": {},
"videoActionABRepeat": "Repetir de A a B",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Fijar el fin",
"@videoRepeatActionSetEnd": {}
}

View file

@ -1366,5 +1366,13 @@
"setHomeCustomCollection": "Collection personnalisée",
"@setHomeCustomCollection": {},
"settingsThumbnailShowHdrIcon": "Afficher licône HDR",
"@settingsThumbnailShowHdrIcon": {}
"@settingsThumbnailShowHdrIcon": {},
"videoRepeatActionSetEnd": "Définir la fin",
"@videoRepeatActionSetEnd": {},
"stopTooltip": "Arrêter",
"@stopTooltip": {},
"videoActionABRepeat": "Lecture répétée A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetStart": "Définir le début",
"@videoRepeatActionSetStart": {}
}

View file

@ -1366,5 +1366,13 @@
"settingsThumbnailShowHdrIcon": "Tampilkan ikon HDR",
"@settingsThumbnailShowHdrIcon": {},
"castDialogTitle": "Siarkan Perangkat",
"@castDialogTitle": {}
"@castDialogTitle": {},
"stopTooltip": "Berhenti",
"@stopTooltip": {},
"videoActionABRepeat": "Ulang A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetStart": "Tetapkan awal",
"@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "Tetapkan akhir",
"@videoRepeatActionSetEnd": {}
}

View file

@ -1235,7 +1235,7 @@
"@lengthUnitPercent": {},
"saveCopyButtonLabel": "コピーを保存",
"@saveCopyButtonLabel": {},
"columnCount": "{count, plural, =1{1 列} other{{count} 列}}",
"columnCount": "{count, plural, other{{count} 列}}",
"@columnCount": {
"placeholders": {
"count": {}
@ -1246,5 +1246,23 @@
"entryActionCast": "キャスト",
"@entryActionCast": {},
"editorTransformRotate": "回転",
"@editorTransformRotate": {}
"@editorTransformRotate": {},
"settingsVideoResumptionModeTile": "前回の位置からの再生",
"@settingsVideoResumptionModeTile": {},
"settingsAskEverytime": "都度選択",
"@settingsAskEverytime": {},
"maxBrightnessNever": "無効",
"@maxBrightnessNever": {},
"settingsVideoResumptionModeDialogTitle": "前回の位置からの再生",
"@settingsVideoResumptionModeDialogTitle": {},
"settingsVideoBackgroundMode": "バックグラウンド再生",
"@settingsVideoBackgroundMode": {},
"settingsVideoGestureVerticalDragBrightnessVolume": "左右で上下にスワイプして輝度と音量を調節",
"@settingsVideoGestureVerticalDragBrightnessVolume": {},
"settingsViewerShowHistogram": "ヒストグラムを表示",
"@settingsViewerShowHistogram": {},
"settingsVideoBackgroundModeDialogTitle": "バックグラウンド再生",
"@settingsVideoBackgroundModeDialogTitle": {},
"maxBrightnessAlways": "常時有効",
"@maxBrightnessAlways": {}
}

View file

@ -1366,5 +1366,13 @@
"collectionActionSetHome": "홈으로 설정",
"@collectionActionSetHome": {},
"setHomeCustomCollection": "지정 미디어",
"@setHomeCustomCollection": {}
"@setHomeCustomCollection": {},
"videoRepeatActionSetStart": "시작 지점 설정",
"@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "종료 지점 설정",
"@videoRepeatActionSetEnd": {},
"stopTooltip": "취소",
"@stopTooltip": {},
"videoActionABRepeat": "A-B 반복",
"@videoActionABRepeat": {}
}

View file

@ -1524,5 +1524,13 @@
"setHomeCustomCollection": "Własna kolekcja",
"@setHomeCustomCollection": {},
"collectionActionSetHome": "Ustaw jako stronę główną",
"@collectionActionSetHome": {}
"@collectionActionSetHome": {},
"videoRepeatActionSetStart": "Ustaw początek",
"@videoRepeatActionSetStart": {},
"stopTooltip": "Zatrzymaj",
"@stopTooltip": {},
"videoActionABRepeat": "Powtarzanie A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Ustaw koniec",
"@videoRepeatActionSetEnd": {}
}

View file

@ -1360,5 +1360,11 @@
"entryActionCast": "Transmitir",
"@entryActionCast": {},
"castDialogTitle": "Dispositivos para Transmitir",
"@castDialogTitle": {}
"@castDialogTitle": {},
"settingsThumbnailShowHdrIcon": "Mostrar ícone HDR",
"@settingsThumbnailShowHdrIcon": {},
"collectionActionSetHome": "Definir como início",
"@collectionActionSetHome": {},
"setHomeCustomCollection": "Coleção personalizada",
"@setHomeCustomCollection": {}
}

View file

@ -1366,5 +1366,13 @@
"setHomeCustomCollection": "Собственная коллекция",
"@setHomeCustomCollection": {},
"collectionActionSetHome": "Установить как главную",
"@collectionActionSetHome": {}
"@collectionActionSetHome": {},
"videoRepeatActionSetStart": "Установить начало",
"@videoRepeatActionSetStart": {},
"stopTooltip": "Остановить",
"@stopTooltip": {},
"videoActionABRepeat": "Повторить от А до Б",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Установить конец",
"@videoRepeatActionSetEnd": {}
}

View file

@ -1524,5 +1524,13 @@
"setHomeCustomCollection": "Власна колекція",
"@setHomeCustomCollection": {},
"collectionActionSetHome": "Встановити як головну",
"@collectionActionSetHome": {}
"@collectionActionSetHome": {},
"videoRepeatActionSetStart": "Змінити початок",
"@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "Змінити кінець",
"@videoRepeatActionSetEnd": {},
"stopTooltip": "Зупинити",
"@stopTooltip": {},
"videoActionABRepeat": "Повторити від А до Б",
"@videoActionABRepeat": {}
}

View file

@ -79,6 +79,7 @@ class Contributors {
Contributor('luckris25', 'lk1thebestl@gmail.com'),
Contributor('Marc Amorós', 'marquitus99@gmail.com'),
Contributor('elea11', 'p.manuel.warnecke@gmail.com'),
Contributor('しいたけ', 'Shiitake@users.noreply.hosted.weblate.org'),
// Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali
// Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese
// Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese

View file

@ -33,8 +33,8 @@ class LiveAvesAvailability implements AvesAvailability {
return _isConnected!;
}
void _updateConnectivityFromResult(ConnectivityResult result) {
final newValue = result != ConnectivityResult.none;
void _updateConnectivityFromResult(List<ConnectivityResult> result) {
final newValue = result.isNotEmpty && !result.contains(ConnectivityResult.none);
if (_isConnected != newValue) {
_isConnected = newValue;
debugPrint('Device is connected=$_isConnected');

View file

@ -7,6 +7,7 @@ import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/ref/metadata/exif.dart';
import 'package:aves/ref/metadata/iptc.dart';
import 'package:aves/ref/metadata/xmp.dart';
@ -121,7 +122,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
if (latLng != null && latLng != removalLocation) {
final latitude = latLng.latitude;
final longitude = latLng.longitude;
const locale = 'en_US';
const locale = asciiLocale;
final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}';
final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}';
iso6709String = '$isoLat$isoLon/';

View file

@ -1,4 +1,8 @@
import 'package:aves/convert/metadata/fields.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
@ -16,6 +20,7 @@ class NamingPattern {
factory NamingPattern.from({
required String userPattern,
required int entryCount,
required String locale,
}) {
final processors = <NamingProcessor>[];
@ -36,7 +41,13 @@ class NamingPattern {
switch (processorKey) {
case DateNamingProcessor.key:
if (processorOptions != null) {
processors.add(DateNamingProcessor(processorOptions.trim()));
processors.add(DateNamingProcessor(processorOptions.trim(), locale));
}
case TagsNamingProcessor.key:
processors.add(TagsNamingProcessor(processorOptions?.trim() ?? ''));
case MetadataFieldNamingProcessor.key:
if (processorOptions != null) {
processors.add(MetadataFieldNamingProcessor(processorOptions.trim()));
}
case NameNamingProcessor.key:
processors.add(const NameNamingProcessor());
@ -95,21 +106,33 @@ class NamingPattern {
switch (processorKey) {
case DateNamingProcessor.key:
return '<$processorKey, yyyyMMdd-HHmmss>';
case TagsNamingProcessor.key:
return '<$processorKey, ->';
case CounterNamingProcessor.key:
case NameNamingProcessor.key:
default:
if (processorKey.startsWith(MetadataFieldNamingProcessor.key)) {
final field = MetadataFieldNamingProcessor.fieldFromKey(processorKey);
return '<${MetadataFieldNamingProcessor.key}, $field>';
}
return '<$processorKey>';
}
}
String apply(AvesEntry entry, int index) => processors.map((v) => v.process(entry, index) ?? '').join().trimLeft();
Future<String> apply(AvesEntry entry, int index) async {
final fields = processors.expand((v) => v.getRequiredFields()).toSet();
final fieldValues = await metadataFetchService.getFields(entry, fields);
return processors.map((v) => v.process(entry, index, fieldValues) ?? '').join().trim();
}
}
@immutable
abstract class NamingProcessor extends Equatable {
const NamingProcessor();
String? process(AvesEntry entry, int index);
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues);
Set<MetadataField> getRequiredFields() => {};
}
@immutable
@ -122,7 +145,7 @@ class LiteralNamingProcessor extends NamingProcessor {
const LiteralNamingProcessor(this.text);
@override
String? process(AvesEntry entry, int index) => text;
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) => text;
}
@immutable
@ -134,15 +157,63 @@ class DateNamingProcessor extends NamingProcessor {
@override
List<Object?> get props => [format.pattern];
DateNamingProcessor(String pattern) : format = DateFormat(pattern);
DateNamingProcessor(String pattern, String locale) : format = DateFormat(pattern, locale);
@override
String? process(AvesEntry entry, int index) {
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) {
final date = entry.bestDate;
return date != null ? format.format(date) : null;
}
}
@immutable
class TagsNamingProcessor extends NamingProcessor {
static const key = 'tags';
static const defaultSeparator = ' ';
final String separator;
@override
List<Object?> get props => [separator];
TagsNamingProcessor(String separator) : separator = separator.isEmpty ? defaultSeparator : separator;
@override
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) {
return entry.tags.join(separator);
}
}
@immutable
class MetadataFieldNamingProcessor extends NamingProcessor {
static const key = 'field';
static String keyWithField(MetadataField field) => '$key-${field.name}';
// loose, for user to see and later parse
static String fieldFromKey(String keyWithField) => keyWithField.substring(key.length + 1);
late final MetadataField? field;
@override
List<Object?> get props => [field];
MetadataFieldNamingProcessor(String field) {
final lowerField = field.toLowerCase();
this.field = MetadataField.values.firstWhereOrNull((v) => v.name.toLowerCase() == lowerField);
}
@override
Set<MetadataField> getRequiredFields() {
return {field}.whereNotNull().toSet();
}
@override
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) {
return fieldValues[field?.toPlatform]?.toString();
}
}
@immutable
class NameNamingProcessor extends NamingProcessor {
static const key = 'name';
@ -153,7 +224,7 @@ class NameNamingProcessor extends NamingProcessor {
const NameNamingProcessor();
@override
String? process(AvesEntry entry, int index) => entry.filenameWithoutExtension;
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) => entry.filenameWithoutExtension;
}
@immutable
@ -174,5 +245,5 @@ class CounterNamingProcessor extends NamingProcessor {
});
@override
String? process(AvesEntry entry, int index) => '${index + start}'.padLeft(padding, '0');
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) => '${index + start}'.padLeft(padding, '0');
}

View file

@ -50,7 +50,7 @@ mixin AppSettings on SettingsAccess {
].join(localeSeparator);
}
set(SettingKeys.localeKey, tag);
_appliedLocale = null;
resetAppliedLocale();
}
List<Locale> _systemLocalesFallback = [];

View file

@ -1,10 +1,9 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/defaults.dart';
import 'package:aves/model/settings/modules/search.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
mixin FilterGridsSettings on SettingsAccess, SearchSettings {
mixin FilterGridsSettings on SettingsAccess {
AlbumChipGroupFactor get albumGroupFactor => getEnumOrDefault(SettingKeys.albumGroupFactorKey, SettingsDefaults.albumGroupFactor, AlbumChipGroupFactor.values);
set albumGroupFactor(AlbumChipGroupFactor newValue) => set(SettingKeys.albumGroupFactorKey, newValue.toString());
@ -53,21 +52,6 @@ mixin FilterGridsSettings on SettingsAccess, SearchSettings {
set pinnedFilters(Set<CollectionFilter> newValue) => set(SettingKeys.pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
Set<CollectionFilter> get hiddenFilters => (getStringList(SettingKeys.hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
set hiddenFilters(Set<CollectionFilter> newValue) => set(SettingKeys.hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final _hiddenFilters = hiddenFilters;
if (visible) {
_hiddenFilters.removeAll(filters);
} else {
_hiddenFilters.addAll(filters);
searchHistory = searchHistory..removeWhere(filters.contains);
}
hiddenFilters = _hiddenFilters;
}
bool get showAlbumPickQuery => getBool(SettingKeys.showAlbumPickQueryKey) ?? false;
set showAlbumPickQuery(bool newValue) => set(SettingKeys.showAlbumPickQueryKey, newValue);

View file

@ -0,0 +1,42 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/modules/search.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
mixin PrivacySettings on SettingsAccess, SearchSettings {
Set<CollectionFilter> get hiddenFilters => (getStringList(SettingKeys.hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
set hiddenFilters(Set<CollectionFilter> newValue) => set(SettingKeys.hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final _hiddenFilters = hiddenFilters;
if (visible) {
_hiddenFilters.removeAll(filters);
} else {
_hiddenFilters.addAll(filters);
searchHistory = searchHistory..removeWhere(filters.contains);
final _deactivatedHiddenFilters = deactivatedHiddenFilters;
_deactivatedHiddenFilters.removeAll(filters);
deactivatedHiddenFilters = _deactivatedHiddenFilters;
}
hiddenFilters = _hiddenFilters;
}
Set<CollectionFilter> get deactivatedHiddenFilters => (getStringList(SettingKeys.deactivatedHiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
set deactivatedHiddenFilters(Set<CollectionFilter> newValue) => set(SettingKeys.deactivatedHiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
void activateHiddenFilter(CollectionFilter filter, bool active) {
final _deactivatedHiddenFilters = deactivatedHiddenFilters;
if (active) {
_deactivatedHiddenFilters.remove(filter);
} else {
_deactivatedHiddenFilters.add(filter);
}
deactivatedHiddenFilters = _deactivatedHiddenFilters;
final visible = !active;
changeFilterVisibility({filter}, visible);
}
}

View file

@ -15,6 +15,7 @@ import 'package:aves/model/settings/modules/display.dart';
import 'package:aves/model/settings/modules/filter_grids.dart';
import 'package:aves/model/settings/modules/info.dart';
import 'package:aves/model/settings/modules/navigation.dart';
import 'package:aves/model/settings/modules/privacy.dart';
import 'package:aves/model/settings/modules/search.dart';
import 'package:aves/model/settings/modules/viewer.dart';
import 'package:aves/ref/bursts.dart';
@ -37,7 +38,7 @@ import 'package:latlong2/latlong.dart';
final Settings settings = Settings._private();
class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings, NavigationSettings, SearchSettings, CollectionSettings, FilterGridsSettings, ViewerSettings, VideoSettings, SubtitlesSettings, InfoSettings {
class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings, NavigationSettings, SearchSettings, CollectionSettings, FilterGridsSettings, PrivacySettings, ViewerSettings, VideoSettings, SubtitlesSettings, InfoSettings {
final List<StreamSubscription> _subscriptions = [];
final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
@ -83,7 +84,8 @@ class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings
enableBlurEffect = performanceClass >= 29;
final androidInfo = await DeviceInfoPlugin().androidInfo;
final pattern = BurstPatterns.byManufacturer[androidInfo.manufacturer];
final manufacturer = androidInfo.manufacturer.toLowerCase();
final pattern = BurstPatterns.byManufacturer[manufacturer];
collectionBurstPatterns = pattern != null ? [pattern] : [];
// availability

View file

@ -196,19 +196,19 @@ mixin AlbumMixin on SourceBase {
final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) {
final name = pContext.joinAll(['', ...parts.skip(i)]);
final testName = '$separator$name';
final testName = '$separator${name.toLowerCase()}';
if (others.every((item) => !item.endsWith(testName))) return name;
}
return dirPath;
}
final otherAlbumsOnDevice = _directories.whereNotNull().where((item) => item != dirPath).toSet();
final otherAlbumsOnDevice = _directories.whereNotNull().where((item) => item != dirPath).map((v) => v.toLowerCase()).toSet();
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
if (uniqueNameInDevice.length <= relativeDir.length) {
return uniqueNameInDevice;
}
final volumePath = dir.volumePath;
final volumePath = dir.volumePath.toLowerCase();
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet();
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);

View file

@ -8,6 +8,7 @@ import 'package:aves/model/video/profiles/aac.dart';
import 'package:aves/model/video/profiles/h264.dart';
import 'package:aves/model/video/profiles/hevc.dart';
import 'package:aves/ref/languages.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/mp4.dart';
import 'package:aves/services/common/services.dart';
@ -453,7 +454,7 @@ class VideoMetadataFormatter {
static String _formatFilesize(dynamic value) {
final size = value is int ? value : int.tryParse(value);
return size != null ? formatFileSize('en_US', size) : value;
return size != null ? formatFileSize(asciiLocale, size) : value;
}
static String _formatLanguage(String value) {

View file

@ -1,16 +1,19 @@
class BurstPatterns {
static const _keyGroupName = 'key';
static const fairphone = r'^IMG_(?<key>\d{8}_\d{6})_BURST(\d+)$';
static const samsung = r'^(?<key>\d{8}_\d{6})_(\d+)$';
static const sony = r'^DSC(PDC)?_\d+_BURST(?<key>\d{17})(_COVER)?$';
static final options = [
BurstPatterns.fairphone,
BurstPatterns.samsung,
BurstPatterns.sony,
];
static String getName(String pattern) {
return switch (pattern) {
fairphone => 'Fairphone',
samsung => 'Samsung',
sony => 'Sony',
_ => pattern,
@ -19,6 +22,7 @@ class BurstPatterns {
static String getExample(String pattern) {
return switch (pattern) {
fairphone => 'IMG_20151021_072800_BURST007',
samsung => '20151021_072800_007',
sony => 'DSC_0007_BURST20151021072800123',
_ => '?',
@ -26,6 +30,7 @@ class BurstPatterns {
}
static const byManufacturer = {
_Manufacturers.fairphone: fairphone,
_Manufacturers.samsung: samsung,
_Manufacturers.sony: sony,
};
@ -47,8 +52,9 @@ class BurstPatterns {
}
}
// values as returned by `DeviceInfoPlugin().androidInfo`
// values as returned by `DeviceInfoPlugin().androidInfo` (lower-cased)
class _Manufacturers {
static const fairphone = 'fairphone';
static const samsung = 'samsung';
static const sony = 'sony';
}

47
lib/ref/locales.dart Normal file
View file

@ -0,0 +1,47 @@
import 'dart:ui';
const String asciiLocale = 'en_US';
// cf https://en.wikipedia.org/wiki/Eastern_Arabic_numerals
bool shouldUseNativeDigits(Locale? countrifiedLocale) {
switch (countrifiedLocale?.toString()) {
// Maghreb
case 'ar_DZ': // Algeria
case 'ar_EH': // Western Sahara
case 'ar_LY': // Libya
case 'ar_MA': // Morocco
case 'ar_MR': // Mauritania
case 'ar_TN': // Tunisia
return false;
// Mashriq
case 'ar_AE': // United Arab Emirates
case 'ar_BH': // Bahrain
case 'ar_EG': // Egypt
case 'ar_IQ': // Iraq
case 'ar_JO': // Jordan
case 'ar_KW': // Kuwait
case 'ar_LB': // Lebanon
case 'ar_OM': // Oman
case 'ar_PS': // Palestinian Territories
case 'ar_QA': // Qatar
case 'ar_SA': // Saudi Arabia
case 'ar_SD': // Sudan
case 'ar_SS': // South Sudan
case 'ar_SY': // Syria
case 'ar_YE': // Yemen
return true;
// Horn of Africa
case 'ar_DJ': // Djibouti
case 'ar_ER': // Eritrea
case 'ar_KM': // Comoros
case 'ar_SO': // Somalia
return true;
// others
case 'ar_IL': // Israel
case 'ar_TD': // Chad
return true;
case null:
default:
return true;
}
}

View file

@ -76,7 +76,7 @@ Future<void> _init() async {
enum AnalyzerState { running, stopping, stopped }
class Analyzer {
class Analyzer with WidgetsBindingObserver {
late AppLocalizations _l10n;
final ValueNotifier<AnalyzerState> _serviceStateNotifier = ValueNotifier<AnalyzerState>(AnalyzerState.stopped);
AnalysisController? _controller;
@ -102,6 +102,7 @@ class Analyzer {
}
_serviceStateNotifier.addListener(_onServiceStateChanged);
_source.stateNotifier.addListener(_onSourceStateChanged);
WidgetsBinding.instance.addObserver(this);
}
void dispose() {
@ -111,11 +112,18 @@ class Analyzer {
}
_stopUpdateTimer();
_controller?.dispose();
WidgetsBinding.instance.removeObserver(this);
_serviceStateNotifier.removeListener(_onServiceStateChanged);
_source.stateNotifier.removeListener(_onSourceStateChanged);
_source.dispose();
}
@override
void didHaveMemoryPressure() {
super.didHaveMemoryPressure();
reportService.log('Analyzer memory pressure');
}
Future<void> start(dynamic args) async {
List<int>? entryIds;
var force = false;
@ -126,7 +134,7 @@ class Analyzer {
progressTotal = args['progressTotal'];
progressOffset = args['progressOffset'];
}
debugPrint('$runtimeType start for ${entryIds?.length ?? 'all'} entries, at $progressOffset/$progressTotal');
await reportService.log('Analyzer start for ${entryIds?.length ?? 'all'} entries, at $progressOffset/$progressTotal');
_controller?.dispose();
_controller = AnalysisController(
canStartService: false,
@ -147,8 +155,8 @@ class Analyzer {
});
}
void stop() {
debugPrint('$runtimeType stop');
Future<void> stop() async {
await reportService.log('Analyzer stop');
_serviceStateNotifier.value = AnalyzerState.stopped;
}

View file

@ -22,7 +22,7 @@ abstract class MetadataFetchService {
Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry, {bool background = false});
Future<OverlayMetadata> getFields(AvesEntry entry, Set<MetadataSyntheticField> fields);
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry, Set<MetadataSyntheticField> fields);
Future<GeoTiffInfo?> getGeoTiffInfo(AvesEntry entry);
@ -39,6 +39,8 @@ abstract class MetadataFetchService {
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
Future<DateTime?> getDate(AvesEntry entry, MetadataField field);
Future<Map<String, dynamic>> getFields(AvesEntry entry, Set<MetadataField> fields);
}
class PlatformMetadataFetchService implements MetadataFetchService {
@ -110,7 +112,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}
@override
Future<OverlayMetadata> getFields(AvesEntry entry, Set<MetadataSyntheticField> fields) async {
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry, Set<MetadataSyntheticField> fields) async {
if (fields.isNotEmpty && !entry.isSvg) {
try {
// returns fields on demand, with various value types:
@ -119,7 +121,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
// 'exposureTime' (string),
// 'focalLength' (double),
// 'iso' (int),
final result = await _platform.invokeMethod('getFields', <String, dynamic>{
final result = await _platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
@ -284,4 +286,24 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}
return null;
}
@override
Future<Map<String, dynamic>> getFields(AvesEntry entry, Set<MetadataField> fields) async {
if (fields.isNotEmpty && !entry.isSvg) {
try {
final result = await _platform.invokeMethod('getFields', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'fields': fields.map((v) => v.toPlatform).toList(),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (entry.isValid) {
await reportService.recordError(e, stack);
}
}
}
return {};
}
}

View file

@ -105,6 +105,7 @@ class AIcons {
static const info = Icons.info_outlined;
static const layers = Icons.layers_outlined;
static const map = Icons.map_outlined;
static const more = Icons.more_horiz_outlined;
static final move = MdiIcons.fileMoveOutline;
static const mute = Icons.volume_off_outlined;
static const unmute = Icons.volume_up_outlined;
@ -118,7 +119,10 @@ class AIcons {
static const pause = Icons.pause;
static const print = Icons.print_outlined;
static const refresh = Icons.refresh_outlined;
static const repeat = Icons.repeat_outlined;
static final repeatOff = MdiIcons.repeatOff;
static const replay10 = Icons.replay_10_outlined;
static final resetBounds = MdiIcons.rayStartEnd;
static const reverse = Icons.invert_colors_outlined;
static const skip10 = Icons.forward_10_outlined;
static const reset = Icons.restart_alt_outlined;
@ -130,6 +134,8 @@ class AIcons {
static const select = Icons.select_all_outlined;
static const setAs = Icons.wallpaper_outlined;
static final setCover = MdiIcons.imageEditOutline;
static final setEnd = MdiIcons.rayEnd;
static final setStart = MdiIcons.rayStart;
static const share = Icons.share_outlined;
static const show = Icons.visibility_outlined;
static final showFullscreen = MdiIcons.arrowExpand;

View file

@ -1,3 +1,4 @@
import 'package:aves/ref/locales.dart';
import 'package:aves/ref/metadata/xmp.dart';
import 'package:intl/intl.dart';
import 'package:xml/xml.dart';
@ -60,7 +61,7 @@ class XMP {
return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
}
static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}';
static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss', asciiLocale).format(date)}${_xmpTimeZoneDesignator(date)}';
static String? getString(
List<XmlNode> nodes,

View file

@ -36,6 +36,7 @@ extension ExtraEntryActionView on EntryAction {
l10n.videoActionMute,
EntryAction.videoSelectStreams => l10n.videoActionSelectStreams,
EntryAction.videoSetSpeed => l10n.videoActionSetSpeed,
EntryAction.videoABRepeat => l10n.videoActionABRepeat,
EntryAction.videoSettings => l10n.viewerActionSettings,
EntryAction.videoTogglePlay =>
// different data depending on toggle state
@ -110,6 +111,7 @@ extension ExtraEntryActionView on EntryAction {
AIcons.mute,
EntryAction.videoSelectStreams => AIcons.streams,
EntryAction.videoSetSpeed => AIcons.speed,
EntryAction.videoABRepeat => AIcons.repeat,
EntryAction.videoSettings => AIcons.videoSettings,
EntryAction.videoTogglePlay =>
// different data depending on toggle state

View file

@ -11,6 +11,10 @@ extension ExtraMetadataFieldView on MetadataField {
return 'Exif digitized date';
case MetadataField.exifGpsDatestamp:
return 'Exif GPS date';
case MetadataField.exifMake:
return 'Exif make';
case MetadataField.exifModel:
return 'Exif model';
case MetadataField.xmpXmpCreateDate:
return 'XMP xmp:CreateDate';
default:

View file

@ -6,6 +6,7 @@ import 'package:aves/app_flavor.dart';
import 'package:aves/flutter_version.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart';
@ -176,7 +177,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
final result = await Process.run('logcat', ['-d']);
final logs = result.stdout;
final success = await storageService.createFile(
'aves-logs-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.txt',
'aves-logs-${DateFormat('yyyyMMdd_HHmmss', asciiLocale).format(DateTime.now())}.txt',
MimeTypes.plainText,
Uint8List.fromList(utf8.encode(logs)),
);

View file

@ -16,6 +16,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart';
@ -37,12 +38,14 @@ import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:collection/collection.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localization_nn/flutter_localization_nn.dart';
import 'package:intl/intl.dart';
import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart';
import 'package:screen_brightness/screen_brightness.dart';
@ -158,6 +161,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final ValueNotifier<PageTransitionsBuilder> _pageTransitionsBuilderNotifier = ValueNotifier(defaultPageTransitionsBuilder);
final ValueNotifier<TvMediaQueryModifier?> _tvMediaQueryModifierNotifier = ValueNotifier(null);
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.main);
final ValueNotifier<LocaleOverrides> _localeOverridesNotifier = ValueNotifier(LocaleOverrides.none);
// observers are not registered when using the same list object with different items
// the list itself needs to be reassigned
@ -217,6 +221,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
Provider<TvRailController>.value(value: _tvRailController),
DurationsProvider(),
HighlightInfoProvider(),
ListenableProvider<ValueNotifier<LocaleOverrides>>.value(value: _localeOverridesNotifier),
],
child: OverlaySupport(
child: FutureBuilder<void>(
@ -239,9 +244,6 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
),
builder: (context, s, child) {
final (settingsLocale, themeBrightness, enableDynamicColor) = s;
AStyles.updateStylesForLocale(settings.appliedLocale);
return DynamicColorBuilder(
builder: (lightScheme, darkScheme) {
const defaultAccent = AvesColorsData.defaultAccent;
@ -402,6 +404,12 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
}
}
@override
void didHaveMemoryPressure() {
super.didHaveMemoryPressure();
reportService.log('App memory pressure');
}
@override
void didChangeMetrics() => _updateCutoutInsets();
@ -411,6 +419,33 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
}
}
@override
void didChangeLocales(List<Locale>? locales) {
_applyLocale();
}
void _applyLocale() {
settings.resetAppliedLocale();
final appliedLocale = settings.appliedLocale;
AStyles.updateStylesForLocale(appliedLocale);
Locale? countrifiedLocale;
if (appliedLocale.countryCode == null) {
final languageCode = appliedLocale.languageCode;
countrifiedLocale = WidgetsBinding.instance.platformDispatcher.locales.firstWhereOrNull((v) => v.languageCode == languageCode);
}
if (appliedLocale.languageCode == 'ar') {
final useNativeDigits = shouldUseNativeDigits(countrifiedLocale);
DateFormat.useNativeDigitsByDefaultFor(appliedLocale.toString(), useNativeDigits);
DateFormat.useNativeDigitsByDefaultFor(countrifiedLocale.toString(), useNativeDigits);
}
_localeOverridesNotifier.value = LocaleOverrides(
countrifiedLocale: countrifiedLocale,
);
}
Widget _getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
Size? _getScreenSize(BuildContext context) {
@ -504,7 +539,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
}
void _monitorSettings() {
void applyIsInstalledAppAccessAllowed() {
void _applyIsInstalledAppAccessAllowed() {
if (settings.isInstalledAppAccessAllowed) {
appInventory.initAppNames();
} else {
@ -512,9 +547,9 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
}
}
void applyDisplayRefreshRateMode() => settings.displayRefreshRateMode.apply();
void _applyDisplayRefreshRateMode() => settings.displayRefreshRateMode.apply();
void applyMaxBrightness() {
void _applyMaxBrightness() {
switch (settings.maxBrightness) {
case MaxBrightness.never:
case MaxBrightness.viewerOnly:
@ -524,9 +559,9 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
}
}
void applyKeepScreenOn() => settings.keepScreenOn.apply();
void _applyKeepScreenOn() => settings.keepScreenOn.apply();
void applyIsRotationLocked() {
void _applyIsRotationLocked() {
if (!settings.isRotationLocked && !settings.useTvLayout) {
windowService.requestOrientation();
}
@ -545,20 +580,22 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final settingStream = settings.updateStream;
// app
settingStream.where((event) => event.key == SettingKeys.isInstalledAppAccessAllowedKey).listen((_) => applyIsInstalledAppAccessAllowed());
settingStream.where((event) => event.key == SettingKeys.isInstalledAppAccessAllowedKey).listen((_) => _applyIsInstalledAppAccessAllowed());
settingStream.where((event) => event.key == SettingKeys.localeKey).listen((_) => _applyLocale());
// display
settingStream.where((event) => event.key == SettingKeys.displayRefreshRateModeKey).listen((_) => applyDisplayRefreshRateMode());
settingStream.where((event) => event.key == SettingKeys.maxBrightnessKey).listen((_) => applyMaxBrightness());
settingStream.where((event) => event.key == SettingKeys.displayRefreshRateModeKey).listen((_) => _applyDisplayRefreshRateMode());
settingStream.where((event) => event.key == SettingKeys.maxBrightnessKey).listen((_) => _applyMaxBrightness());
settingStream.where((event) => event.key == SettingKeys.forceTvLayoutKey).listen((_) => applyForceTvLayout());
// navigation
settingStream.where((event) => event.key == SettingKeys.keepScreenOnKey).listen((_) => applyKeepScreenOn());
settingStream.where((event) => event.key == SettingKeys.keepScreenOnKey).listen((_) => _applyKeepScreenOn());
// platform settings
settingStream.where((event) => event.key == SettingKeys.platformAccelerometerRotationKey).listen((_) => applyIsRotationLocked());
settingStream.where((event) => event.key == SettingKeys.platformAccelerometerRotationKey).listen((_) => _applyIsRotationLocked());
applyDisplayRefreshRateMode();
applyMaxBrightness();
applyKeepScreenOn();
applyIsRotationLocked();
_applyLocale();
_applyDisplayRefreshRateMode();
_applyMaxBrightness();
_applyKeepScreenOn();
_applyIsRotationLocked();
}
Future<void> _setupErrorReporting() async {
@ -632,3 +669,18 @@ class AvesScrollBehavior extends MaterialScrollBehavior {
}
typedef TvMediaQueryModifier = MediaQueryData Function(MediaQueryData);
class LocaleOverrides extends Equatable {
final Locale? countrifiedLocale;
@override
List<Object?> get props => [countrifiedLocale];
const LocaleOverrides({
required this.countrifiedLocale,
});
static const LocaleOverrides none = LocaleOverrides(
countrifiedLocale: null,
);
}

View file

@ -672,8 +672,8 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
final newest = firstKey.date;
final oldest = lastKey.date;
if (newest != null && oldest != null) {
final localeName = context.l10n.localeName;
final dateFormat = (newest.difference(oldest).inDays).abs() > 365 ? DateFormat.y(localeName) : DateFormat.MMM(localeName);
final locale = context.l10n.localeName;
final dateFormat = (newest.difference(oldest).inDays).abs() > 365 ? DateFormat.y(locale) : DateFormat.MMM(locale);
String? lastLabel;
sectionLayouts.forEach((section) {
final date = (section.sectionKey as EntryDateSectionKey).date;

View file

@ -356,10 +356,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
);
if (pattern == null) return;
final entriesToNewName = Map.fromEntries(entries.mapIndexed((index, entry) {
final newName = pattern.apply(entry, index);
final namingFutures = entries.mapIndexed((index, entry) async {
final newName = await pattern.apply(entry, index);
return MapEntry(entry, '$newName${entry.extension}');
})).whereNotNullValue();
});
final entriesToNewName = Map.fromEntries(await Future.wait(namingFutures)).whereNotNullValue();
await rename(context, entriesToNewName: entriesToNewName, persist: true);
_browse(context);
@ -555,15 +556,16 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
Future<void> removeLocation(BuildContext context, Set<AvesEntry> entries) async {
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AvesDialog(
content: Text(context.l10n.genericDangerWarningDialogMessage),
content: Text(l10n.genericDangerWarningDialogMessage),
actions: [
const CancelButton(),
TextButton(
onPressed: () => Navigator.maybeOf(context)?.pop(true),
child: Text(context.l10n.applyButtonLabel),
child: Text(l10n.applyButtonLabel),
),
],
),

View file

@ -55,7 +55,7 @@ mixin EntryEditorMixin {
final entry = entries.first;
final initialTitle = entry.catalogMetadata?.xmpTitle ?? '';
final fields = await metadataFetchService.getFields(entry, {MetadataSyntheticField.description});
final fields = await metadataFetchService.getOverlayMetadata(entry, {MetadataSyntheticField.description});
final initialDescription = fields.description ?? '';
return showDialog<Map<DescriptionField, String?>>(

View file

@ -11,8 +11,8 @@ import 'package:aves/widgets/common/action_mixins/overlay_snack_bar.dart';
import 'package:aves/widgets/common/basic/circle.dart';
import 'package:aves/widgets/common/basic/text/change_highlight.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/extensions/theme.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:overlay_support/overlay_support.dart';
@ -22,6 +22,13 @@ import 'package:provider/provider.dart';
enum FeedbackType { info, warn }
mixin FeedbackMixin {
static final ValueNotifier<EdgeInsets?> snackBarMarginOverrideNotifier = ValueNotifier(null);
static EdgeInsets snackBarMarginDefault(BuildContext context) {
final mq = context.read<MediaQueryData>();
return EdgeInsets.only(bottom: max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom));
}
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
void showFeedback(BuildContext context, FeedbackType type, String message, [SnackBarAction? action]) {
@ -53,10 +60,7 @@ mixin FeedbackMixin {
stop: action != null ? start.add(duration) : null,
);
if (context.currentRouteName == EntryViewerPage.routeName) {
// avoid interactive widgets at the bottom of the page
final margin = EntryViewerPage.snackBarMargin(context);
if (snackBarMarginOverrideNotifier.value != null) {
// as of Flutter v2.10.4, `SnackBar` can only be positioned at the bottom,
// and space under the snack bar `margin` does not receive gestures
// (because it is used by the `Dismissible` wrapping the snack bar)
@ -65,8 +69,15 @@ mixin FeedbackMixin {
notificationOverlayEntry = showOverlayNotification(
(context) => SafeArea(
bottom: false,
child: Padding(
padding: margin,
child: ValueListenableBuilder<EdgeInsets?>(
valueListenable: snackBarMarginOverrideNotifier,
builder: (context, margin, child) {
return AnimatedPadding(
padding: margin ?? snackBarMarginDefault(context),
duration: ADurations.pageTransitionAnimation,
child: child,
);
},
child: OverlaySnackBar(
content: snackBarContent,
action: action != null
@ -346,6 +357,7 @@ class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerPro
final contentTextFontSize = contentTextStyle.fontSize ?? theme.textTheme.bodyMedium!.fontSize!;
final timerChangeShadowColor = colorScheme.primary;
final remainingDurationAnimation = _remainingDurationMillis;
return Row(
children: [
if (widget.type == FeedbackType.warn) ...[
@ -356,16 +368,17 @@ class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerPro
const SizedBox(width: 8),
],
Expanded(child: Text(widget.message)),
if (_remainingDurationMillis != null) ...[
if (remainingDurationAnimation != null) ...[
const SizedBox(width: 16),
AnimatedBuilder(
animation: _remainingDurationMillis!,
animation: remainingDurationAnimation,
builder: (context, child) {
final remainingDurationMillis = _remainingDurationMillis!.value;
final remainingDurationMillis = remainingDurationAnimation.value;
final totalDurationMillis = _totalDurationMillis;
return CircularIndicator(
radius: 16,
lineWidth: 2,
percent: remainingDurationMillis / _totalDurationMillis!,
percent: totalDurationMillis != null && totalDurationMillis > 0 ? remainingDurationMillis / totalDurationMillis : 0,
background: Colors.grey,
// progress color is provided by the caller,
// because we cannot use the app context theme here

View file

@ -1,3 +1,4 @@
import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@ -26,6 +27,7 @@ class AvesPopScope extends StatelessWidget {
Navigator.maybeOf(context)?.pop();
} else {
// exit
reportService.log('Exit by pop');
SystemNavigator.pop();
}
}

View file

@ -1,3 +1,4 @@
import 'package:aves/ref/locales.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
@ -15,6 +16,8 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
Widget build(BuildContext context) {
super.build(context);
final currentSizeBytes = formatFileSize(asciiLocale, imageCache.currentSizeBytes);
final maxSizeBytes = formatFileSize(asciiLocale, imageCache.maximumSizeBytes);
return AvesExpansionTile(
title: 'Cache',
children: [
@ -25,7 +28,7 @@ class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKee
Row(
children: [
Expanded(
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFileSize('en_US', imageCache.currentSizeBytes)}/${formatFileSize('en_US', imageCache.maximumSizeBytes)}'),
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t$currentSizeBytes/$maxSizeBytes'),
),
const SizedBox(width: 8),
ElevatedButton(

View file

@ -7,6 +7,7 @@ import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/vaults/details.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/model/video_playback.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
@ -65,7 +66,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
return Row(
children: [
Expanded(
child: Text('DB file size: ${formatFileSize('en_US', snapshot.data!)}'),
child: Text('DB file size: ${formatFileSize(asciiLocale, snapshot.data!)}'),
),
const SizedBox(width: 8),
ElevatedButton(

View file

@ -69,6 +69,7 @@ class _DebugSettingsSectionState extends State<DebugSettingsSection> with Automa
'drawerPageBookmarks': toMultiline(settings.drawerPageBookmarks),
'pinnedFilters': toMultiline(settings.pinnedFilters),
'hiddenFilters': toMultiline(settings.hiddenFilters),
'deactivatedHiddenFilters': toMultiline(settings.deactivatedHiddenFilters),
'searchHistory': toMultiline(settings.searchHistory),
'recentDestinationAlbums': toMultiline(settings.recentDestinationAlbums),
'recentTags': toMultiline(settings.recentTags),

View file

@ -1,3 +1,4 @@
import 'package:aves/ref/locales.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart';
@ -47,7 +48,7 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': v.state,
if (freeSpace != null) 'freeSpace': formatFileSize('en_US', freeSpace),
if (freeSpace != null) 'freeSpace': formatFileSize(asciiLocale, freeSpace),
},
),
),

View file

@ -6,8 +6,10 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/styles.dart';
import 'package:aves/view/src/metadata/fields.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/font_size_icon_theme.dart';
import 'package:aves/widgets/common/basic/popup/expansion_panel.dart';
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
import 'package:aves/widgets/common/basic/scaffold.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -36,6 +38,7 @@ class RenameEntrySetPage extends StatefulWidget {
class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
final TextEditingController _patternTextController = TextEditingController();
final ValueNotifier<NamingPattern> _namingPatternNotifier = ValueNotifier<NamingPattern>(const NamingPattern([]));
late final String locale;
static const int previewMax = 10;
static const double thumbnailExtent = 48;
@ -49,7 +52,11 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
super.initState();
_patternTextController.text = settings.entryRenamingPattern;
_patternTextController.addListener(_onUserPatternChanged);
_onUserPatternChanged();
WidgetsBinding.instance.addPostFrameCallback((_) {
locale = context.l10n.localeName;
_onUserPatternChanged();
});
}
@override
@ -91,10 +98,6 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
child: PopupMenuButton<String>(
itemBuilder: (context) {
return [
PopupMenuItem(
value: DateNamingProcessor.key,
child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)),
),
PopupMenuItem(
value: NameNamingProcessor.key,
child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)),
@ -103,6 +106,28 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
value: CounterNamingProcessor.key,
child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)),
),
PopupMenuItem(
value: DateNamingProcessor.key,
child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)),
),
PopupMenuItem(
value: TagsNamingProcessor.key,
child: MenuRow(text: l10n.tagPageTitle, icon: const Icon(AIcons.tag)),
),
PopupMenuExpansionPanel<String>(
value: MetadataFieldNamingProcessor.key,
icon: AIcons.more,
title: MaterialLocalizations.of(context).moreButtonTooltip,
items: [
MetadataField.exifMake,
MetadataField.exifModel,
]
.map((field) => PopupMenuItem(
value: MetadataFieldNamingProcessor.keyWithField(field),
child: MenuRow(text: field.title),
))
.toList(),
),
];
},
onSelected: (key) async {
@ -159,11 +184,17 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
ValueListenableBuilder<NamingPattern>(
valueListenable: _namingPatternNotifier,
builder: (context, pattern, child) {
return Text(
pattern.apply(entry, index),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
return FutureBuilder<String>(
future: pattern.apply(entry, index),
builder: (context, snapshot) {
final info = snapshot.data;
return Text(
info ?? '',
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);
},
);
},
),
@ -203,6 +234,7 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
_namingPatternNotifier.value = NamingPattern.from(
userPattern: _patternTextController.text,
entryCount: entryCount,
locale: locale,
);
}

View file

@ -58,50 +58,77 @@ class _HiddenFilters extends StatelessWidget {
@override
Widget build(BuildContext context) {
bool filterPredicate(CollectionFilter v) => v is! PathFilter;
return Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => settings.hiddenFilters.where((v) => v is! PathFilter).toSet(),
builder: (context, hiddenFilters, child) {
if (hiddenFilters.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Banner(bannerText: context.l10n.settingsHiddenFiltersBanner),
const Divider(height: 0),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: EmptyContent(
icon: AIcons.hide,
text: context.l10n.settingsHiddenFiltersEmpty,
selector: (context, s) => settings.hiddenFilters.where(filterPredicate).toSet(),
builder: (context, activatedHiddenFilters, child) {
return Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => settings.deactivatedHiddenFilters.where(filterPredicate).toSet(),
builder: (context, deactivatedHiddenFilters, child) {
final allHiddenFilters = {
...activatedHiddenFilters,
...deactivatedHiddenFilters,
};
if (allHiddenFilters.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Banner(bannerText: context.l10n.settingsHiddenFiltersBanner),
const Divider(height: 0),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: EmptyContent(
icon: AIcons.hide,
text: context.l10n.settingsHiddenFiltersEmpty,
),
),
),
),
),
],
);
}
],
);
}
final filterList = hiddenFilters.toList()..sort();
return ListView(
children: [
_Banner(bannerText: context.l10n.settingsHiddenFiltersBanner),
const Divider(height: 0),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: filterList.map((filter) {
final filterList = allHiddenFilters.toList()..sort();
return ListView(
children: [
_Banner(bannerText: context.l10n.settingsHiddenFiltersBanner),
const Divider(height: 0),
const SizedBox(height: 8),
...filterList.map((filter) {
void onRemove(CollectionFilter filter) => settings.changeFilterVisibility({filter}, true);
return AvesFilterChip(
filter: filter,
onTap: onRemove,
onRemove: onRemove,
onLongPress: null,
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
children: [
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
debugPrint('TLAD constraints=$constraints');
return Row(
children: [
AvesFilterChip(
filter: filter,
maxWidth: constraints.maxWidth,
onTap: onRemove,
onRemove: onRemove,
onLongPress: null,
),
const Spacer(),
],
);
}),
),
const SizedBox(width: 8),
Switch(
value: activatedHiddenFilters.contains(filter),
onChanged: (v) => settings.activateHiddenFilter(filter, v),
),
],
),
);
}).toList(),
),
),
],
}),
],
);
},
);
},
);
@ -114,7 +141,10 @@ class _HiddenPaths extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Selector<Settings, Set<PathFilter>>(
selector: (context, s) => settings.hiddenFilters.whereType<PathFilter>().toSet(),
selector: (context, s) => {
...settings.hiddenFilters,
...settings.deactivatedHiddenFilters,
}.whereType<PathFilter>().toSet(),
builder: (context, hiddenPaths, child) {
final pathList = hiddenPaths.toList()..sort();
return Column(

View file

@ -12,10 +12,10 @@ abstract class SettingsSection {
FutureOr<List<SettingsTile>> tiles(BuildContext context);
Widget build(BuildContext context, ValueNotifier<String?> expandedNotifier) {
Widget build(BuildContext sectionContext, ValueNotifier<String?> expandedNotifier) {
return FutureBuilder<List<SettingsTile>>(
future: Future.value(tiles(context)),
builder: (context, snapshot) {
future: Future.value(tiles(sectionContext)),
builder: (tileContext, snapshot) {
final tiles = snapshot.data;
if (tiles == null) return const SizedBox();
@ -25,11 +25,12 @@ abstract class SettingsSection {
// use a fixed value instead of the title to identify this expansion tile
// so that the tile state is kept when the language is modified
value: key,
leading: icon(context),
title: title(context),
leading: icon(tileContext),
title: title(tileContext),
expandedNotifier: expandedNotifier,
showHighlight: false,
children: tiles.map((v) => v.build(context)).toList(),
// reuse section context so that dialogs opened from tiles have the right text theme
children: tiles.map((v) => v.build(sectionContext)).toList(),
);
},
);

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/icons.dart';
@ -117,7 +118,7 @@ class _SettingsMobilePageState extends State<SettingsMobilePage> with FeedbackMi
final allJsonString = jsonEncode(allMap);
final success = await storageService.createFile(
'aves-settings-${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}.json',
'aves-settings-${DateFormat('yyyyMMdd_HHmmss', asciiLocale).format(DateTime.now())}.json',
MimeTypes.json,
Uint8List.fromList(utf8.encode(allJsonString)),
);

View file

@ -94,6 +94,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
return !settings.useTvLayout && targetEntry.isPureVideo;
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoABRepeat:
case EntryAction.videoSettings:
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:
@ -229,6 +230,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.videoToggleMute:
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoABRepeat:
case EntryAction.videoSettings:
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:

View file

@ -64,6 +64,8 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
await _showStreamSelectionDialog(context, controller);
case EntryAction.videoSetSpeed:
await _showSpeedDialog(context, controller);
case EntryAction.videoABRepeat:
controller.toggleABRepeat();
case EntryAction.videoSettings:
await _showSettings(context, controller);
case EntryAction.videoTogglePlay:

View file

@ -63,7 +63,7 @@ class EntryViewerStack extends StatefulWidget {
State<EntryViewerStack> createState() => _EntryViewerStackState();
}
class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewControllerMixin, FeedbackMixin, TickerProviderStateMixin {
class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewControllerMixin, FeedbackMixin, TickerProviderStateMixin, RouteAware {
final Floating _floating = Floating();
late int _currentEntryIndex;
late ValueNotifier<int> _currentVerticalPage;
@ -184,6 +184,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
@override
void dispose() {
AvesApp.pageRouteObserver.unsubscribe(this);
_floating.dispose();
cleanEntryControllers(entryNotifier.value);
_videoActionDelegate.dispose();
@ -287,6 +288,41 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
);
}
// route aware
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute) {
AvesApp.pageRouteObserver.subscribe(this, route);
}
}
@override
void didPopNext() => _overrideSnackBarMargin();
@override
void didPush() => _overrideSnackBarMargin();
@override
void didPop() => _resetSnackBarMargin();
@override
void didPushNext() => _resetSnackBarMargin();
void _overrideSnackBarMargin() {
if (isViewingImage) {
FeedbackMixin.snackBarMarginOverrideNotifier.value = EdgeInsets.only(bottom: ViewerBottomOverlay.actionSafeHeight(context));
} else {
FeedbackMixin.snackBarMarginOverrideNotifier.value = FeedbackMixin.snackBarMarginDefault(context);
}
}
void _resetSnackBarMargin() => FeedbackMixin.snackBarMarginOverrideNotifier.value = null;
// lifecycle
void _onAppLifecycleStateChanged() {
switch (AvesApp.lifecycleStateNotifier.value) {
case AppLifecycleState.inactive:
@ -662,6 +698,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page;
_overrideSnackBarMargin();
switch (page) {
case transitionPage:
dismissFeedback(context);

View file

@ -74,7 +74,7 @@ class _ViewerDetailOverlayState extends State<ViewerDetailOverlay> {
if (requestEntry == null) {
_detailLoader = SynchronousFuture(const OverlayMetadata());
} else {
_detailLoader = metadataFetchService.getFields(requestEntry, {
_detailLoader = metadataFetchService.getOverlayMetadata(requestEntry, {
if (settings.showOverlayShootingDetails) ...{
MetadataSyntheticField.aperture,
MetadataSyntheticField.exposureTime,

View file

@ -0,0 +1,78 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
import 'package:aves_video/aves_video.dart';
import 'package:flutter/material.dart';
class VideoABRepeatOverlay extends StatefulWidget {
final AvesVideoController? controller;
final Animation<double> scale;
const VideoABRepeatOverlay({
super.key,
required this.controller,
required this.scale,
});
@override
State<StatefulWidget> createState() => _VideoABRepeatOverlayState();
}
class _VideoABRepeatOverlayState extends State<VideoABRepeatOverlay> {
Animation<double> get scale => widget.scale;
AvesVideoController? get controller => widget.controller;
ValueNotifier<ABRepeat?> get abRepeatNotifier => controller?.abRepeatNotifier ?? ValueNotifier(null);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return ValueListenableBuilder<ABRepeat?>(
valueListenable: abRepeatNotifier,
builder: (context, abRepeat, child) {
if (abRepeat == null) return const SizedBox();
Widget boundButton;
if (abRepeat.start == null) {
boundButton = IconButton(
icon: Icon(AIcons.setStart),
onPressed: controller?.setABRepeatStart,
tooltip: l10n.videoRepeatActionSetStart,
);
} else if (abRepeat.end == null) {
boundButton = IconButton(
icon: Icon(AIcons.setEnd),
onPressed: controller?.setABRepeatEnd,
tooltip: l10n.videoRepeatActionSetEnd,
);
} else {
boundButton = IconButton(
icon: Icon(AIcons.resetBounds),
onPressed: controller?.resetABRepeat,
tooltip: l10n.resetTooltip,
);
}
return Row(
children: [
const Spacer(),
OverlayButton(
scale: scale,
child: boundButton,
),
const SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: Icon(AIcons.repeatOff),
onPressed: () => controller?.toggleABRepeat(),
tooltip: l10n.stopTooltip,
),
),
],
);
},
);
}
}

View file

@ -37,6 +37,8 @@ class _VideoProgressBarState extends State<VideoProgressBar> {
bool get isPlaying => controller?.isPlaying ?? false;
ValueNotifier<ABRepeat?> get abRepeatNotifier => controller?.abRepeatNotifier ?? ValueNotifier(null);
@override
Widget build(BuildContext context) {
final blurred = settings.enableBlurEffect;
@ -69,8 +71,7 @@ class _VideoProgressBarState extends State<VideoProgressBar> {
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred),
border: AvesBorder.border(context),
@ -80,62 +81,80 @@ class _VideoProgressBarState extends State<VideoProgressBar> {
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.noScaling,
),
child: Column(
key: _progressBarKey,
mainAxisSize: MainAxisSize.min,
children: [
Row(
child: ValueListenableBuilder<ABRepeat?>(
valueListenable: abRepeatNotifier,
builder: (context, abRepeat, child) {
return Stack(
fit: StackFit.passthrough,
children: [
StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = controller?.currentPosition.floor() ?? 0;
return Text(
formatFriendlyDuration(Duration(milliseconds: position)),
style: textStyle,
strutStyle: strutStyle,
);
}),
const Spacer(),
Text(
formatFriendlyDuration(Duration(milliseconds: controller?.duration ?? 0)),
style: textStyle,
strutStyle: strutStyle,
if (abRepeat != null) ...[
_buildABRepeatMark(context, abRepeat.start),
_buildABRepeatMark(context, abRepeat.end),
],
Container(
key: _progressBarKey,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = controller?.currentPosition.floor() ?? 0;
return Text(
formatFriendlyDuration(Duration(milliseconds: position)),
style: textStyle,
strutStyle: strutStyle,
);
}),
const Spacer(),
Text(
formatFriendlyDuration(Duration(milliseconds: controller?.duration ?? 0)),
style: textStyle,
strutStyle: strutStyle,
),
],
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Directionality(
// force directionality for `LinearProgressIndicator`
textDirection: TextDirection.ltr,
child: StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
var progress = controller?.progress ?? 0.0;
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(
value: progress,
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2),
);
}),
),
),
Row(
children: [
_buildSpeedIndicator(),
_buildMuteIndicator(),
Text(
// fake text below to match the height of the text above and center the whole thing
'',
style: textStyle,
strutStyle: strutStyle,
),
],
),
],
),
),
],
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Directionality(
// force directionality for `LinearProgressIndicator`
textDirection: TextDirection.ltr,
child: StreamBuilder<int>(
stream: positionStream,
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
var progress = controller?.progress ?? 0.0;
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(
value: progress,
backgroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(.2),
);
}),
),
),
Row(
children: [
_buildSpeedIndicator(),
_buildMuteIndicator(),
Text(
// fake text below to match the height of the text above and center the whole thing
'',
style: textStyle,
strutStyle: strutStyle,
),
],
),
],
);
},
),
),
),
@ -145,6 +164,20 @@ class _VideoProgressBarState extends State<VideoProgressBar> {
);
}
Widget _buildABRepeatMark(BuildContext context, int? position) {
if (controller == null || position == null) return const SizedBox();
return Positioned(
left: _progressToDx(position / controller!.duration),
top: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
border: Border(left: AvesBorder.straightSide(context, width: 2)),
),
),
);
}
Widget _buildSpeedIndicator() => StreamBuilder<double>(
stream: controller?.speedStream ?? Stream.value(1.0),
builder: (context, snapshot) {
@ -175,11 +208,20 @@ class _VideoProgressBarState extends State<VideoProgressBar> {
},
);
RenderBox? _getProgressBarRenderBox() {
return _progressBarKey.currentContext?.findRenderObject() as RenderBox?;
}
void _seekFromTap(Offset globalPosition) async {
if (controller == null) return;
final keyContext = _progressBarKey.currentContext!;
final box = keyContext.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(globalPosition);
await controller!.seekToProgress(localPosition.dx / box.size.width);
final box = _getProgressBarRenderBox();
if (controller == null || box == null) return;
final dx = box.globalToLocal(globalPosition).dx;
await controller!.seekToProgress(dx / box.size.width);
}
double? _progressToDx(double progress) {
final box = _getProgressBarRenderBox();
return box == null ? null : progress * box.size.width;
}
}

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/view/view.dart';
import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
import 'package:aves/widgets/viewer/overlay/video/ab_repeat.dart';
import 'package:aves/widgets/viewer/overlay/video/controls.dart';
import 'package:aves/widgets/viewer/overlay/video/progress_bar.dart';
import 'package:aves_model/aves_model.dart';
@ -64,19 +65,28 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
);
}
return Row(
return Column(
children: [
Expanded(
child: VideoProgressBar(
controller: controller,
scale: scale,
),
),
VideoControlRow(
entry: entry,
VideoABRepeatOverlay(
controller: controller,
scale: scale,
onActionSelected: widget.onActionSelected,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: VideoProgressBar(
controller: controller,
scale: scale,
),
),
VideoControlRow(
entry: entry,
controller: controller,
scale: scale,
onActionSelected: widget.onActionSelected,
),
],
),
],
);

View file

@ -405,6 +405,7 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
controller: controller ?? _magnifierController,
contentSize: displaySize ?? entry.displaySize,
allowOriginalScaleBeyondRange: !isWallpaperMode,
allowDoubleTap: _allowDoubleTap,
minScale: minScale,
maxScale: maxScale,
initialScale: viewerController.initialScale,
@ -434,22 +435,34 @@ class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateM
}
}
void _onTap({Alignment? alignment}) {
Notification? _handleSideSingleTap(Alignment? alignment) {
if (settings.viewerGestureSideTapNext && alignment != null) {
final x = alignment.x;
final sideRatio = _getSideRatio();
if (sideRatio != null) {
const animate = false;
if (x < sideRatio) {
(context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate)).dispatch(context);
return;
return context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate);
} else if (x > 1 - sideRatio) {
(context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate)).dispatch(context);
return;
return context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate);
}
}
}
const ToggleOverlayNotification().dispatch(context);
return null;
}
void _onTap({Alignment? alignment}) => (_handleSideSingleTap(alignment) ?? const ToggleOverlayNotification()).dispatch(context);
// side gesture handling by precedence:
// - seek in video by side double tap (if enabled)
// - go to previous/next entry by side single tap (if enabled)
// - zoom in/out by double tap
bool _allowDoubleTap(Alignment alignment) {
if (entry.isVideo && settings.videoGestureSideDoubleTapSeek) {
return true;
}
final actionNotification = _handleSideSingleTap(alignment);
return actionNotification == null;
}
void _onMediaCommand(MediaCommandEvent event) {

Some files were not shown because too many files have changed in this diff Show more