Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-11-23 21:39:11 +01:00
commit af636f175d
153 changed files with 5411 additions and 1037 deletions

View file

@ -17,7 +17,7 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git # Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.3.8' flutter-version: '3.3.9'
channel: 'stable' channel: 'stable'
- name: Clone the repository. - name: Clone the repository.

View file

@ -19,14 +19,9 @@ jobs:
# Available versions may lag behind https://github.com/flutter/flutter.git # Available versions may lag behind https://github.com/flutter/flutter.git
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
flutter-version: '3.3.8' flutter-version: '3.3.9'
channel: 'stable' channel: 'stable'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
- name: Install NDK
run: echo "y" | sudo /usr/local/lib/android/sdk/tools/bin/sdkmanager --install "ndk;20.0.5594570" --sdk_root=${ANDROID_SDK_ROOT}
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2
@ -50,22 +45,28 @@ jobs:
# `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above # `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above
# The SkSL bundle must be produced with the same Flutter engine as the one used to build the artifact # The SkSL bundle must be produced with the same Flutter engine as the one used to build the artifact
# flutter build <subcommand> --bundle-sksl-path shaders.sksl.json # flutter build <subcommand> --bundle-sksl-path shaders.sksl.json
# do not bundle shaders for izzy/libre flavours, to avoid crashes in some environments:
# cf https://github.com/deckerst/aves/issues/388
# cf https://github.com/deckerst/aves/issues/398
run: | run: |
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
mkdir outputs mkdir outputs
(cd scripts/; ./apply_flavor_play.sh) (cd scripts/; ./apply_flavor_play.sh)
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.8.sksl.json flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs cp build/app/outputs/bundle/playRelease/*.aab outputs
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_3.3.8.sksl.json flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders.sksl.json
cp build/app/outputs/apk/play/release/*.apk outputs cp build/app/outputs/apk/play/release/*.apk outputs
(cd scripts/; ./apply_flavor_huawei.sh) (cd scripts/; ./apply_flavor_huawei.sh)
flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders_3.3.8.sksl.json flutter build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders.sksl.json
cp build/app/outputs/apk/huawei/release/*.apk outputs cp build/app/outputs/apk/huawei/release/*.apk outputs
(cd scripts/; ./apply_flavor_izzy.sh) (cd scripts/; ./apply_flavor_izzy.sh)
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_3.3.8.sksl.json flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi
cp build/app/outputs/apk/izzy/release/*.apk outputs cp build/app/outputs/apk/izzy/release/*.apk outputs
(cd scripts/; ./apply_flavor_libre.sh)
flutter build apk -t lib/main_libre.dart --flavor libre
cp build/app/outputs/apk/libre/release/*.apk outputs
rm $AVES_STORE_FILE rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks AVES_STORE_FILE: ${{ github.workspace }}/key.jks

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
.buildlog/ .buildlog/
.history .history
.svn/ .svn/
migrate_working_dir/
# IntelliJ related # IntelliJ related
*.iml *.iml

View file

@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
## <a id="v1.7.5"></a>[v1.7.5] - 2022-11-23
### Added
- Viewer: Info page editing actions available as quick actions
- Video: subtitle vertical position option
- Info: export metadata to text file
- Accessibility: apply bold font system setting
- Widget: option to show most recent item instead of random items
- `libre` app flavor (no mobile service maps, no Crashlytics)
### Changed
- Map: no default map style for `izzy` and `libre` flavors
- Viewer: allow setting default editor
- Viewer: keep manually un/muted state for following autoplayed videos
- upgraded Flutter to stable v3.3.9
### Fixed
- crash when cataloguing some MP4 files
- reading metadata for some MP4 files
## <a id="v1.7.4"></a>[v1.7.4] - 2022-11-11 ## <a id="v1.7.4"></a>[v1.7.4] - 2022-11-11
### Added ### Added

View file

@ -15,6 +15,9 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/huawei-appgallery-badge-english-black.png" [<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/huawei-appgallery-badge-english-black.png"
alt='Get it on Huawei AppGallery' alt='Get it on Huawei AppGallery'
height="80">](https://appgallery.huawei.com/app/C106014023) height="80">](https://appgallery.huawei.com/app/C106014023)
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/samsung-galaxy-store-badge-english.png"
alt='Get it on Samsung Galaxy Store'
height="80">](https://galaxy.store/aves)
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/amazon-appstore-badge-english-black.png" [<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/amazon-appstore-badge-english-black.png"
alt='Get it on Amazon Appstore' alt='Get it on Amazon Appstore'
height="80">](https://www.amazon.com/dp/B09XQHQQ72) height="80">](https://www.amazon.com/dp/B09XQHQQ72)
@ -115,7 +118,7 @@ Some users have expressed the wish to financially support the project. Thanks!
Before running or building the app, update the dependencies for the desired flavor: Before running or building the app, update the dependencies for the desired flavor:
``` ```
# (cd scripts/; ./apply_flavor_play.sh) # ./scripts/apply_flavor_play.sh
``` ```
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys. To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
@ -125,10 +128,5 @@ To run the app:
# flutter run -t lib/main_play.dart --flavor play # flutter run -t lib/main_play.dart --flavor play
``` ```
To run the app on API 19 emulators:
```
# flutter run -t lib/main_play.dart --flavor play --enable-software-rendering
```
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver [Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check [Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check

View file

@ -4,7 +4,7 @@ plugins {
id 'kotlin-kapt' id 'kotlin-kapt'
} }
def appId = "deckers.thibault.aves" def packageName = "deckers.thibault.aves"
// Flutter properties // Flutter properties
@ -49,7 +49,7 @@ android {
} }
defaultConfig { defaultConfig {
applicationId appId applicationId packageName
// minSdkVersion constraints: // minSdkVersion constraints:
// - Flutter & other plugins: 16 // - Flutter & other plugins: 16
// - google_maps_flutter v2.1.1: 20 // - google_maps_flutter v2.1.1: 20
@ -63,7 +63,6 @@ android {
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey'], manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey'],
huaweiApiKey: keystoreProperties['huaweiApiKey']] huaweiApiKey: keystoreProperties['huaweiApiKey']]
multiDexEnabled true multiDexEnabled true
resValue 'string', 'search_provider', "${appId}.search_provider"
} }
signingConfigs { signingConfigs {
@ -81,8 +80,6 @@ android {
play { play {
// Google Play // Google Play
dimension "store" dimension "store"
ext.useCrashlytics = true
ext.useHMS = false
// generate a universal APK without x86 native libs // generate a universal APK without x86 native libs
ext.useNdkAbiFilters = true ext.useNdkAbiFilters = true
} }
@ -90,8 +87,6 @@ android {
huawei { huawei {
// Huawei AppGallery // Huawei AppGallery
dimension "store" dimension "store"
ext.useCrashlytics = false
ext.useHMS = true
// generate a universal APK without x86 native libs // generate a universal APK without x86 native libs
ext.useNdkAbiFilters = true ext.useNdkAbiFilters = true
} }
@ -101,21 +96,27 @@ android {
// check offending libraries with `scanapk` // check offending libraries with `scanapk`
// cf https://android.izzysoft.de/articles/named/app-modules-2 // cf https://android.izzysoft.de/articles/named/app-modules-2
dimension "store" dimension "store"
ext.useCrashlytics = false
ext.useHMS = false
// generate APK by ABI, but NDK ABI filters are incompatible with split APK generation // generate APK by ABI, but NDK ABI filters are incompatible with split APK generation
ext.useNdkAbiFilters = false ext.useNdkAbiFilters = false
} }
libre {
// F-Droid
// check offending libraries with `fdroidserver`
// cf https://f-droid.org/en/docs/Submitting_to_F-Droid_Quick_Start_Guide/
dimension "store"
// generate a universal APK without x86 native libs
ext.useNdkAbiFilters = true
applicationIdSuffix ".libre"
}
} }
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
resValue 'string', 'search_provider', "${appId}.debug.search_provider"
} }
profile { profile {
applicationIdSuffix ".profile" applicationIdSuffix ".profile"
resValue 'string', 'search_provider', "${appId}.profile.search_provider"
} }
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
@ -124,6 +125,11 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
applicationVariants.all { variant ->
variant.resValue 'string', 'screen_saver_settings_activity', "${applicationId}/${packageName}.ScreenSaverSettingsActivity"
variant.resValue 'string', 'search_provider', "${applicationId}.search_provider"
}
android.productFlavors.each { flavor -> android.productFlavors.each { flavor ->
def tasks = gradle.startParameter.taskNames.toString().toLowerCase() def tasks = gradle.startParameter.taskNames.toString().toLowerCase()
if (tasks.contains(flavor.name) && flavor.ext.useNdkAbiFilters) { if (tasks.contains(flavor.name) && flavor.ext.useNdkAbiFilters) {
@ -138,6 +144,7 @@ android {
} }
} }
} }
lint { lint {
disable 'InvalidPackage' disable 'InvalidPackage'
} }
@ -183,8 +190,8 @@ dependencies {
// - https://jitpack.io/p/deckerst/mp4parser // - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android // - https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
implementation 'com.github.deckerst.mp4parser:isoparser:64b571fdfb' implementation 'com.github.deckerst.mp4parser:isoparser:7b698ab674'
implementation 'com.github.deckerst.mp4parser:muxer:64b571fdfb' implementation 'com.github.deckerst.mp4parser:muxer:7b698ab674'
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e' implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
// huawei flavor only // huawei flavor only
@ -196,15 +203,13 @@ dependencies {
compileOnly rootProject.findProject(':streams_channel') compileOnly rootProject.findProject(':streams_channel')
} }
android.productFlavors.each { flavor -> if (useCrashlytics) {
def tasks = gradle.startParameter.taskRequests.toString().toLowerCase() println("Building flavor with Crashlytics plugin")
if (tasks.contains(flavor.name) && flavor.ext.useCrashlytics) { apply plugin: 'com.google.gms.google-services'
println("Building flavor [${flavor.name}] with Crashlytics plugin") apply plugin: 'com.google.firebase.crashlytics'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
}
if (tasks.contains(flavor.name) && flavor.ext.useHMS) {
println("Building flavor [${flavor.name}] with HMS plugin")
apply plugin: 'com.huawei.agconnect'
}
} }
if (useHms) {
println("Building flavor with HMS plugin")
apply plugin: 'com.huawei.agconnect'
}

View file

@ -1,2 +0,0 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves.debug/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves Libre</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves Libre [Debug]</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves Libre [Profile]</string>
</resources>

View file

@ -172,9 +172,10 @@ This change eventually prevents building the app with Flutter v3.3.3.
</intent-filter> </intent-filter>
</activity> </activity>
<!-- exported for Android API 19 launcher to access this activity -->
<activity <activity
android:name=".HomeWidgetSettingsActivity" android:name=".HomeWidgetSettingsActivity"
android:exported="false" android:exported="true"
android:theme="@style/NormalTheme"> android:theme="@style/NormalTheme">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />

View file

@ -3,12 +3,17 @@ package deckers.thibault.aves
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class HomeWidgetSettingsActivity : MainActivity() { class HomeWidgetSettingsActivity : MainActivity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
if (FlutterUtils.isSoftwareRenderingRequired()) {
intent.enableSoftwareRendering()
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// cancel if user does not complete widget setup // cancel if user does not complete widget setup

View file

@ -21,6 +21,8 @@ import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import deckers.thibault.aves.utils.FlutterUtils.isSoftwareRenderingRequired
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
@ -40,6 +42,14 @@ open class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent") Log.i(LOG_TAG, "onCreate intent=$intent")
if (isSoftwareRenderingRequired()) {
intent.enableSoftwareRendering()
// running the app from Android Studio automatically adds to the intent the `start-paused` flag
// so the IDE can connect to the app, but launching on KitKat emulators fails because of a timeout
intent.removeExtra("start-paused")
}
intent.extras?.takeUnless { it.isEmpty }?.let { intent.extras?.takeUnless { it.isEmpty }?.let {
Log.i(LOG_TAG, "onCreate intent extras=$it") Log.i(LOG_TAG, "onCreate intent extras=$it")
} }

View file

@ -14,6 +14,8 @@ import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
@ -24,6 +26,9 @@ class WallpaperActivity : FlutterActivity() {
private lateinit var intentDataMap: MutableMap<String, Any?> private lateinit var intentDataMap: MutableMap<String, Any?>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (FlutterUtils.isSoftwareRenderingRequired()) {
intent.enableSoftwareRendering()
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.i(LOG_TAG, "onCreate intent=$intent") Log.i(LOG_TAG, "onCreate intent=$intent")

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
@ -19,6 +20,7 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved) "areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts) "hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis) "getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -76,8 +78,28 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
result.success(millis) result.success(millis)
} }
// Flutter v3.4 already checks the system `Configuration.fontWeightAdjustment` to update `MediaQuery`
// but we need to also check the non-standard Samsung field `bf` representing the bold font toggle
private fun shouldUseBoldFont(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var shouldBold = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val config = contextWrapper.resources.configuration
val fontWeightAdjustment = config.fontWeightAdjustment
shouldBold = if (fontWeightAdjustment != Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED && fontWeightAdjustment != 0) {
fontWeightAdjustment >= BOLD_TEXT_WEIGHT_ADJUSTMENT
} else {
// fallback to Samsung non-standard field
Regex(" bf=([01]) ").find(config.toString())?.groups?.get(1)?.value == "1"
}
}
result.success(shouldBold)
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<AccessibilityHandler>() private val LOG_TAG = LogUtils.createTag<AccessibilityHandler>()
const val CHANNEL = "deckers.thibault/aves/accessibility" const val CHANNEL = "deckers.thibault/aves/accessibility"
// match Flutter way: https://github.com/flutter/engine/blob/main/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L125
const val BOLD_TEXT_WEIGHT_ADJUSTMENT = 300
} }
} }

View file

@ -213,7 +213,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} }
private fun edit(call: MethodCall, result: MethodChannel.Result) { private fun edit(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
if (uri == null) { if (uri == null) {
@ -224,7 +223,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_EDIT) val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(getShareableUri(context, uri), mimeType) .setDataAndType(getShareableUri(context, uri), mimeType)
val started = safeStartActivityChooser(title, intent) val started = safeStartActivity(intent)
result.success(started) result.success(started)
} }
@ -327,7 +326,16 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
context.startActivity(intent) context.startActivity(intent)
return true return true
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.w(LOG_TAG, "failed to start activity for intent=$intent", e) if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
return safeStartActivity(intent)
} else {
Log.w(LOG_TAG, "failed to start activity for intent=$intent", e)
}
} }
return false return false
} }

View file

@ -15,11 +15,8 @@ import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
import deckers.thibault.aves.metadata.PixyMetaHelper
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -42,6 +39,7 @@ import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import org.mp4parser.IsoFile import org.mp4parser.IsoFile
import org.mp4parser.PropertyBoxParserImpl import org.mp4parser.PropertyBoxParserImpl
import org.mp4parser.boxes.iso14496.part12.FreeBox
import org.mp4parser.boxes.iso14496.part12.MediaDataBox import org.mp4parser.boxes.iso14496.part12.MediaDataBox
import org.mp4parser.boxes.iso14496.part12.SampleTableBox import org.mp4parser.boxes.iso14496.part12.SampleTableBox
import java.io.FileInputStream import java.io.FileInputStream
@ -344,9 +342,20 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
FileInputStream(it.fileDescriptor).use { stream -> FileInputStream(it.fileDescriptor).use { stream ->
stream.channel.use { channel -> stream.channel.use { channel ->
val boxParser = PropertyBoxParserImpl().apply { val boxParser = PropertyBoxParserImpl().apply {
// parsing `MediaDataBox` can take a long time val skippedTypes = listOf(
// parsing `SampleTableBox` may yield OOM // parsing `MediaDataBox` can take a long time
skippingBoxes(MediaDataBox.TYPE, SampleTableBox.TYPE) MediaDataBox.TYPE,
// parsing `SampleTableBox` or `FreeBox` may yield OOM
SampleTableBox.TYPE, FreeBox.TYPE,
// some files are padded with `0` but the parser does not stop, reads type "0000",
// then a large size from following "0000", which may yield OOM
"0000",
)
setBoxSkipper { type, size ->
if (skippedTypes.contains(type)) return@setBoxSkipper true
if (size > Mp4ParserHelper.BOX_SIZE_DANGER_THRESHOLD) throw Exception("box (type=$type size=$size) is too large")
false
}
} }
IsoFile(channel, boxParser).use { isoFile -> IsoFile(channel, boxParser).use { isoFile ->
isoFile.dumpBoxes(sb) isoFile.dumpBoxes(sb)

View file

@ -124,6 +124,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val metadataMap = HashMap<String, MutableMap<String, String>>() val metadataMap = HashMap<String, MutableMap<String, String>>()
var foundExif = false var foundExif = false
var foundXmp = false var foundXmp = false
var foundMp4Uuid = false
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>, allowMultiple: Boolean = false) { fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>, allowMultiple: Boolean = false) {
if (foundXmp && !allowMultiple) return if (foundXmp && !allowMultiple) return
@ -209,6 +210,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input) val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundMp4Uuid = metadata.directories.any { it is Mp4UuidBoxDirectory && it.tagCount > 0 }
val dirByName = metadata.directories.filter { val dirByName = metadata.directories.filter {
(it.tagCount > 0 || it.errorCount > 0) (it.tagCount > 0 || it.errorCount > 0)
@ -383,16 +385,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
fun fallbackProcessXmp(xmpMeta: XMPMeta) { fun fallbackProcessXmp(xmpMeta: XMPMeta) {
val thisDirName = XmpDirectory().name val thisDirName = XmpDirectory().name
val dirMap = metadataMap[thisDirName] ?: HashMap() val dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[thisDirName] = dirMap
processXmp(xmpMeta, dirMap) processXmp(xmpMeta, dirMap)
if (dirMap.isNotEmpty()) {
metadataMap[thisDirName] = dirMap
}
} }
XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp) XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
if (isLargeMp4(mimeType, sizeBytes)) { // `metadata-extractor` may fail to get UUID boxes for some MP4 files,
XMP.checkMp4(context, mimeType, uri) { dirs -> // so we always check with `mp4parser`, even for smaller files
for (dir in dirs.filterIsInstance<XmpDirectory>()) { XMP.checkMp4(context, mimeType, uri) { dirs ->
fallbackProcessXmp(dir.xmpMeta) for (dir in dirs.filterIsInstance<XmpDirectory>()) {
} fallbackProcessXmp(dir.xmpMeta)
}
if (!foundMp4Uuid) {
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) { for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
processMp4Uuid(dir) processMp4Uuid(dir)
} }
@ -491,6 +497,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
var foundExif = false var foundExif = false
var foundXmp = false var foundXmp = false
var foundMp4Uuid = false
fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) { fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) {
if (foundXmp && !allowMultiple) return if (foundXmp && !allowMultiple) return
@ -543,6 +550,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input) val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 } foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
foundMp4Uuid = metadata.directories.any { it is Mp4UuidBoxDirectory && it.tagCount > 0 }
// File type // File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
@ -695,11 +703,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) { // `metadata-extractor` may fail to get UUID boxes for some MP4 files,
XMP.checkMp4(context, mimeType, uri) { dirs -> // so we always check with `mp4parser`, even for smaller files
for (dir in dirs.filterIsInstance<XmpDirectory>()) { XMP.checkMp4(context, mimeType, uri) { dirs ->
processXmp(dir.xmpMeta) for (dir in dirs.filterIsInstance<XmpDirectory>()) {
} processXmp(dir.xmpMeta)
}
if (!foundMp4Uuid) {
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) { for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
processMp4Uuid(dir) processMp4Uuid(dir)
} }
@ -941,11 +951,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) { // `metadata-extractor` may fail to get UUID boxes for some MP4 files,
XMP.checkMp4(context, mimeType, uri) { dirs -> // so we always check with `mp4parser`, even for smaller files
for (dir in dirs.filterIsInstance<XmpDirectory>()) { XMP.checkMp4(context, mimeType, uri) { dirs ->
processXmp(dir.xmpMeta) for (dir in dirs.filterIsInstance<XmpDirectory>()) {
} processXmp(dir.xmpMeta)
} }
} }
@ -1026,11 +1036,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp) XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) { // `metadata-extractor` may fail to get UUID boxes for some MP4 files,
XMP.checkMp4(context, mimeType, uri) { dirs -> // so we always check with `mp4parser`, even for smaller files
for (dir in dirs.filterIsInstance<XmpDirectory>()) { XMP.checkMp4(context, mimeType, uri) { dirs ->
processXmp(dir.xmpMeta) for (dir in dirs.filterIsInstance<XmpDirectory>()) {
} processXmp(dir.xmpMeta)
} }
} }

View file

@ -96,7 +96,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes) val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes)
success(granted) success(granted)
} catch (e: Exception) { } catch (e: Exception) {
error("requestMediaFileAccess-request", "failed to request access to uris=$uris", e.message) error("requestMediaFileAccess-request", "failed to request access to ${uris.size} uris=$uris", e.message)
} }
endOfStream() endOfStream()
} }

View file

@ -15,6 +15,9 @@ import java.io.FileInputStream
import java.nio.channels.Channels import java.nio.channels.Channels
object Mp4ParserHelper { object Mp4ParserHelper {
// arbitrary size to detect boxes that may yield an OOM
const val BOX_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> { fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
// we can skip uninteresting boxes with a seekable data source // we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri") val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
@ -22,9 +25,17 @@ object Mp4ParserHelper {
FileInputStream(it.fileDescriptor).use { stream -> FileInputStream(it.fileDescriptor).use { stream ->
stream.channel.use { channel -> stream.channel.use { channel ->
val boxParser = PropertyBoxParserImpl().apply { val boxParser = PropertyBoxParserImpl().apply {
// parsing `MediaDataBox` can take a long time
// do not skip anything inside `MovieBox` as it will be parsed and rewritten for editing // do not skip anything inside `MovieBox` as it will be parsed and rewritten for editing
skippingBoxes(MediaDataBox.TYPE) // do not skip weird boxes (like trailing "0000" box), to fail fast if it is large
val skippedTypes = listOf(
// parsing `MediaDataBox` can take a long time
MediaDataBox.TYPE,
)
setBoxSkipper { type, size ->
if (skippedTypes.contains(type)) return@setBoxSkipper true
if (size > BOX_SIZE_DANGER_THRESHOLD) throw Exception("box (type=$type size=$size) is too large")
false
}
} }
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device` // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
IsoFile(channel, boxParser).use { isoFile -> IsoFile(channel, boxParser).use { isoFile ->

View file

@ -17,11 +17,13 @@ import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import org.mp4parser.IsoFile import org.mp4parser.IsoFile
import org.mp4parser.PropertyBoxParserImpl import org.mp4parser.PropertyBoxParserImpl
import org.mp4parser.boxes.UserBox import org.mp4parser.boxes.UserBox
import org.mp4parser.boxes.iso14496.part12.FreeBox
import org.mp4parser.boxes.iso14496.part12.MediaDataBox import org.mp4parser.boxes.iso14496.part12.MediaDataBox
import org.mp4parser.boxes.iso14496.part12.SampleTableBox import org.mp4parser.boxes.iso14496.part12.SampleTableBox
import java.io.FileInputStream import java.io.FileInputStream
@ -143,19 +145,32 @@ object XMP {
FileInputStream(it.fileDescriptor).use { stream -> FileInputStream(it.fileDescriptor).use { stream ->
stream.channel.use { channel -> stream.channel.use { channel ->
val boxParser = PropertyBoxParserImpl().apply { val boxParser = PropertyBoxParserImpl().apply {
// parsing `MediaDataBox` can take a long time val skippedTypes = listOf(
// parsing `SampleTableBox` may yield OOM // parsing `MediaDataBox` can take a long time
skippingBoxes(MediaDataBox.TYPE, SampleTableBox.TYPE) MediaDataBox.TYPE,
// parsing `SampleTableBox` or `FreeBox` may yield OOM
SampleTableBox.TYPE, FreeBox.TYPE,
)
setBoxSkipper { type, size ->
if (skippedTypes.contains(type)) return@setBoxSkipper true
if (size > Mp4ParserHelper.BOX_SIZE_DANGER_THRESHOLD) throw Exception("box (type=$type size=$size) is too large")
false
}
} }
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device` // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
IsoFile(channel, boxParser).use { isoFile -> IsoFile(channel, boxParser).use { isoFile ->
isoFile.processBoxes(UserBox::class.java, true) { box, _ -> isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
val bytes = box.toBytes() val boxSize = box.size
val payload = bytes.copyOfRange(8, bytes.size) if (MemoryUtils.canAllocate(boxSize)) {
val bytes = box.toBytes()
val payload = bytes.copyOfRange(8, bytes.size)
val metadata = com.drew.metadata.Metadata() val metadata = com.drew.metadata.Metadata()
SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null) SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null)
processDirs(metadata.directories.filter { dir -> dir.tagCount > 0 }.toList()) processDirs(metadata.directories.filter { dir -> dir.tagCount > 0 }.toList())
} else {
Log.w(LOG_TAG, "MP4 box too large at $boxSize bytes, for mimeType=$mimeType uri=$uri")
}
} }
} }
} }

View file

@ -1,6 +1,8 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
@ -63,4 +65,29 @@ object FlutterUtils {
r.run() r.run()
} }
} }
fun Intent.enableSoftwareRendering() {
putExtra("enable-software-rendering", true)
Log.i(LOG_TAG, "Enable software rendering")
}
fun isSoftwareRenderingRequired() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT && isEmulator
private val isEmulator: Boolean
get() = (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|| Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.PRODUCT.contains("sdk_google")
|| Build.PRODUCT.contains("google_sdk")
|| Build.PRODUCT.contains("sdk")
|| Build.PRODUCT.contains("sdk_x86")
|| Build.PRODUCT.contains("vbox86p")
|| Build.PRODUCT.contains("emulator")
|| Build.PRODUCT.contains("simulator"))
} }

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_widget_label">Rama foto</string>
<string name="wallpaper">Tapet</string>
<string name="videos_shortcut_short_label">Videoclipuri</string>
<string name="analysis_channel_name">Scanare media</string>
<string name="analysis_service_description">Scanați imagini și videoclipuri</string>
<string name="analysis_notification_default_title">Scanarea suporturilor</string>
<string name="analysis_notification_action_stop">Stop</string>
<string name="search_shortcut_short_label">Căutare</string>
</resources>

View file

@ -1,2 +1,2 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android" <dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves/deckers.thibault.aves.ScreenSaverSettingsActivity" /> android:settingsActivity="@string/screen_saver_settings_activity" />

View file

@ -1,2 +0,0 @@
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="deckers.thibault.aves.profile/deckers.thibault.aves.ScreenSaverSettingsActivity" />

View file

@ -1,20 +1,35 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.7.10' ext.kotlin_version = '1.7.20'
ext.useCrashlytics = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("play") }
ext.useHms = gradle.startParameter.taskNames.any { task -> task.containsIgnoreCase("huawei") }
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url 'https://developer.huawei.com/repo/' }
if (useHms) {
// HMS (used by some flavors only)
maven { url 'https://developer.huawei.com/repo/' }
}
} }
dependencies { dependencies {
// TODO TLAD upgrade Android Gradle plugin >=7.3 when this is fixed: https://github.com/flutter/flutter/issues/115100 // TODO TLAD upgrade Android Gradle plugin >=7.3 when this is fixed: https://github.com/flutter/flutter/issues/115100
classpath 'com.android.tools.build:gradle:7.2.2' classpath 'com.android.tools.build:gradle:7.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.3.14' if (useCrashlytics) {
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.2' // GMS & Firebase Crashlytics (used by some flavors only)
// HMS (used by some flavors only) classpath 'com.google.gms:google-services:4.3.14'
classpath 'com.huawei.agconnect:agcp:1.7.2.300' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.2'
}
if (useHms) {
// HMS (used by some flavors only)
classpath 'com.huawei.agconnect:agcp:1.7.2.300'
}
} }
} }
@ -22,8 +37,13 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url 'https://developer.huawei.com/repo/' }
if (useHms) {
// HMS (used by some flavors only)
maven { url 'https://developer.huawei.com/repo/' }
}
} }
// gradle.projectsEvaluated { // gradle.projectsEvaluated {
// tasks.withType(JavaCompile) { // tasks.withType(JavaCompile) {
// options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" // options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"

View file

@ -0,0 +1,5 @@
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from <b>API 19 to 33</b>, i.e. from KitKat to Android 13) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

View file

@ -0,0 +1 @@
المعرض ومستكشف البيانات الوصفية

View file

@ -0,0 +1,5 @@
In v1.7.5:
- use viewer quick actions to rate, tag, locate
- set a default editor
- export metadata to a text file
Full changelog available on GitHub

View file

@ -0,0 +1,5 @@
<i>Aves</i> poate gestiona tot felul de imagini și videoclipuri, inclusiv JPEG și MP4-uri tipice, dar și lucruri mai exotice, cum ar fi <b>TIFF-uri cu mai multe pagini, SVG, AVI vechi și multe altele</b>! Acesta scanează colecția dvs. media pentru a identifica <b>fotografii în mișcare</b>, <b>panorame</b> (alias foto sferice), <b>videoclipuri la 360°</b>, precum și <b>GeoTIFF</b> fișiere.
<b>Navigația și căutarea</b> sunt o parte importantă a <i>Aves</i>. Scopul este ca utilizatorii să treacă cu ușurință de la albume la fotografii la etichete la hărți etc.
<i>Aves</i> se integrează cu Android (de la <b>API 19 la 33</b>, adică de la KitKat la Android 13) cu funcții precum <b>widgeturi</b>, <b>comenzi rapide pentru aplicații</b>, <b>protector de ecran</b> și gestionarea <b>căutării globale</b>. De asemenea, funcționează ca <b>vizionator și selector de conținut media</b>.

View file

@ -0,0 +1 @@
Galeria și exploratorul de metadate

View file

@ -1,4 +1,4 @@
enum AppFlavor { play, huawei, izzy } enum AppFlavor { play, huawei, izzy, libre }
extension ExtraAppFlavor on AppFlavor { extension ExtraAppFlavor on AppFlavor {
bool get canEnableErrorReporting { bool get canEnableErrorReporting {
@ -7,6 +7,18 @@ extension ExtraAppFlavor on AppFlavor {
return true; return true;
case AppFlavor.huawei: case AppFlavor.huawei:
case AppFlavor.izzy: case AppFlavor.izzy:
case AppFlavor.libre:
return false;
}
}
bool get hasMapStyleDefault {
switch (this) {
case AppFlavor.play:
case AppFlavor.huawei:
return true;
case AppFlavor.izzy:
case AppFlavor.libre:
return false; return false;
} }
} }

1
lib/l10n/app_ar.arb Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -1156,5 +1156,7 @@
"tagPlaceholderPlace": "Ort", "tagPlaceholderPlace": "Ort",
"@tagPlaceholderPlace": {}, "@tagPlaceholderPlace": {},
"editEntryLocationDialogSetCustom": "Benutzerdefinierten Standort festlegen", "editEntryLocationDialogSetCustom": "Benutzerdefinierten Standort festlegen",
"@editEntryLocationDialogSetCustom": {} "@editEntryLocationDialogSetCustom": {},
"entryInfoActionExportMetadata": "Metadaten exportieren",
"@entryInfoActionExportMetadata": {}
} }

View file

@ -1154,5 +1154,23 @@
"tagPlaceholderCountry": "Χώρα", "tagPlaceholderCountry": "Χώρα",
"@tagPlaceholderCountry": {}, "@tagPlaceholderCountry": {},
"tagPlaceholderPlace": "Μέρος", "tagPlaceholderPlace": "Μέρος",
"@tagPlaceholderPlace": {} "@tagPlaceholderPlace": {},
"settingsWidgetDisplayedItem": "Εμφανιζόμενο αρχείο",
"@settingsWidgetDisplayedItem": {},
"tagEditorSectionPlaceholders": "Καταχώρηση τοποθεσίας",
"@tagEditorSectionPlaceholders": {},
"subtitlePositionBottom": "Κάτω",
"@subtitlePositionBottom": {},
"settingsSubtitleThemeTextPositionTile": "Θέση κειμένου",
"@settingsSubtitleThemeTextPositionTile": {},
"subtitlePositionTop": "Πάνω",
"@subtitlePositionTop": {},
"widgetDisplayedItemRandom": "Τυχαίο",
"@widgetDisplayedItemRandom": {},
"widgetDisplayedItemMostRecent": "Πιο πρόσφατο",
"@widgetDisplayedItemMostRecent": {},
"settingsSubtitleThemeTextPositionDialogTitle": "Θεση κειμενου",
"@settingsSubtitleThemeTextPositionDialogTitle": {},
"entryInfoActionExportMetadata": "Εξαγωγή μεταδεδομένων",
"@entryInfoActionExportMetadata": {}
} }

View file

@ -122,6 +122,7 @@
"entryInfoActionEditRating": "Edit rating", "entryInfoActionEditRating": "Edit rating",
"entryInfoActionEditTags": "Edit tags", "entryInfoActionEditTags": "Edit tags",
"entryInfoActionRemoveMetadata": "Remove metadata", "entryInfoActionRemoveMetadata": "Remove metadata",
"entryInfoActionExportMetadata": "Export metadata",
"filterBinLabel": "Recycle bin", "filterBinLabel": "Recycle bin",
"filterFavouriteLabel": "Favorite", "filterFavouriteLabel": "Favorite",
@ -197,6 +198,9 @@
"displayRefreshRatePreferHighest": "Highest rate", "displayRefreshRatePreferHighest": "Highest rate",
"displayRefreshRatePreferLowest": "Lowest rate", "displayRefreshRatePreferLowest": "Lowest rate",
"subtitlePositionTop": "Top",
"subtitlePositionBottom": "Bottom",
"videoPlaybackSkip": "Skip", "videoPlaybackSkip": "Skip",
"videoPlaybackMuted": "Play muted", "videoPlaybackMuted": "Play muted",
"videoPlaybackWithSound": "Play with sound", "videoPlaybackWithSound": "Play with sound",
@ -215,6 +219,9 @@
"wallpaperTargetLock": "Lock screen", "wallpaperTargetLock": "Lock screen",
"wallpaperTargetHomeLock": "Home and lock screens", "wallpaperTargetHomeLock": "Home and lock screens",
"widgetDisplayedItemRandom": "Random",
"widgetDisplayedItemMostRecent": "Most recent",
"widgetOpenPageHome": "Open home", "widgetOpenPageHome": "Open home",
"widgetOpenPageCollection": "Open collection", "widgetOpenPageCollection": "Open collection",
"widgetOpenPageViewer": "Open viewer", "widgetOpenPageViewer": "Open viewer",
@ -737,6 +744,8 @@
"settingsSubtitleThemeSample": "This is a sample.", "settingsSubtitleThemeSample": "This is a sample.",
"settingsSubtitleThemeTextAlignmentTile": "Text alignment", "settingsSubtitleThemeTextAlignmentTile": "Text alignment",
"settingsSubtitleThemeTextAlignmentDialogTitle": "Text Alignment", "settingsSubtitleThemeTextAlignmentDialogTitle": "Text Alignment",
"settingsSubtitleThemeTextPositionTile": "Text position",
"settingsSubtitleThemeTextPositionDialogTitle": "Text Position",
"settingsSubtitleThemeTextSize": "Text size", "settingsSubtitleThemeTextSize": "Text size",
"settingsSubtitleThemeShowOutline": "Show outline and shadow", "settingsSubtitleThemeShowOutline": "Show outline and shadow",
"settingsSubtitleThemeTextColor": "Text color", "settingsSubtitleThemeTextColor": "Text color",
@ -805,6 +814,7 @@
"settingsWidgetPageTitle": "Photo Frame", "settingsWidgetPageTitle": "Photo Frame",
"settingsWidgetShowOutline": "Outline", "settingsWidgetShowOutline": "Outline",
"settingsWidgetOpenPage": "When tapping on the widget", "settingsWidgetOpenPage": "When tapping on the widget",
"settingsWidgetDisplayedItem": "Displayed item",
"settingsCollectionTile": "Collection", "settingsCollectionTile": "Collection",

View file

@ -1156,5 +1156,21 @@
"tagEditorSectionPlaceholders": "Marcadores de la posición", "tagEditorSectionPlaceholders": "Marcadores de la posición",
"@tagEditorSectionPlaceholders": {}, "@tagEditorSectionPlaceholders": {},
"settingsAllowMediaManagement": "Permitir la gestión de medios", "settingsAllowMediaManagement": "Permitir la gestión de medios",
"@settingsAllowMediaManagement": {} "@settingsAllowMediaManagement": {},
"entryInfoActionExportMetadata": "Exportar los metadatos",
"@entryInfoActionExportMetadata": {},
"subtitlePositionTop": "Parte superior",
"@subtitlePositionTop": {},
"subtitlePositionBottom": "Inferior",
"@subtitlePositionBottom": {},
"settingsSubtitleThemeTextPositionTile": "Posición del texto",
"@settingsSubtitleThemeTextPositionTile": {},
"widgetDisplayedItemMostRecent": "Más reciente",
"@widgetDisplayedItemMostRecent": {},
"widgetDisplayedItemRandom": "Aleatorio",
"@widgetDisplayedItemRandom": {},
"settingsSubtitleThemeTextPositionDialogTitle": "Posición del texto",
"@settingsSubtitleThemeTextPositionDialogTitle": {},
"settingsWidgetDisplayedItem": "Elemento para mostrar",
"@settingsWidgetDisplayedItem": {}
} }

View file

@ -1156,5 +1156,21 @@
"tagPlaceholderCountry": "Pays", "tagPlaceholderCountry": "Pays",
"@tagPlaceholderCountry": {}, "@tagPlaceholderCountry": {},
"settingsAllowMediaManagement": "Autoriser la gestion des médias", "settingsAllowMediaManagement": "Autoriser la gestion des médias",
"@settingsAllowMediaManagement": {} "@settingsAllowMediaManagement": {},
"subtitlePositionTop": "Haut",
"@subtitlePositionTop": {},
"subtitlePositionBottom": "Bas",
"@subtitlePositionBottom": {},
"settingsSubtitleThemeTextPositionTile": "Position du texte",
"@settingsSubtitleThemeTextPositionTile": {},
"widgetDisplayedItemMostRecent": "Le plus récent",
"@widgetDisplayedItemMostRecent": {},
"settingsWidgetDisplayedItem": "Élément affiché",
"@settingsWidgetDisplayedItem": {},
"widgetDisplayedItemRandom": "Aléatoire",
"@widgetDisplayedItemRandom": {},
"settingsSubtitleThemeTextPositionDialogTitle": "Position du texte",
"@settingsSubtitleThemeTextPositionDialogTitle": {},
"entryInfoActionExportMetadata": "Exporter les métadonnées",
"@entryInfoActionExportMetadata": {}
} }

View file

@ -325,7 +325,7 @@
"@otherDirectoryDescription": {}, "@otherDirectoryDescription": {},
"storageAccessDialogMessage": "Si prega di selezionare la {directory} di «{volume}» nella prossima schermata per dare accesso a questa applicazione.", "storageAccessDialogMessage": "Si prega di selezionare la {directory} di «{volume}» nella prossima schermata per dare accesso a questa applicazione.",
"@storageAccessDialogMessage": {}, "@storageAccessDialogMessage": {},
"restrictedAccessDialogMessage": "Questa applicazione non è autorizzata a modificare i file nella {directory} di «{volume}».\n\nUtilizzare un gestore di file o unapplicazione di galleria preinstallata per spostare gli elementi in unaltra directory.", "restrictedAccessDialogMessage": "Questa applicazione non è autorizzata a modificare i file nella {directory} di «{volume}».\n\nUsa un gestore file o unapp galleria preinstallata per spostare gli elementi in unaltra cartella.",
"@restrictedAccessDialogMessage": {}, "@restrictedAccessDialogMessage": {},
"notEnoughSpaceDialogMessage": "Questa operazione ha bisogno di {neededSize} di spazio libero su «{volume}» per essere completata, ma è rimasto solo {freeSize}.", "notEnoughSpaceDialogMessage": "Questa operazione ha bisogno di {neededSize} di spazio libero su «{volume}» per essere completata, ma è rimasto solo {freeSize}.",
"@notEnoughSpaceDialogMessage": {}, "@notEnoughSpaceDialogMessage": {},
@ -449,7 +449,7 @@
"@videoStreamSelectionDialogAudio": {}, "@videoStreamSelectionDialogAudio": {},
"videoStreamSelectionDialogText": "Sottotitoli", "videoStreamSelectionDialogText": "Sottotitoli",
"@videoStreamSelectionDialogText": {}, "@videoStreamSelectionDialogText": {},
"videoStreamSelectionDialogOff": "Off", "videoStreamSelectionDialogOff": "Spento",
"@videoStreamSelectionDialogOff": {}, "@videoStreamSelectionDialogOff": {},
"videoStreamSelectionDialogTrack": "Traccia", "videoStreamSelectionDialogTrack": "Traccia",
"@videoStreamSelectionDialogTrack": {}, "@videoStreamSelectionDialogTrack": {},
@ -1156,5 +1156,21 @@
"widgetOpenPageCollection": "Apri collezione", "widgetOpenPageCollection": "Apri collezione",
"@widgetOpenPageCollection": {}, "@widgetOpenPageCollection": {},
"editEntryLocationDialogSetCustom": "Imposta posizione personalizzata", "editEntryLocationDialogSetCustom": "Imposta posizione personalizzata",
"@editEntryLocationDialogSetCustom": {} "@editEntryLocationDialogSetCustom": {},
"entryInfoActionExportMetadata": "Esporta metadati",
"@entryInfoActionExportMetadata": {},
"subtitlePositionTop": "In cima",
"@subtitlePositionTop": {},
"widgetDisplayedItemMostRecent": "Più recente",
"@widgetDisplayedItemMostRecent": {},
"widgetDisplayedItemRandom": "Casuale",
"@widgetDisplayedItemRandom": {},
"settingsSubtitleThemeTextPositionTile": "Posizione testo",
"@settingsSubtitleThemeTextPositionTile": {},
"subtitlePositionBottom": "In basso",
"@subtitlePositionBottom": {},
"settingsSubtitleThemeTextPositionDialogTitle": "Posizione testo",
"@settingsSubtitleThemeTextPositionDialogTitle": {},
"settingsWidgetDisplayedItem": "Elemento visualizzato",
"@settingsWidgetDisplayedItem": {}
} }

View file

@ -1156,5 +1156,21 @@
"tagPlaceholderPlace": "장소", "tagPlaceholderPlace": "장소",
"@tagPlaceholderPlace": {}, "@tagPlaceholderPlace": {},
"settingsAllowMediaManagement": "미디어 관리 허용", "settingsAllowMediaManagement": "미디어 관리 허용",
"@settingsAllowMediaManagement": {} "@settingsAllowMediaManagement": {},
"subtitlePositionTop": "위",
"@subtitlePositionTop": {},
"settingsSubtitleThemeTextPositionTile": "수직 정렬",
"@settingsSubtitleThemeTextPositionTile": {},
"widgetDisplayedItemMostRecent": "최신",
"@widgetDisplayedItemMostRecent": {},
"subtitlePositionBottom": "아래",
"@subtitlePositionBottom": {},
"widgetDisplayedItemRandom": "무작위로",
"@widgetDisplayedItemRandom": {},
"settingsSubtitleThemeTextPositionDialogTitle": "수직 정렬",
"@settingsSubtitleThemeTextPositionDialogTitle": {},
"settingsWidgetDisplayedItem": "표시될 항목",
"@settingsWidgetDisplayedItem": {},
"entryInfoActionExportMetadata": "메타데이터 내보내기",
"@entryInfoActionExportMetadata": {}
} }

718
lib/l10n/app_ro.arb Normal file
View file

@ -0,0 +1,718 @@
{
"welcomeMessage": "Bun venit la Aves",
"@welcomeMessage": {},
"welcomeOptional": "Opțional",
"@welcomeOptional": {},
"welcomeTermsToggle": "Sunt de acord cu Termenii și condițiile",
"@welcomeTermsToggle": {},
"timeSeconds": "{seconds, plural, =1{1 secundă} other{{seconds} secunde}}",
"@timeSeconds": {
"placeholders": {
"seconds": {}
}
},
"timeMinutes": "{minutes, plural, =1{1 minut} other{{minutes} minute}}",
"@timeMinutes": {
"placeholders": {
"minutes": {}
}
},
"timeDays": "{days, plural, =1{1 zi} other{{days} zile}}",
"@timeDays": {
"placeholders": {
"days": {}
}
},
"focalLength": "{length} mm",
"@focalLength": {
"placeholders": {
"length": {
"type": "String",
"example": "5.4"
}
}
},
"applyButtonLabel": "APLICA",
"@applyButtonLabel": {},
"deleteButtonLabel": "ȘTERGE",
"@deleteButtonLabel": {},
"nextButtonLabel": "URMĂTORUL",
"@nextButtonLabel": {},
"showButtonLabel": "SPECTACOL",
"@showButtonLabel": {},
"hideButtonLabel": "ASCUNDE",
"@hideButtonLabel": {},
"continueButtonLabel": "CONTINUA",
"@continueButtonLabel": {},
"cancelTooltip": "Anulare",
"@cancelTooltip": {},
"changeTooltip": "Schimbare",
"@changeTooltip": {},
"previousTooltip": "Anterior",
"@previousTooltip": {},
"nextTooltip": "Următorul",
"@nextTooltip": {},
"showTooltip": "Spectacol",
"@showTooltip": {},
"hideTooltip": "Ascunde",
"@hideTooltip": {},
"actionRemove": "Elimina",
"@actionRemove": {},
"resetTooltip": "Resetați",
"@resetTooltip": {},
"saveTooltip": "Salvați",
"@saveTooltip": {},
"pickTooltip": "Alege",
"@pickTooltip": {},
"doubleBackExitMessage": "Atingeți „înapoi” din nou pentru a ieși.",
"@doubleBackExitMessage": {},
"doNotAskAgain": "Nu cere din nou",
"@doNotAskAgain": {},
"sourceStateLoading": "Se încarcă",
"@sourceStateLoading": {},
"sourceStateCataloguing": "Catalogare",
"@sourceStateCataloguing": {},
"sourceStateLocatingCountries": "Localizarea țărilor",
"@sourceStateLocatingCountries": {},
"sourceStateLocatingPlaces": "Localizarea locurilor",
"@sourceStateLocatingPlaces": {},
"chipActionDelete": "Șterge",
"@chipActionDelete": {},
"chipActionGoToAlbumPage": "Afișați în albume",
"@chipActionGoToAlbumPage": {},
"chipActionGoToTagPage": "Afișați în etichete",
"@chipActionGoToTagPage": {},
"chipActionGoToCountryPage": "Adișați în țări",
"@chipActionGoToCountryPage": {},
"chipActionFilterOut": "Filtre de ieșire",
"@chipActionFilterOut": {},
"chipActionFilterIn": "Filtre de intrare",
"@chipActionFilterIn": {},
"chipActionHide": "Ascunde",
"@chipActionHide": {},
"chipActionPin": "Fixați sus",
"@chipActionPin": {},
"chipActionUnpin": "Anulați fixarea de sus",
"@chipActionUnpin": {},
"chipActionRename": "Redenumiți",
"@chipActionRename": {},
"chipActionSetCover": "Setați capacul",
"@chipActionSetCover": {},
"chipActionCreateAlbum": "Creați album",
"@chipActionCreateAlbum": {},
"entryActionCopyToClipboard": "Copiați în clipboard",
"@entryActionCopyToClipboard": {},
"entryActionDelete": "Șterge",
"@entryActionDelete": {},
"entryActionConvert": "Convertit",
"@entryActionConvert": {},
"entryActionExport": "Export",
"@entryActionExport": {},
"entryActionInfo": "Info",
"@entryActionInfo": {},
"entryActionRename": "Redenumiți",
"@entryActionRename": {},
"entryActionRotateCCW": "Rotiți în sens invers acelor de ceasornic",
"@entryActionRotateCCW": {},
"entryActionFlip": "Întoarceți pe orizontală",
"@entryActionFlip": {},
"entryActionPrint": "Imprimare",
"@entryActionPrint": {},
"entryActionShare": "Partajare",
"@entryActionShare": {},
"entryActionConvertMotionPhotoToStillImage": "Convertiți în imagine statică",
"@entryActionConvertMotionPhotoToStillImage": {},
"entryActionViewMotionPhotoVideo": "Deschide videoclipul",
"@entryActionViewMotionPhotoVideo": {},
"entryActionEdit": "Editați",
"@entryActionEdit": {},
"entryActionOpen": "Deschide cu",
"@entryActionOpen": {},
"entryActionSetAs": "Setați ca",
"@entryActionSetAs": {},
"entryActionOpenMap": "Afișați în aplicația pentru hartă",
"@entryActionOpenMap": {},
"entryActionRotateScreen": "Rotiți ecranul",
"@entryActionRotateScreen": {},
"entryActionAddFavourite": "Adauga la favorite",
"@entryActionAddFavourite": {},
"videoActionCaptureFrame": "Captură cadru",
"@videoActionCaptureFrame": {},
"videoActionUnmute": "Activați sunetul",
"@videoActionUnmute": {},
"videoActionMute": "Dezactivați sunetul",
"@videoActionMute": {},
"videoActionPause": "Pauză",
"@videoActionPause": {},
"videoActionPlay": "Redă",
"@videoActionPlay": {},
"videoActionReplay10": "Căutați înapoi 10 secunde",
"@videoActionReplay10": {},
"videoActionSkip10": "Căutați înainte 10 secunde",
"@videoActionSkip10": {},
"videoActionSelectStreams": "Selectați piese",
"@videoActionSelectStreams": {},
"videoActionSettings": "Setări",
"@videoActionSettings": {},
"slideshowActionResume": "Reluare",
"@slideshowActionResume": {},
"slideshowActionShowInCollection": "Afișați în colecție",
"@slideshowActionShowInCollection": {},
"entryInfoActionEditDate": "Editați data și ora",
"@entryInfoActionEditDate": {},
"entryInfoActionEditLocation": "Editați locația",
"@entryInfoActionEditLocation": {},
"entryInfoActionEditTitleDescription": "Editați titlul și descrierea",
"@entryInfoActionEditTitleDescription": {},
"entryInfoActionEditRating": "Editați evaluarea",
"@entryInfoActionEditRating": {},
"entryInfoActionRemoveMetadata": "Eliminați metadatele",
"@entryInfoActionRemoveMetadata": {},
"filterBinLabel": "Cos de gunoi",
"@filterBinLabel": {},
"filterFavouriteLabel": "Favorit",
"@filterFavouriteLabel": {},
"filterNoDateLabel": "Nedatat",
"@filterNoDateLabel": {},
"filterNoLocationLabel": "Nelocat",
"@filterNoLocationLabel": {},
"filterNoRatingLabel": "Neevaluat",
"@filterNoRatingLabel": {},
"filterNoTagLabel": "Neetichetat",
"@filterNoTagLabel": {},
"filterNoTitleLabel": "Fără titlu",
"@filterNoTitleLabel": {},
"filterOnThisDayLabel": "În această zi",
"@filterOnThisDayLabel": {},
"filterRatingRejectedLabel": "Respins",
"@filterRatingRejectedLabel": {},
"filterTypeAnimatedLabel": "Animații",
"@filterTypeAnimatedLabel": {},
"filterTypeMotionPhotoLabel": "Fotografie în mișcare",
"@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panoramă",
"@filterTypePanoramaLabel": {},
"filterTypeRawLabel": "Raw",
"@filterTypeRawLabel": {},
"filterTypeSphericalVideoLabel": "Video 360°",
"@filterTypeSphericalVideoLabel": {},
"filterTypeGeotiffLabel": "GeoTIFF",
"@filterTypeGeotiffLabel": {},
"filterMimeImageLabel": "Imagine",
"@filterMimeImageLabel": {},
"filterMimeVideoLabel": "Video",
"@filterMimeVideoLabel": {},
"coordinateFormatDms": "DMS",
"@coordinateFormatDms": {},
"coordinateFormatDecimal": "Grade zecimale",
"@coordinateFormatDecimal": {},
"coordinateDms": "{coordinate} {direction}",
"@coordinateDms": {
"placeholders": {
"coordinate": {
"type": "String",
"example": "38° 41 47.72″"
},
"direction": {
"type": "String",
"example": "S"
}
}
},
"coordinateDmsNorth": "N",
"@coordinateDmsNorth": {},
"coordinateDmsSouth": "S",
"@coordinateDmsSouth": {},
"coordinateDmsEast": "E",
"@coordinateDmsEast": {},
"coordinateDmsWest": "W",
"@coordinateDmsWest": {},
"unitSystemMetric": "Metric",
"@unitSystemMetric": {},
"unitSystemImperial": "Imperial",
"@unitSystemImperial": {},
"videoLoopModeNever": "Niciodată",
"@videoLoopModeNever": {},
"videoLoopModeShortOnly": "Numai videoclipuri scurte",
"@videoLoopModeShortOnly": {},
"videoLoopModeAlways": "Mereu",
"@videoLoopModeAlways": {},
"videoControlsPlay": "Redă",
"@videoControlsPlay": {},
"videoControlsPlaySeek": "Redați și căutați înapoi/înainte",
"@videoControlsPlaySeek": {},
"videoControlsPlayOutside": "Deschide cu alt player",
"@videoControlsPlayOutside": {},
"videoControlsNone": "Nici unul",
"@videoControlsNone": {},
"mapStyleGoogleNormal": "Hărți Google",
"@mapStyleGoogleNormal": {},
"mapStyleGoogleHybrid": "Hărți Google (hibrid)",
"@mapStyleGoogleHybrid": {},
"mapStyleGoogleTerrain": "Hărți Google (Teren)",
"@mapStyleGoogleTerrain": {},
"mapStyleHuaweiNormal": "Petal Maps",
"@mapStyleHuaweiNormal": {},
"mapStyleHuaweiTerrain": "Petal Maps (Teren)",
"@mapStyleHuaweiTerrain": {},
"mapStyleOsmHot": "OSM umanitar",
"@mapStyleOsmHot": {},
"mapStyleStamenWatercolor": "Stamine Acuarela",
"@mapStyleStamenWatercolor": {},
"nameConflictStrategyRename": "Redenumiți",
"@nameConflictStrategyRename": {},
"mapStyleStamenToner": "Stamine Toner",
"@mapStyleStamenToner": {},
"nameConflictStrategyReplace": "Înlocuiți",
"@nameConflictStrategyReplace": {},
"nameConflictStrategySkip": "Sări",
"@nameConflictStrategySkip": {},
"keepScreenOnNever": "Nu",
"@keepScreenOnNever": {},
"keepScreenOnViewerOnly": "Numai pagina de vizualizare",
"@keepScreenOnViewerOnly": {},
"keepScreenOnAlways": "Mereu",
"@keepScreenOnAlways": {},
"accessibilityAnimationsRemove": "Preveniți efectele ecranului",
"@accessibilityAnimationsRemove": {},
"accessibilityAnimationsKeep": "Păstrați efectele ecranului",
"@accessibilityAnimationsKeep": {},
"displayRefreshRatePreferHighest": "Rata cea mai mare",
"@displayRefreshRatePreferHighest": {},
"displayRefreshRatePreferLowest": "Rata cea mai mica",
"@displayRefreshRatePreferLowest": {},
"videoPlaybackSkip": "Sări",
"@videoPlaybackSkip": {},
"videoPlaybackMuted": "Redare fără sunet",
"@videoPlaybackMuted": {},
"videoPlaybackWithSound": "Redare cu sunet",
"@videoPlaybackWithSound": {},
"themeBrightnessDark": "Dark",
"@themeBrightnessDark": {},
"themeBrightnessLight": "Light",
"@themeBrightnessLight": {},
"themeBrightnessBlack": "Black",
"@themeBrightnessBlack": {},
"viewerTransitionSlide": "Slide",
"@viewerTransitionSlide": {},
"viewerTransitionParallax": "Paralaxă",
"@viewerTransitionParallax": {},
"viewerTransitionFade": "Decolorare",
"@viewerTransitionFade": {},
"viewerTransitionZoomIn": "Mărește zoom",
"@viewerTransitionZoomIn": {},
"viewerTransitionNone": "Nici unul",
"@viewerTransitionNone": {},
"wallpaperTargetHome": "Ecranul de start",
"@wallpaperTargetHome": {},
"wallpaperTargetLock": "Ecranul de blocare",
"@wallpaperTargetLock": {},
"wallpaperTargetHomeLock": "Ecranul de start și de blocare",
"@wallpaperTargetHomeLock": {},
"widgetOpenPageHome": "Deschide acasă",
"@widgetOpenPageHome": {},
"widgetOpenPageCollection": "Deschide colecții",
"@widgetOpenPageCollection": {},
"widgetOpenPageViewer": "Deschide vizualizatorul",
"@widgetOpenPageViewer": {},
"albumTierNew": "Nou",
"@albumTierNew": {},
"albumTierPinned": "Fixat",
"@albumTierPinned": {},
"albumTierSpecial": "Uzual",
"@albumTierSpecial": {},
"albumTierApps": "Aplicații",
"@albumTierApps": {},
"albumTierRegular": "Alții",
"@albumTierRegular": {},
"storageVolumeDescriptionFallbackPrimary": "Stocare internă",
"@storageVolumeDescriptionFallbackPrimary": {},
"storageVolumeDescriptionFallbackNonPrimary": "card SD",
"@storageVolumeDescriptionFallbackNonPrimary": {},
"rootDirectoryDescription": "directorul rădăcină",
"@rootDirectoryDescription": {},
"otherDirectoryDescription": "directorul „{name}”",
"@otherDirectoryDescription": {
"placeholders": {
"name": {
"type": "String",
"example": "Pictures",
"description": "the name of a specific directory"
}
}
},
"notEnoughSpaceDialogMessage": "Această operațiune are nevoie de {neededSize} spațiu liber pe „{volume}” pentru a fi finalizată, dar a mai rămas doar {freeSize}.",
"@notEnoughSpaceDialogMessage": {
"placeholders": {
"neededSize": {
"type": "String",
"example": "314 MB"
},
"freeSize": {
"type": "String",
"example": "123 MB"
},
"volume": {
"type": "String",
"example": "SD card",
"description": "the name of a storage volume"
}
}
},
"missingSystemFilePickerDialogMessage": "Selectorul de fișiere de sistem lipsește sau este dezactivat. Activați-l și încercați din nou.",
"@missingSystemFilePickerDialogMessage": {},
"nameConflictDialogSingleSourceMessage": "Unele fișiere din folderul de destinație au același nume.",
"@nameConflictDialogSingleSourceMessage": {},
"nameConflictDialogMultipleSourceMessage": "Unele fișiere au același nume.",
"@nameConflictDialogMultipleSourceMessage": {},
"addShortcutDialogLabel": "Etichetă de comandă rapidă",
"@addShortcutDialogLabel": {},
"addShortcutButtonLabel": "ADD",
"@addShortcutButtonLabel": {},
"noMatchingAppDialogMessage": "Nu există aplicații care să se ocupe de asta.",
"@noMatchingAppDialogMessage": {},
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Mutați acest articol în coșul de reciclare?} other{Mutați aceste {count} articole în coșul de reciclare?}}",
"@binEntriesConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Ștergeți acest articol?} other{Ștergeți aceste {count} articole?}}",
"@deleteEntriesConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"moveUndatedConfirmationDialogMessage": "Salvați datele articolului înainte de a continua?",
"@moveUndatedConfirmationDialogMessage": {},
"moveUndatedConfirmationDialogSetDate": "Salvați datele",
"@moveUndatedConfirmationDialogSetDate": {},
"videoResumeDialogMessage": "Doriți să reluați redarea la {time}?",
"@videoResumeDialogMessage": {
"placeholders": {
"time": {
"type": "String",
"example": "13:37"
}
}
},
"videoStartOverButtonLabel": "Începe din nou",
"@videoStartOverButtonLabel": {},
"videoResumeButtonLabel": "Reluați",
"@videoResumeButtonLabel": {},
"setCoverDialogLatest": "Ultimul articol",
"@setCoverDialogLatest": {},
"setCoverDialogAuto": "Auto",
"@setCoverDialogAuto": {},
"setCoverDialogCustom": "Personalizat",
"@setCoverDialogCustom": {},
"hideFilterConfirmationDialogMessage": "Fotografiile și videoclipurile care se potrivesc vor fi ascunse din colecția ta. Le puteți afișa din nou din setările „Confidențialitate”.\n\n Ești sigur că vrei să le ascunzi?",
"@hideFilterConfirmationDialogMessage": {},
"newAlbumDialogTitle": "Album nou",
"@newAlbumDialogTitle": {},
"newAlbumDialogNameLabel": "Numele albumului",
"@newAlbumDialogNameLabel": {},
"newAlbumDialogNameLabelAlreadyExistsHelper": "Directorul există deja",
"@newAlbumDialogNameLabelAlreadyExistsHelper": {},
"newAlbumDialogStorageLabel": "Depozitare:",
"@newAlbumDialogStorageLabel": {},
"renameAlbumDialogLabel": "Nume nou",
"@renameAlbumDialogLabel": {},
"renameAlbumDialogLabelAlreadyExistsHelper": "Directorul există deja",
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
"renameEntrySetPageTitle": "Redenumiți",
"@renameEntrySetPageTitle": {},
"renameEntrySetPagePatternFieldLabel": "Model de denumire",
"@renameEntrySetPagePatternFieldLabel": {},
"renameEntrySetPageInsertTooltip": "Inserați câmp",
"@renameEntrySetPageInsertTooltip": {},
"renameEntrySetPagePreviewSectionTitle": "previzualizare",
"@renameEntrySetPagePreviewSectionTitle": {},
"renameProcessorCounter": "Tejghea",
"@renameProcessorCounter": {},
"renameProcessorName": "Nume",
"@renameProcessorName": {},
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Ștergeți acest album și articolul său?} other{Ștergeți acest album și {count} articole ale acestuia?}}",
"@deleteSingleAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Delete these albums and their item?} other{Delete these albums and their {count} items?}}",
"@deleteMultiAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"exportEntryDialogFormat": "Format:",
"@exportEntryDialogFormat": {},
"exportEntryDialogWidth": "Lăţime",
"@exportEntryDialogWidth": {},
"exportEntryDialogHeight": "Înălţime",
"@exportEntryDialogHeight": {},
"renameEntryDialogLabel": "Nume nou",
"@renameEntryDialogLabel": {},
"editEntryDialogCopyFromItem": "Copiați din alt articol",
"@editEntryDialogCopyFromItem": {},
"editEntryDialogTargetFieldsHeader": "Câmpuri de modificat",
"@editEntryDialogTargetFieldsHeader": {},
"editEntryDateDialogTitle": "Data și ora",
"@editEntryDateDialogTitle": {},
"editEntryDateDialogSetCustom": "Setați o dată personalizată",
"@editEntryDateDialogSetCustom": {},
"editEntryDateDialogCopyField": "Copie de la altă dată",
"@editEntryDateDialogCopyField": {},
"editEntryDateDialogExtractFromTitle": "Extras din titlu",
"@editEntryDateDialogExtractFromTitle": {},
"editEntryDateDialogShift": "Schimbă",
"@editEntryDateDialogShift": {},
"editEntryDateDialogSourceFileModifiedDate": "Data modificării fișierului",
"@editEntryDateDialogSourceFileModifiedDate": {},
"durationDialogHours": "Ore",
"@durationDialogHours": {},
"durationDialogMinutes": "Minute",
"@durationDialogMinutes": {},
"durationDialogSeconds": "secunde",
"@durationDialogSeconds": {},
"editEntryLocationDialogTitle": "Locație",
"@editEntryLocationDialogTitle": {},
"editEntryLocationDialogSetCustom": "Setați locația personalizată",
"@editEntryLocationDialogSetCustom": {},
"editEntryLocationDialogChooseOnMap": "Alegeți pe hartă",
"@editEntryLocationDialogChooseOnMap": {},
"editEntryLocationDialogLatitude": "Latitudine",
"@editEntryLocationDialogLatitude": {},
"editEntryLocationDialogLongitude": "Longitudine",
"@editEntryLocationDialogLongitude": {},
"locationPickerUseThisLocationButton": "Utilizați această locație",
"@locationPickerUseThisLocationButton": {},
"editEntryRatingDialogTitle": "Evaluare",
"@editEntryRatingDialogTitle": {},
"removeEntryMetadataDialogTitle": "Eliminarea metadatelor",
"@removeEntryMetadataDialogTitle": {},
"removeEntryMetadataDialogMore": "Mai mult",
"@removeEntryMetadataDialogMore": {},
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP este necesar pentru a reda videoclipul dintr-o fotografie în mișcare.\n\n Sunteți sigur că doriți să-l eliminați?",
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
"convertMotionPhotoToStillImageWarningDialogMessage": "Esti sigur?",
"@convertMotionPhotoToStillImageWarningDialogMessage": {},
"videoSpeedDialogLabel": "Viteza de redare",
"@videoSpeedDialogLabel": {},
"videoStreamSelectionDialogVideo": "Video",
"@videoStreamSelectionDialogVideo": {},
"videoStreamSelectionDialogAudio": "Audio",
"@videoStreamSelectionDialogAudio": {},
"videoStreamSelectionDialogText": "Subtitrări",
"@videoStreamSelectionDialogText": {},
"videoStreamSelectionDialogOff": "Oprit",
"@videoStreamSelectionDialogOff": {},
"videoStreamSelectionDialogTrack": "Track",
"@videoStreamSelectionDialogTrack": {},
"videoStreamSelectionDialogNoSelection": "Nu există alte piese.",
"@videoStreamSelectionDialogNoSelection": {},
"genericSuccessFeedback": "Terminat!",
"@genericSuccessFeedback": {},
"genericFailureFeedback": "Eșuat",
"@genericFailureFeedback": {},
"menuActionConfigureView": "Vedere",
"@menuActionConfigureView": {},
"menuActionSelect": "Selectați",
"@menuActionSelect": {},
"menuActionSelectAll": "Selectează tot",
"@menuActionSelectAll": {},
"menuActionSelectNone": "Nu selectați nimic",
"@menuActionSelectNone": {},
"menuActionMap": "Hartă",
"@menuActionMap": {},
"menuActionSlideshow": "Prezentare de diapozitive",
"@menuActionSlideshow": {},
"menuActionStats": "Statistici",
"@menuActionStats": {},
"viewDialogSortSectionTitle": "Sortează",
"@viewDialogSortSectionTitle": {},
"viewDialogGroupSectionTitle": "grup",
"@viewDialogGroupSectionTitle": {},
"viewDialogLayoutSectionTitle": "Aspect",
"@viewDialogLayoutSectionTitle": {},
"viewDialogReverseSortOrder": "Ordinea de sortare inversă",
"@viewDialogReverseSortOrder": {},
"tileLayoutMosaic": "Mozaic",
"@tileLayoutMosaic": {},
"tileLayoutGrid": "Grilă",
"@tileLayoutGrid": {},
"tileLayoutList": "Listă",
"@tileLayoutList": {},
"coverDialogTabCover": "Cover",
"@coverDialogTabCover": {},
"coverDialogTabApp": "App",
"@coverDialogTabApp": {},
"coverDialogTabColor": "Culoare",
"@coverDialogTabColor": {},
"appPickDialogTitle": "Alegeți aplicația",
"@appPickDialogTitle": {},
"appPickDialogNone": "Nici unul",
"@appPickDialogNone": {},
"aboutPageTitle": "Despre",
"@aboutPageTitle": {},
"aboutLinkLicense": "Licență",
"@aboutLinkLicense": {},
"aboutLinkPolicy": "Politica de Confidențialitate",
"@aboutLinkPolicy": {},
"aboutBugSectionTitle": "Raport de eroare",
"@aboutBugSectionTitle": {},
"aboutBugSaveLogInstruction": "Salvați jurnalele aplicației într-un fișier",
"@aboutBugSaveLogInstruction": {},
"aboutBugCopyInfoInstruction": "Copiați informațiile despre sistem",
"@aboutBugCopyInfoInstruction": {},
"aboutBugCopyInfoButton": "Copie",
"@aboutBugCopyInfoButton": {},
"aboutBugReportInstruction": "Raportați pe GitHub cu jurnalele și informațiile de sistem",
"@aboutBugReportInstruction": {},
"aboutBugReportButton": "Raport",
"@aboutBugReportButton": {},
"aboutCreditsSectionTitle": "credite",
"@aboutCreditsSectionTitle": {},
"aboutCreditsWorldAtlas1": "Această aplicație folosește un fișier TopoJSON de la",
"@aboutCreditsWorldAtlas1": {},
"aboutCreditsWorldAtlas2": "sub licență ISC.",
"@aboutCreditsWorldAtlas2": {},
"aboutTranslatorsSectionTitle": "Traducători",
"@aboutTranslatorsSectionTitle": {},
"aboutLicensesSectionTitle": "Licențe open-source",
"@aboutLicensesSectionTitle": {},
"aboutLicensesBanner": "Această aplicație folosește următoarele pachete și biblioteci open-source.",
"@aboutLicensesBanner": {},
"aboutLicensesAndroidLibrariesSectionTitle": "Biblioteci Android",
"@aboutLicensesAndroidLibrariesSectionTitle": {},
"aboutLicensesFlutterPluginsSectionTitle": "Pluginuri Flutter",
"@aboutLicensesFlutterPluginsSectionTitle": {},
"aboutLicensesFlutterPackagesSectionTitle": "Pachete Flutter",
"@aboutLicensesFlutterPackagesSectionTitle": {},
"aboutLicensesDartPackagesSectionTitle": "Pachete Dart",
"@aboutLicensesDartPackagesSectionTitle": {},
"aboutLicensesShowAllButtonLabel": "Afișați toate licențele",
"@aboutLicensesShowAllButtonLabel": {},
"collectionPageTitle": "Colectie",
"@collectionPageTitle": {},
"collectionSelectPageTitle": "Selectați articole",
"@collectionSelectPageTitle": {},
"collectionActionShowTitleSearch": "Afișează filtrul de titlu",
"@collectionActionShowTitleSearch": {},
"collectionActionHideTitleSearch": "Ascunde filtrul de titlu",
"@collectionActionHideTitleSearch": {},
"collectionActionAddShortcut": "Adauga scurtatura",
"@collectionActionAddShortcut": {},
"collectionActionEmptyBin": "Coșul gol",
"@collectionActionEmptyBin": {},
"collectionActionCopy": "Copiați în album",
"@collectionActionCopy": {},
"collectionActionMove": "Mutați la album",
"@collectionActionMove": {},
"collectionActionRescan": "Rescanați",
"@collectionActionRescan": {},
"collectionActionEdit": "Editați",
"@collectionActionEdit": {},
"collectionSearchTitlesHintText": "Căutați titluri",
"@collectionSearchTitlesHintText": {},
"collectionGroupAlbum": "După album",
"@collectionGroupAlbum": {},
"collectionGroupMonth": "După lună",
"@collectionGroupMonth": {},
"collectionGroupDay": "După zi",
"@collectionGroupDay": {},
"collectionGroupNone": "Nu grupați",
"@collectionGroupNone": {},
"sectionUnknown": "Necunoscut",
"@sectionUnknown": {},
"dateToday": "Astăzi",
"@dateToday": {},
"dateYesterday": "Ieri",
"@dateYesterday": {},
"dateThisMonth": "Luna aceasta",
"@dateThisMonth": {},
"collectionDeleteFailureFeedback": "{count, plural, =1{Failed to delete 1 item} other{Failed to delete {count} items}}",
"@collectionDeleteFailureFeedback": {
"placeholders": {
"count": {}
}
},
"collectionCopyFailureFeedback": "{count, plural, =1{Eșuat la copierea unui articol} other{Eșuat la copierea {count} articole}}",
"@collectionCopyFailureFeedback": {
"placeholders": {
"count": {}
}
},
"collectionMoveFailureFeedback": "{count, plural, =1{Nu s-au mutat 1 articol} other{Nu s-au mutat {count} articole}}",
"@collectionMoveFailureFeedback": {
"placeholders": {
"count": {}
}
},
"appName": "Aves",
"@appName": {},
"itemCount": "{count, plural, =1{1 item} other{{count} items}}",
"@itemCount": {
"placeholders": {
"count": {}
}
},
"restrictedAccessDialogMessage": "Această aplicație nu are permisiunea de a modifica fișiere din {directory} „{volume}”.\n\nUtilizați un manager de fișiere preinstalat sau o aplicație de galerie pentru a muta elementele într-un alt director.",
"@restrictedAccessDialogMessage": {
"placeholders": {
"directory": {
"type": "String",
"description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`"
},
"volume": {
"type": "String",
"example": "SD card",
"description": "the name of a storage volume"
}
}
},
"entryActionRotateCW": "Roteste in sensul acelor de ceasornic",
"@entryActionRotateCW": {},
"entryActionViewSource": "Vizualizare sursă",
"@entryActionViewSource": {},
"entryActionShowGeoTiffOnMap": "Afișați ca suprapunere a hărții",
"@entryActionShowGeoTiffOnMap": {},
"entryActionRestore": "Restabiliți",
"@entryActionRestore": {},
"entryActionRemoveFavourite": "Eliminați din favorite",
"@entryActionRemoveFavourite": {},
"videoActionSetSpeed": "Viteza de redare",
"@videoActionSetSpeed": {},
"entryInfoActionEditTags": "Editați etichetele",
"@entryInfoActionEditTags": {},
"filterRecentlyAddedLabel": "Adaugate recent",
"@filterRecentlyAddedLabel": {},
"storageAccessDialogMessage": "Vă rugăm să selectați {directory} „{volume}” în ecranul următor pentru a oferi acestei aplicații acces la acesta.",
"@storageAccessDialogMessage": {
"placeholders": {
"directory": {
"type": "String",
"description": "the name of a directory, using the output of `rootDirectoryDescription` or `otherDirectoryDescription`"
},
"volume": {
"type": "String",
"example": "SD card",
"description": "the name of a storage volume"
}
}
},
"unsupportedTypeDialogMessage": "{count, plural, =1{Această operațiune nu este acceptată pentru articole de următorul tip: {types}.} other{Această operațiune nu este acceptată pentru articole de următoarele tipuri: {types}.}}",
"@unsupportedTypeDialogMessage": {
"placeholders": {
"count": {},
"types": {
"type": "String",
"example": "GIF, TIFF, MP4",
"description": "a list of unsupported types"
}
}
},
"policyPageTitle": "Politica de Confidențialitate",
"@policyPageTitle": {},
"collectionPickPageTitle": "Alege",
"@collectionPickPageTitle": {}
}

View file

@ -1156,5 +1156,9 @@
"editEntryLocationDialogSetCustom": "Редактировать местоположение", "editEntryLocationDialogSetCustom": "Редактировать местоположение",
"@editEntryLocationDialogSetCustom": {}, "@editEntryLocationDialogSetCustom": {},
"settingsAllowMediaManagement": "Разрешить управление медиа", "settingsAllowMediaManagement": "Разрешить управление медиа",
"@settingsAllowMediaManagement": {} "@settingsAllowMediaManagement": {},
"entryInfoActionExportMetadata": "Экспорт метаданных",
"@entryInfoActionExportMetadata": {},
"subtitlePositionBottom": "Внизу",
"@subtitlePositionBottom": {}
} }

View file

@ -527,8 +527,6 @@
"@aboutLicensesSectionTitle": {}, "@aboutLicensesSectionTitle": {},
"aboutLicensesBanner": "本应用使用以下开源软件包和库", "aboutLicensesBanner": "本应用使用以下开源软件包和库",
"@aboutLicensesBanner": {}, "@aboutLicensesBanner": {},
"aboutLicensesAndroidLibrariesSectionTitle": "Android Libraries",
"@aboutLicensesAndroidLibrariesSectionTitle": {},
"aboutLicensesFlutterPluginsSectionTitle": "Flutter Plugins", "aboutLicensesFlutterPluginsSectionTitle": "Flutter Plugins",
"@aboutLicensesFlutterPluginsSectionTitle": {}, "@aboutLicensesFlutterPluginsSectionTitle": {},
"aboutLicensesFlutterPackagesSectionTitle": "Flutter Packages", "aboutLicensesFlutterPackagesSectionTitle": "Flutter Packages",
@ -1146,5 +1144,15 @@
"widgetOpenPageCollection": "打开媒体集", "widgetOpenPageCollection": "打开媒体集",
"@widgetOpenPageCollection": {}, "@widgetOpenPageCollection": {},
"durationDialogSeconds": "秒", "durationDialogSeconds": "秒",
"@durationDialogSeconds": {} "@durationDialogSeconds": {},
"settingsAllowMediaManagement": "允许媒体管理",
"@settingsAllowMediaManagement": {},
"tagEditorSectionPlaceholders": "占位符",
"@tagEditorSectionPlaceholders": {},
"editEntryLocationDialogSetCustom": "设置自定义位置",
"@editEntryLocationDialogSetCustom": {},
"tagPlaceholderCountry": "国家",
"@tagPlaceholderCountry": {},
"tagPlaceholderPlace": "地方",
"@tagPlaceholderPlace": {}
} }

11
lib/main_libre.dart Normal file
View file

@ -0,0 +1,11 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
import 'package:aves/widget_common.dart';
const _flavor = AppFlavor.libre;
@pragma('vm:entry-point')
void main() => mainCommon(_flavor);
@pragma('vm:entry-point')
void widgetMain() => widgetMainCommon(_flavor);

View file

@ -38,6 +38,19 @@ enum EntryAction {
setAs, setAs,
// platform // platform
rotateScreen, rotateScreen,
// metadata
editDate,
editLocation,
editTitleDescription,
editRating,
editTags,
removeMetadata,
exportMetadata,
// metadata / GeoTIFF
showGeoTiffOnMap,
// metadata / motion photo
convertMotionPhotoToStillImage,
viewMotionPhotoVideo,
// debug // debug
debug, debug,
} }
@ -99,6 +112,22 @@ class EntryActions {
EntryAction.videoSelectStreams, EntryAction.videoSelectStreams,
EntryAction.videoSettings, EntryAction.videoSettings,
]; ];
static const commonMetadataActions = [
EntryAction.editDate,
EntryAction.editLocation,
EntryAction.editTitleDescription,
EntryAction.editRating,
EntryAction.editTags,
EntryAction.removeMetadata,
EntryAction.exportMetadata,
];
static const formatSpecificMetadataActions = [
EntryAction.showGeoTiffOnMap,
EntryAction.convertMotionPhotoToStillImage,
EntryAction.viewMotionPhotoVideo,
];
} }
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {
@ -170,6 +199,29 @@ extension ExtraEntryAction on EntryAction {
// platform // platform
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
return context.l10n.entryActionRotateScreen; return context.l10n.entryActionRotateScreen;
// metadata
case EntryAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntryAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntryAction.editTitleDescription:
return context.l10n.entryInfoActionEditTitleDescription;
case EntryAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntryAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
case EntryAction.exportMetadata:
return context.l10n.entryInfoActionExportMetadata;
// metadata / GeoTIFF
case EntryAction.showGeoTiffOnMap:
return context.l10n.entryActionShowGeoTiffOnMap;
// metadata / motion photo
case EntryAction.convertMotionPhotoToStillImage:
return context.l10n.entryActionConvertMotionPhotoToStillImage;
case EntryAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
return 'Debug'; return 'Debug';
@ -258,6 +310,29 @@ extension ExtraEntryAction on EntryAction {
// platform // platform
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
return AIcons.rotateScreen; return AIcons.rotateScreen;
// metadata
case EntryAction.editDate:
return AIcons.date;
case EntryAction.editLocation:
return AIcons.location;
case EntryAction.editTitleDescription:
return AIcons.description;
case EntryAction.editRating:
return AIcons.editRating;
case EntryAction.editTags:
return AIcons.editTags;
case EntryAction.removeMetadata:
return AIcons.clear;
case EntryAction.exportMetadata:
return AIcons.fileExport;
// metadata / GeoTIFF
case EntryAction.showGeoTiffOnMap:
return AIcons.map;
// metadata / motion photo
case EntryAction.convertMotionPhotoToStillImage:
return AIcons.convertToStillImage;
case EntryAction.viewMotionPhotoVideo:
return AIcons.openVideo;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
return AIcons.debug; return AIcons.debug;

View file

@ -1,112 +0,0 @@
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum EntryInfoAction {
// general
editDate,
editLocation,
editTitleDescription,
editRating,
editTags,
removeMetadata,
// GeoTIFF
showGeoTiffOnMap,
// motion photo
convertMotionPhotoToStillImage,
viewMotionPhotoVideo,
// debug
debug,
}
class EntryInfoActions {
static const common = [
EntryInfoAction.editDate,
EntryInfoAction.editLocation,
EntryInfoAction.editTitleDescription,
EntryInfoAction.editRating,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
];
static const formatSpecific = [
EntryInfoAction.showGeoTiffOnMap,
EntryInfoAction.convertMotionPhotoToStillImage,
EntryInfoAction.viewMotionPhotoVideo,
];
}
extension ExtraEntryInfoAction on EntryInfoAction {
String getText(BuildContext context) {
switch (this) {
// general
case EntryInfoAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntryInfoAction.editTitleDescription:
return context.l10n.entryInfoActionEditTitleDescription;
case EntryInfoAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryInfoAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return context.l10n.entryActionShowGeoTiffOnMap;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return context.l10n.entryActionConvertMotionPhotoToStillImage;
case EntryInfoAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// debug
case EntryInfoAction.debug:
return 'Debug';
}
}
Widget getIcon() {
final child = Icon(_getIconData());
switch (this) {
case EntryInfoAction.debug:
return ShaderMask(
shaderCallback: AvesColorsData.debugGradient.createShader,
blendMode: BlendMode.srcIn,
child: child,
);
default:
return child;
}
}
IconData _getIconData() {
switch (this) {
// general
case EntryInfoAction.editDate:
return AIcons.date;
case EntryInfoAction.editLocation:
return AIcons.location;
case EntryInfoAction.editTitleDescription:
return AIcons.description;
case EntryInfoAction.editRating:
return AIcons.editRating;
case EntryInfoAction.editTags:
return AIcons.editTags;
case EntryInfoAction.removeMetadata:
return AIcons.clear;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return AIcons.map;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return AIcons.convertToStillImage;
case EntryInfoAction.viewMotionPhotoVideo:
return AIcons.openVideo;
// debug
case EntryInfoAction.debug:
return AIcons.debug;
}
}
}

154
lib/model/entry_info.dart Normal file
View file

@ -0,0 +1,154 @@
import 'dart:async';
import 'dart:collection';
import 'package:aves/model/entry.dart';
import 'package:aves/model/video/keys.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/svg_metadata_service.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
extension ExtraAvesEntryInfo on AvesEntry {
// directory names may contain the name of their parent directory (as prefix + '/')
// directory names may contain an index (as suffix in '[]')
static final directoryNamePattern = RegExp(r'^((?<parent>.*?)/)?(?<name>.*?)(\[(?<index>\d+)\])?$');
Future<List<MapEntry<String, MetadataDirectory>>> getMetadataDirectories(BuildContext context) async {
final rawMetadata = await (isSvg ? SvgMetadataService.getAllMetadata(this) : metadataFetchService.getAllMetadata(this));
final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String;
String? parent;
int? index;
final match = directoryNamePattern.firstMatch(directoryName);
if (match != null) {
parent = match.namedGroup('parent');
final nameMatch = match.namedGroup('name');
if (nameMatch != null) {
directoryName = nameMatch;
}
final indexMatch = match.namedGroup('index');
if (indexMatch != null) {
index = int.tryParse(indexMatch);
}
}
final rawTags = dirKV.value as Map;
return MetadataDirectory(
directoryName,
_toSortedTags(rawTags),
parent: parent,
index: index,
);
}).toList();
if (isVideo || (mimeType == MimeTypes.heif && isMultiPage)) {
directories.addAll(await _getStreamDirectories(context));
}
final titledDirectories = directories.map((dir) {
var title = dir.name;
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
title = '${dir.parent}/$title';
}
if (dir.index != null) {
title += ' ${dir.index}';
}
return MapEntry(title, dir);
}).toList()
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
return titledDirectories;
}
Future<List<MetadataDirectory>> _getStreamDirectories(BuildContext context) async {
final directories = <MetadataDirectory>[];
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(this);
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
if (formattedMediaTags.isNotEmpty) {
// overwrite generic directory found from the platform side
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags)));
}
if (mediaInfo.containsKey(Keys.streams)) {
String getTypeText(Map stream) {
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
switch (type) {
case StreamTypes.attachment:
return 'Attachment';
case StreamTypes.audio:
return 'Audio';
case StreamTypes.metadata:
return 'Metadata';
case StreamTypes.subtitle:
case StreamTypes.timedText:
return 'Text';
case StreamTypes.video:
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
case StreamTypes.unknown:
default:
return 'Unknown';
}
}
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
// display known streams as separate directories (e.g. video, audio, subs)
if (knownStreams.isNotEmpty) {
final indexDigits = knownStreams.length.toString().length;
final colors = context.read<AvesColorsData>();
for (final stream in knownStreams) {
final index = (stream[Keys.index] ?? 0) + 1;
final typeText = getTypeText(stream);
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')}$typeText';
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
if (formattedStreamTags.isNotEmpty) {
final color = colors.fromString(typeText);
directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color));
}
}
}
// group attachments by format (e.g. TTF fonts)
if (attachmentStreams.isNotEmpty) {
final formatCount = <String, List<String?>>{};
for (final stream in attachmentStreams) {
final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase();
if (!formatCount.containsKey(codec)) {
formatCount[codec] = [];
}
formatCount[codec]!.add(stream[Keys.filename]);
}
if (formatCount.isNotEmpty) {
final rawTags = formatCount.map((key, value) {
final count = value.length;
// remove duplicate names, so number of displayed names may not match displayed count
final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase);
return MapEntry(key, '$count items: ${names.join(', ')}');
});
directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags)));
}
}
}
return directories;
}
SplayTreeMap<String, String> _toSortedTags(Map rawTags) {
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
var value = (tagKV.value as String? ?? '').trim();
if (value.isEmpty) return null;
final tagName = tagKV.key as String;
return MapEntry(tagName, value);
}).whereNotNull()));
return tags;
}
}

View file

@ -7,7 +7,6 @@ import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:aves_map/aves_map.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SettingsDefaults { class SettingsDefaults {
@ -99,12 +98,12 @@ class SettingsDefaults {
// subtitles // subtitles
static const subtitleFontSize = 20.0; static const subtitleFontSize = 20.0;
static const subtitleTextAlignment = TextAlign.center; static const subtitleTextAlignment = TextAlign.center;
static const subtitleTextPosition = SubtitlePosition.bottom;
static const subtitleShowOutline = true; static const subtitleShowOutline = true;
static const subtitleTextColor = Colors.white; static const subtitleTextColor = Colors.white;
static const subtitleBackgroundColor = Colors.transparent; static const subtitleBackgroundColor = Colors.transparent;
// info // info
static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value
static const infoMapZoom = 12.0; static const infoMapZoom = 12.0;
static const coordinateFormat = CoordinateFormat.dms; static const coordinateFormat = CoordinateFormat.dms;
static const unitSystem = UnitSystem.metric; static const unitSystem = UnitSystem.metric;
@ -138,6 +137,7 @@ class SettingsDefaults {
static const widgetOutline = false; static const widgetOutline = false;
static const widgetShape = WidgetShape.rrect; static const widgetShape = WidgetShape.rrect;
static const widgetOpenPage = WidgetOpenPage.viewer; static const widgetOpenPage = WidgetOpenPage.viewer;
static const widgetDisplayedItem = WidgetDisplayedItem.random;
// platform settings // platform settings
static const isRotationLocked = false; static const isRotationLocked = false;

View file

@ -20,6 +20,8 @@ enum KeepScreenOn { never, viewerOnly, always }
enum SlideshowVideoPlayback { skip, playMuted, playWithSound } enum SlideshowVideoPlayback { skip, playMuted, playWithSound }
enum SubtitlePosition { top, bottom }
enum UnitSystem { metric, imperial } enum UnitSystem { metric, imperial }
enum VideoControls { play, playSeek, playOutside, none } enum VideoControls { play, playSeek, playOutside, none }
@ -30,6 +32,8 @@ enum VideoAutoPlayMode { disabled, playMuted, playWithSound }
enum ViewerTransition { slide, parallax, fade, zoomIn, none } enum ViewerTransition { slide, parallax, fade, zoomIn, none }
enum WidgetDisplayedItem { random, mostRecent }
enum WidgetOpenPage { home, collection, viewer } enum WidgetOpenPage { home, collection, viewer }
enum WidgetShape { rrect, circle, heart } enum WidgetShape { rrect, circle, heart }

View file

@ -3,6 +3,19 @@ import 'package:aves_map/aves_map.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
extension ExtraEntryMapStyle on EntryMapStyle { extension ExtraEntryMapStyle on EntryMapStyle {
static bool isHeavy(EntryMapStyle? style) {
switch (style) {
case EntryMapStyle.googleNormal:
case EntryMapStyle.googleHybrid:
case EntryMapStyle.googleTerrain:
case EntryMapStyle.hmsNormal:
case EntryMapStyle.hmsTerrain:
return true;
default:
return false;
}
}
String getName(BuildContext context) { String getName(BuildContext context) {
switch (this) { switch (this) {
case EntryMapStyle.googleNormal: case EntryMapStyle.googleNormal:
@ -24,19 +37,6 @@ extension ExtraEntryMapStyle on EntryMapStyle {
} }
} }
bool get isHeavy {
switch (this) {
case EntryMapStyle.googleNormal:
case EntryMapStyle.googleHybrid:
case EntryMapStyle.googleTerrain:
case EntryMapStyle.hmsNormal:
case EntryMapStyle.hmsTerrain:
return true;
default:
return false;
}
}
bool get needMobileService { bool get needMobileService {
switch (this) { switch (this) {
case EntryMapStyle.osmHot: case EntryMapStyle.osmHot:

View file

@ -0,0 +1,24 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraSubtitlePosition on SubtitlePosition {
String getName(BuildContext context) {
switch (this) {
case SubtitlePosition.top:
return context.l10n.subtitlePositionTop;
case SubtitlePosition.bottom:
return context.l10n.subtitlePositionBottom;
}
}
TextAlignVertical toTextAlignVertical() {
switch (this) {
case SubtitlePosition.top:
return TextAlignVertical.top;
case SubtitlePosition.bottom:
return TextAlignVertical.bottom;
}
}
}

View file

@ -0,0 +1,14 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
extension ExtraWidgetDisplayedItem on WidgetDisplayedItem {
String getName(BuildContext context) {
switch (this) {
case WidgetDisplayedItem.random:
return context.l10n.widgetDisplayedItemRandom;
case WidgetDisplayedItem.mostRecent:
return context.l10n.widgetDisplayedItemMostRecent;
}
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:aves/app_flavor.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -122,6 +123,7 @@ class Settings extends ChangeNotifier {
// subtitles // subtitles
static const subtitleFontSizeKey = 'subtitle_font_size'; static const subtitleFontSizeKey = 'subtitle_font_size';
static const subtitleTextAlignmentKey = 'subtitle_text_alignment'; static const subtitleTextAlignmentKey = 'subtitle_text_alignment';
static const subtitleTextPositionKey = 'subtitle_text_position';
static const subtitleShowOutlineKey = 'subtitle_show_outline'; static const subtitleShowOutlineKey = 'subtitle_show_outline';
static const subtitleTextColorKey = 'subtitle_text_color'; static const subtitleTextColorKey = 'subtitle_text_color';
static const subtitleBackgroundColorKey = 'subtitle_background_color'; static const subtitleBackgroundColorKey = 'subtitle_background_color';
@ -171,6 +173,7 @@ class Settings extends ChangeNotifier {
static const widgetShapePrefixKey = '${_widgetKeyPrefix}shape_'; static const widgetShapePrefixKey = '${_widgetKeyPrefix}shape_';
static const widgetCollectionFiltersPrefixKey = '${_widgetKeyPrefix}collection_filters_'; static const widgetCollectionFiltersPrefixKey = '${_widgetKeyPrefix}collection_filters_';
static const widgetOpenPagePrefixKey = '${_widgetKeyPrefix}open_page_'; static const widgetOpenPagePrefixKey = '${_widgetKeyPrefix}open_page_';
static const widgetDisplayedItemPrefixKey = '${_widgetKeyPrefix}displayed_item_';
static const widgetUriPrefixKey = '${_widgetKeyPrefix}uri_'; static const widgetUriPrefixKey = '${_widgetKeyPrefix}uri_';
// platform settings // platform settings
@ -202,18 +205,20 @@ class Settings extends ChangeNotifier {
bool isInternalKey(String key) => _internalKeys.contains(key) || key.startsWith(_widgetKeyPrefix); bool isInternalKey(String key) => _internalKeys.contains(key) || key.startsWith(_widgetKeyPrefix);
Future<void> setContextualDefaults() async { Future<void> setContextualDefaults(AppFlavor flavor) async {
// performance // performance
final performanceClass = await deviceService.getPerformanceClass(); final performanceClass = await deviceService.getPerformanceClass();
enableBlurEffect = performanceClass >= 29; enableBlurEffect = performanceClass >= 29;
// availability // availability
final defaultMapStyle = mobileServices.defaultMapStyle; if (flavor.hasMapStyleDefault) {
if (mobileServices.mapStyles.contains(defaultMapStyle)) { final defaultMapStyle = mobileServices.defaultMapStyle;
mapStyle = defaultMapStyle; if (mobileServices.mapStyles.contains(defaultMapStyle)) {
} else { mapStyle = defaultMapStyle;
final styles = EntryMapStyle.values.whereNot((v) => v.needMobileService).toList(); } else {
mapStyle = styles[Random().nextInt(styles.length)]; final styles = EntryMapStyle.values.whereNot((v) => v.needMobileService).toList();
mapStyle = styles[Random().nextInt(styles.length)];
}
} }
} }
@ -570,6 +575,10 @@ class Settings extends ChangeNotifier {
set subtitleTextAlignment(TextAlign newValue) => setAndNotify(subtitleTextAlignmentKey, newValue.toString()); set subtitleTextAlignment(TextAlign newValue) => setAndNotify(subtitleTextAlignmentKey, newValue.toString());
SubtitlePosition get subtitleTextPosition => getEnumOrDefault(subtitleTextPositionKey, SettingsDefaults.subtitleTextPosition, SubtitlePosition.values);
set subtitleTextPosition(SubtitlePosition newValue) => setAndNotify(subtitleTextPositionKey, newValue.toString());
bool get subtitleShowOutline => getBool(subtitleShowOutlineKey) ?? SettingsDefaults.subtitleShowOutline; bool get subtitleShowOutline => getBool(subtitleShowOutlineKey) ?? SettingsDefaults.subtitleShowOutline;
set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue); set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue);
@ -598,13 +607,15 @@ class Settings extends ChangeNotifier {
// map // map
EntryMapStyle get mapStyle { EntryMapStyle? get mapStyle {
final preferred = getEnumOrDefault(mapStyleKey, SettingsDefaults.infoMapStyle, EntryMapStyle.values); final preferred = getEnumOrDefault(mapStyleKey, null, EntryMapStyle.values);
if (preferred == null) return null;
final available = availability.mapStyles; final available = availability.mapStyles;
return available.contains(preferred) ? preferred : available.first; return available.contains(preferred) ? preferred : available.first;
} }
set mapStyle(EntryMapStyle newValue) => setAndNotify(mapStyleKey, newValue.toString()); set mapStyle(EntryMapStyle? newValue) => setAndNotify(mapStyleKey, newValue?.toString());
LatLng? get mapDefaultCenter { LatLng? get mapDefaultCenter {
final json = getString(mapDefaultCenterKey); final json = getString(mapDefaultCenterKey);
@ -722,6 +733,10 @@ class Settings extends ChangeNotifier {
void setWidgetOpenPage(int widgetId, WidgetOpenPage newValue) => setAndNotify('$widgetOpenPagePrefixKey$widgetId', newValue.toString()); void setWidgetOpenPage(int widgetId, WidgetOpenPage newValue) => setAndNotify('$widgetOpenPagePrefixKey$widgetId', newValue.toString());
WidgetDisplayedItem getWidgetDisplayedItem(int widgetId) => getEnumOrDefault('$widgetDisplayedItemPrefixKey$widgetId', SettingsDefaults.widgetDisplayedItem, WidgetDisplayedItem.values);
void setWidgetDisplayedItem(int widgetId, WidgetDisplayedItem newValue) => setAndNotify('$widgetDisplayedItemPrefixKey$widgetId', newValue.toString());
String? getWidgetUri(int widgetId) => getString('$widgetUriPrefixKey$widgetId'); String? getWidgetUri(int widgetId) => getString('$widgetUriPrefixKey$widgetId');
void setWidgetUri(int widgetId, String? newValue) => setAndNotify('$widgetUriPrefixKey$widgetId', newValue); void setWidgetUri(int widgetId, String? newValue) => setAndNotify('$widgetUriPrefixKey$widgetId', newValue);
@ -958,6 +973,7 @@ class Settings extends ChangeNotifier {
case videoLoopModeKey: case videoLoopModeKey:
case videoControlsKey: case videoControlsKey:
case subtitleTextAlignmentKey: case subtitleTextAlignmentKey:
case subtitleTextPositionKey:
case mapStyleKey: case mapStyleKey:
case mapDefaultCenterKey: case mapDefaultCenterKey:
case coordinateFormatKey: case coordinateFormatKey:

View file

@ -52,7 +52,9 @@ class MimeTypes {
static const v3gpp = 'video/3gpp'; static const v3gpp = 'video/3gpp';
static const asf = 'video/x-ms-asf'; static const asf = 'video/x-ms-asf';
static const avi = 'video/avi'; static const avi = 'video/avi';
static const aviMSVideo = 'video/msvideo';
static const aviVnd = 'video/vnd.avi'; static const aviVnd = 'video/vnd.avi';
static const aviXMSVideo = 'video/x-msvideo';
static const flv = 'video/flv'; static const flv = 'video/flv';
static const flvX = 'video/x-flv'; static const flvX = 'video/x-flv';
static const mkv = 'video/mkv'; static const mkv = 'video/mkv';
@ -87,7 +89,7 @@ class MimeTypes {
static const Set<String> _knownOpaqueImages = {jpeg}; static const Set<String> _knownOpaqueImages = {jpeg};
static const Set<String> _knownVideos = {v3gpp, asf, avi, aviVnd, flv, flvX, mkv, mkvX, mov, mp2p, mp2t, mp2ts, mp4, mpeg, ogv, realVideo, webm, wmv}; static const Set<String> _knownVideos = {v3gpp, asf, avi, aviMSVideo, aviVnd, aviXMSVideo, flv, flvX, mkv, mkvX, mov, mp2p, mp2t, mp2ts, mp4, mpeg, ogv, realVideo, webm, wmv};
static final Set<String> knownMediaTypes = { static final Set<String> knownMediaTypes = {
anyImage, anyImage,
@ -108,7 +110,9 @@ class MimeTypes {
static bool refersToSameType(String a, b) { static bool refersToSameType(String a, b) {
switch (a) { switch (a) {
case avi: case avi:
case aviMSVideo:
case aviVnd: case aviVnd:
case aviXMSVideo:
return [avi, aviVnd].contains(b); return [avi, aviVnd].contains(b);
case bmp: case bmp:
case bmpX: case bmpX:

View file

@ -4,6 +4,16 @@ import 'package:flutter/services.dart';
class AccessibilityService { class AccessibilityService {
static const _platform = MethodChannel('deckers.thibault/aves/accessibility'); static const _platform = MethodChannel('deckers.thibault/aves/accessibility');
static Future<bool> shouldUseBoldFont() async {
try {
final result = await _platform.invokeMethod('shouldUseBoldFont');
if (result != null) return result as bool;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
static Future<bool> areAnimationsRemoved() async { static Future<bool> areAnimationsRemoved() async {
try { try {
final result = await _platform.invokeMethod('areAnimationsRemoved'); final result = await _platform.invokeMethod('areAnimationsRemoved');

View file

@ -223,6 +223,8 @@ class Constants {
..._googleMobileServices, ..._googleMobileServices,
]; ];
static const List<Dependency> _flutterPluginsLibreOnly = [];
static const List<Dependency> _flutterPluginsPlayOnly = [ static const List<Dependency> _flutterPluginsPlayOnly = [
..._googleMobileServices, ..._googleMobileServices,
Dependency( Dependency(
@ -236,6 +238,7 @@ class Constants {
..._flutterPluginsCommon, ..._flutterPluginsCommon,
if (flavor == AppFlavor.huawei) ..._flutterPluginsHuaweiOnly, if (flavor == AppFlavor.huawei) ..._flutterPluginsHuaweiOnly,
if (flavor == AppFlavor.izzy) ..._flutterPluginsIzzyOnly, if (flavor == AppFlavor.izzy) ..._flutterPluginsIzzyOnly,
if (flavor == AppFlavor.libre) ..._flutterPluginsLibreOnly,
if (flavor == AppFlavor.play) ..._flutterPluginsPlayOnly, if (flavor == AppFlavor.play) ..._flutterPluginsPlayOnly,
]; ];

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:aves/app_flavor.dart'; import 'package:aves/app_flavor.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/model/source/media_store_source.dart';
@ -74,7 +75,14 @@ Future<AvesEntry?> _getWidgetEntry(int widgetId, bool reuseEntry) async {
await readyCompleter.future; await readyCompleter.future;
final entries = CollectionLens(source: source, filters: filters).sortedEntries; final entries = CollectionLens(source: source, filters: filters).sortedEntries;
entries.shuffle(); switch (settings.getWidgetDisplayedItem(widgetId)) {
case WidgetDisplayedItem.random:
entries.shuffle();
break;
case WidgetDisplayedItem.mostRecent:
entries.sort(AvesEntry.compareByDate);
break;
}
final entry = entries.firstOrNull; final entry = entries.firstOrNull;
if (entry != null) { if (entry != null) {
settings.setWidgetUri(widgetId, entry.uri); settings.setWidgetUri(widgetId, entry.uri);

View file

@ -145,9 +145,13 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
final androidInfo = await DeviceInfoPlugin().androidInfo; final androidInfo = await DeviceInfoPlugin().androidInfo;
final flavor = context.read<AppFlavor>().toString().split('.')[1]; final flavor = context.read<AppFlavor>().toString().split('.')[1];
return [ return [
'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})', 'Package: ${packageInfo.packageName}',
'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})', 'Aves version: ${packageInfo.version}-$flavor',
'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})', 'Aves build: ${packageInfo.buildNumber}',
'Flutter version: ${version['frameworkVersion']}',
'Flutter channel: ${version['channel']}',
'Android version: ${androidInfo.version.release}',
'Android API: ${androidInfo.version.sdkInt}',
'Android build: ${androidInfo.display}', 'Android build: ${androidInfo.display}',
'Device: ${androidInfo.manufacturer} ${androidInfo.model}', 'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
'Geocoder: ${device.hasGeocoder ? 'ready' : 'not available'}', 'Geocoder: ${device.hasGeocoder ? 'ready' : 'not available'}',

View file

@ -17,9 +17,9 @@ class AboutTranslators extends StatelessWidget {
Contributor('MeFinity', 'me.dot.finity@gmail.com'), Contributor('MeFinity', 'me.dot.finity@gmail.com'),
Contributor('Maki', null), Contributor('Maki', null),
Contributor('HiSubway', 'shenyusoftware@gmail.com'), Contributor('HiSubway', 'shenyusoftware@gmail.com'),
Contributor('glemco', null), Contributor('glemco', 'glemco@posteo.net'),
Contributor('Aerowolf', null), Contributor('Aerowolf', null),
Contributor('小默', null), Contributor('小默', 'duzhe163908@gmail.com'),
Contributor('metezd', 'itoldyouthat@protonmail.com'), Contributor('metezd', 'itoldyouthat@protonmail.com'),
Contributor('Martijn Fabrie', null), Contributor('Martijn Fabrie', null),
Contributor('Koen Koppens', 'koenkoppens@proton.me'), Contributor('Koen Koppens', 'koenkoppens@proton.me'),
@ -33,6 +33,9 @@ class AboutTranslators extends StatelessWidget {
// Contributor('Allan Nordhøy', 'epost@anotheragency.no'), // Contributor('Allan Nordhøy', 'epost@anotheragency.no'),
// Contributor('Piotr K', '1337.kelt@gmail.com'), // Contributor('Piotr K', '1337.kelt@gmail.com'),
// Contributor('امیر جهانگرد', 'ijahangard.a@gmail.com'), // Contributor('امیر جهانگرد', 'ijahangard.a@gmail.com'),
// Contributor('Ralea Adrian Vicențiu', 'ralea.adrian@gmail.com'),
// Contributor('SAMIRAH AIL', 'samiratalzahrani@gmail.com'),
// Contributor('Salih Ail', 'rrrfff444@gmail.com'),
}; };
@override @override

View file

@ -49,7 +49,7 @@ class AvesApp extends StatefulWidget {
final AppFlavor flavor; final AppFlavor flavor;
// temporary exclude locales not ready yet for prime time // temporary exclude locales not ready yet for prime time
static final _unsupportedLocales = {'fa', 'gl', 'nb', 'pl'}.map(Locale.new).toSet(); static final _unsupportedLocales = {'ar', 'fa', 'gl', 'nb', 'pl', 'ro'}.map(Locale.new).toSet();
static final List<Locale> supportedLocales = AppLocalizations.supportedLocales.where((v) => !_unsupportedLocales.contains(v)).toList(); static final List<Locale> supportedLocales = AppLocalizations.supportedLocales.where((v) => !_unsupportedLocales.contains(v)).toList();
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator'); static final GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator');
@ -108,6 +108,7 @@ class AvesApp extends StatefulWidget {
class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver { class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main); final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
late final Future<void> _appSetup; late final Future<void> _appSetup;
late final Future<bool> _shouldUseBoldFontLoader;
late final Future<CorePalette?> _dynamicColorPaletteLoader; late final Future<CorePalette?> _dynamicColorPaletteLoader;
final CollectionSource _mediaStoreSource = MediaStoreSource(); final CollectionSource _mediaStoreSource = MediaStoreSource();
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay); final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay);
@ -129,6 +130,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
_appSetup = _setup(); _appSetup = _setup();
// remember screen size to use it later, when `context` and `window` are no longer reliable // remember screen size to use it later, when `context` and `window` are no longer reliable
_screenSize = _getScreenSize(); _screenSize = _getScreenSize();
_shouldUseBoldFontLoader = AccessibilityService.shouldUseBoldFont();
_dynamicColorPaletteLoader = DynamicColorPlugin.getCorePalette(); _dynamicColorPaletteLoader = DynamicColorPlugin.getCorePalette();
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
@ -205,32 +207,43 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
lightAccent = Color(tonalPalette?.get(60) ?? defaultAccent.value); lightAccent = Color(tonalPalette?.get(60) ?? defaultAccent.value);
darkAccent = Color(tonalPalette?.get(70) ?? defaultAccent.value); darkAccent = Color(tonalPalette?.get(70) ?? defaultAccent.value);
} }
return MaterialApp( return FutureBuilder<bool>(
navigatorKey: AvesApp.navigatorKey, future: _shouldUseBoldFontLoader,
home: home, builder: (context, snapshot) {
navigatorObservers: _navigatorObservers, // Flutter v3.4 already checks the system `Configuration.fontWeightAdjustment` to update `MediaQuery`
builder: (context, child) { // but we need to also check the non-standard Samsung field `bf` representing the bold font toggle
if (initialized) { final shouldUseBoldFont = snapshot.data ?? false;
WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context)); return MaterialApp(
} navigatorKey: AvesApp.navigatorKey,
return AvesColorsProvider( home: home,
child: Theme( navigatorObservers: _navigatorObservers,
data: Theme.of(context).copyWith( builder: (context, child) {
pageTransitionsTheme: pageTransitionsTheme, if (initialized) {
), WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context));
child: child!, }
), return MediaQuery(
data: MediaQuery.of(context).copyWith(boldText: shouldUseBoldFont),
child: AvesColorsProvider(
child: Theme(
data: Theme.of(context).copyWith(
pageTransitionsTheme: pageTransitionsTheme,
),
child: child!,
),
),
);
},
onGenerateTitle: (context) => context.l10n.appName,
theme: Themes.lightTheme(lightAccent, initialized),
darkTheme: themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized),
themeMode: themeBrightness.appThemeMode,
locale: settingsLocale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AvesApp.supportedLocales,
// TODO TLAD remove custom scroll behavior when this is fixed: https://github.com/flutter/flutter/issues/82906
scrollBehavior: StretchMaterialScrollBehavior(),
); );
}, },
onGenerateTitle: (context) => context.l10n.appName,
theme: Themes.lightTheme(lightAccent, initialized),
darkTheme: themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized),
themeMode: themeBrightness.appThemeMode,
locale: settingsLocale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AvesApp.supportedLocales,
// TODO TLAD remove custom scroll behavior when this is fixed: https://github.com/flutter/flutter/issues/82906
scrollBehavior: StretchMaterialScrollBehavior(),
); );
}, },
); );

View file

@ -28,14 +28,14 @@ class FixedExtentSectionLayout extends SectionLayout {
@override @override
int getMinChildIndexForScrollOffset(double scrollOffset) { int getMinChildIndexForScrollOffset(double scrollOffset) {
scrollOffset -= bodyMinOffset; scrollOffset -= bodyMinOffset;
if (scrollOffset < 0) return firstIndex; if (scrollOffset < 0 || mainAxisStride == 0) return firstIndex;
return bodyFirstIndex + scrollOffset ~/ mainAxisStride; return bodyFirstIndex + scrollOffset ~/ mainAxisStride;
} }
@override @override
int getMaxChildIndexForScrollOffset(double scrollOffset) { int getMaxChildIndexForScrollOffset(double scrollOffset) {
scrollOffset -= bodyMinOffset; scrollOffset -= bodyMinOffset;
if (scrollOffset < 0) return firstIndex; if (scrollOffset < 0 || mainAxisStride == 0) return firstIndex;
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
} }
} }

View file

@ -6,7 +6,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class Attribution extends StatelessWidget { class Attribution extends StatelessWidget {
final EntryMapStyle style; final EntryMapStyle? style;
const Attribution({ const Attribution({
super.key, super.key,

View file

@ -128,7 +128,7 @@ class MapButtonPanel extends StatelessWidget {
icon: const Icon(AIcons.layers), icon: const Icon(AIcons.layers),
onPressed: () => showSelectionDialog<EntryMapStyle>( onPressed: () => showSelectionDialog<EntryMapStyle>(
context: context, context: context,
builder: (context) => AvesSelectionDialog<EntryMapStyle>( builder: (context) => AvesSelectionDialog<EntryMapStyle?>(
initialValue: settings.mapStyle, initialValue: settings.mapStyle,
options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))), options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.mapStyleDialogTitle, title: context.l10n.mapStyleDialogTitle,

View file

@ -7,14 +7,18 @@ import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/map/attribution.dart'; import 'package:aves/widgets/common/map/attribution.dart';
import 'package:aves/widgets/common/map/buttons/panel.dart'; import 'package:aves/widgets/common/map/buttons/panel.dart';
import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/leaflet/map.dart'; import 'package:aves/widgets/common/map/leaflet/map.dart';
import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves_map/aves_map.dart'; import 'package:aves_map/aves_map.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:fluster/fluster.dart'; import 'package:fluster/fluster.dart';
@ -148,10 +152,10 @@ class _GeoMapState extends State<GeoMap> {
onTap(clusterAverageLocation, markerEntry, getClusterEntries); onTap(clusterAverageLocation, markerEntry, getClusterEntries);
} }
return Selector<Settings, EntryMapStyle>( return Selector<Settings, EntryMapStyle?>(
selector: (context, s) => s.mapStyle, selector: (context, s) => s.mapStyle,
builder: (context, mapStyle, child) { builder: (context, mapStyle, child) {
final isHeavy = mapStyle.isHeavy; final isHeavy = ExtraEntryMapStyle.isHeavy(mapStyle);
Widget _buildMarkerWidget(MarkerKey<AvesEntry> key) => ImageMarker( Widget _buildMarkerWidget(MarkerKey<AvesEntry> key) => ImageMarker(
key: key, key: key,
count: key.count, count: key.count,
@ -164,60 +168,85 @@ class _GeoMapState extends State<GeoMap> {
bool _isMarkerImageReady(MarkerKey<AvesEntry> key) => key.entry.isThumbnailReady(extent: MapThemeData.markerImageExtent); bool _isMarkerImageReady(MarkerKey<AvesEntry> key) => key.entry.isThumbnailReady(extent: MapThemeData.markerImageExtent);
Widget child = const SizedBox(); Widget child = const SizedBox();
switch (mapStyle) { if (mapStyle != null) {
case EntryMapStyle.googleNormal: switch (mapStyle) {
case EntryMapStyle.googleHybrid: case EntryMapStyle.googleNormal:
case EntryMapStyle.googleTerrain: case EntryMapStyle.googleHybrid:
case EntryMapStyle.hmsNormal: case EntryMapStyle.googleTerrain:
case EntryMapStyle.hmsTerrain: case EntryMapStyle.hmsNormal:
child = mobileServices.buildMap<AvesEntry>( case EntryMapStyle.hmsTerrain:
controller: widget.controller, child = mobileServices.buildMap<AvesEntry>(
clusterListenable: _clusterChangeNotifier, controller: widget.controller,
boundsNotifier: _boundsNotifier, clusterListenable: _clusterChangeNotifier,
style: mapStyle, boundsNotifier: _boundsNotifier,
decoratorBuilder: _decorateMap, style: mapStyle,
buttonPanelBuilder: _buildButtonPanel, decoratorBuilder: _decorateMap,
markerClusterBuilder: _buildMarkerClusters, buttonPanelBuilder: _buildButtonPanel,
markerWidgetBuilder: _buildMarkerWidget, markerClusterBuilder: _buildMarkerClusters,
markerImageReadyChecker: _isMarkerImageReady, markerWidgetBuilder: _buildMarkerWidget,
dotLocationNotifier: widget.dotLocationNotifier, markerImageReadyChecker: _isMarkerImageReady,
overlayOpacityNotifier: widget.overlayOpacityNotifier, dotLocationNotifier: widget.dotLocationNotifier,
overlayEntry: widget.overlayEntry, overlayOpacityNotifier: widget.overlayOpacityNotifier,
onUserZoomChange: widget.onUserZoomChange, overlayEntry: widget.overlayEntry,
onMapTap: widget.onMapTap, onUserZoomChange: widget.onUserZoomChange,
onMarkerTap: _onMarkerTap, onMapTap: widget.onMapTap,
); onMarkerTap: _onMarkerTap,
break; );
case EntryMapStyle.osmHot: break;
case EntryMapStyle.stamenToner: case EntryMapStyle.osmHot:
case EntryMapStyle.stamenWatercolor: case EntryMapStyle.stamenToner:
child = EntryLeafletMap<AvesEntry>( case EntryMapStyle.stamenWatercolor:
controller: widget.controller, child = EntryLeafletMap<AvesEntry>(
clusterListenable: _clusterChangeNotifier, controller: widget.controller,
boundsNotifier: _boundsNotifier, clusterListenable: _clusterChangeNotifier,
minZoom: 2, boundsNotifier: _boundsNotifier,
maxZoom: 16, minZoom: 2,
style: mapStyle, maxZoom: 16,
decoratorBuilder: _decorateMap, style: mapStyle,
buttonPanelBuilder: _buildButtonPanel, decoratorBuilder: _decorateMap,
markerClusterBuilder: _buildMarkerClusters, buttonPanelBuilder: _buildButtonPanel,
markerWidgetBuilder: _buildMarkerWidget, markerClusterBuilder: _buildMarkerClusters,
dotLocationNotifier: widget.dotLocationNotifier, markerWidgetBuilder: _buildMarkerWidget,
markerSize: Size( dotLocationNotifier: widget.dotLocationNotifier,
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2, markerSize: Size(
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2 + MapThemeData.markerArrowSize.height, MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2,
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2 + MapThemeData.markerArrowSize.height,
),
dotMarkerSize: const Size(
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2,
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2,
),
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
);
break;
}
} else {
final overlay = Center(
child: OverlayTextButton(
onPressed: () => showSelectionDialog<EntryMapStyle>(
context: context,
builder: (context) => AvesSelectionDialog<EntryMapStyle?>(
initialValue: settings.mapStyle,
options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.mapStyleDialogTitle,
),
onSelection: (v) => settings.mapStyle = v,
), ),
dotMarkerSize: const Size( child: Row(
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2, mainAxisSize: MainAxisSize.min,
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2, children: [
const Icon(AIcons.layers),
const SizedBox(width: 8),
Text(context.l10n.mapStyleTooltip),
],
), ),
overlayOpacityNotifier: widget.overlayOpacityNotifier, ),
overlayEntry: widget.overlayEntry, );
onUserZoomChange: widget.onUserZoomChange, child = _decorateMap(context, overlay);
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
);
break;
} }
final mapHeight = context.select<MapThemeData, double?>((v) => v.mapHeight); final mapHeight = context.select<MapThemeData, double?>((v) => v.mapHeight);
@ -308,7 +337,11 @@ class _GeoMapState extends State<GeoMap> {
} }
if (bounds == null) { if (bounds == null) {
// fallback to default center // fallback to default center
final center = settings.mapDefaultCenter ??= Constants.wonders[Random().nextInt(Constants.wonders.length)]; var center = settings.mapDefaultCenter;
if (center == null) {
center = Constants.wonders[Random().nextInt(Constants.wonders.length)];
WidgetsBinding.instance.addPostFrameCallback((_) => settings.mapDefaultCenter = center);
}
bounds = ZoomedBounds.fromPoints( bounds = ZoomedBounds.fromPoints(
points: {center}, points: {center},
collocationZoom: settings.infoMapZoom, collocationZoom: settings.infoMapZoom,

View file

@ -77,16 +77,16 @@ class TileExtentController {
double _extentForColumnCount(int columnCount) => (viewportSize.width - (horizontalPadding * 2) - spacing * (columnCount - 1)) / columnCount; double _extentForColumnCount(int columnCount) => (viewportSize.width - (horizontalPadding * 2) - spacing * (columnCount - 1)) / columnCount;
int _effectiveColumnCountMin() => _columnCountForExtent(_extentMax()).ceil(); int _effectiveColumnCountMin() => max(columnCountMin, _columnCountForExtent(_extentMax()).ceil());
int _effectiveColumnCountMax() => _columnCountForExtent(extentMin).floor(); int _effectiveColumnCountMax() => max(columnCountMin, _columnCountForExtent(extentMin).floor());
int _effectiveColumnCountForExtent(double extent) { int _effectiveColumnCountForExtent(double extent) {
if (extent > 0) { if (extent > 0) {
final columnCount = _columnCountForExtent(extent); final columnCount = _columnCountForExtent(extent);
final countMin = _effectiveColumnCountMin(); final countMin = _effectiveColumnCountMin();
final countMax = _effectiveColumnCountMax(); final countMax = _effectiveColumnCountMax();
return columnCount.clamp(countMin, max(countMin, countMax)).round(); return columnCount.round().clamp(countMin, countMax);
} }
return columnCountDefault; return columnCountDefault;
} }

View file

@ -78,7 +78,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
void initState() { void initState() {
super.initState(); super.initState();
if (settings.mapStyle.isHeavy) { if (ExtraEntryMapStyle.isHeavy(settings.mapStyle)) {
_isPageAnimatingNotifier = ValueNotifier(true); _isPageAnimatingNotifier = ValueNotifier(true);
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
if (!mounted) return; if (!mounted) return;

View file

@ -108,7 +108,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
void initState() { void initState() {
super.initState(); super.initState();
if (settings.mapStyle.isHeavy) { if (ExtraEntryMapStyle.isHeavy(settings.mapStyle)) {
_isPageAnimatingNotifier.value = true; _isPageAnimatingNotifier.value = true;
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
if (!mounted) return; if (!mounted) return;
@ -176,11 +176,11 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
} }
return true; return true;
}, },
child: Selector<Settings, EntryMapStyle>( child: Selector<Settings, EntryMapStyle?>(
selector: (context, s) => s.mapStyle, selector: (context, s) => s.mapStyle,
builder: (context, mapStyle, child) { builder: (context, mapStyle, child) {
late Widget scroller; late Widget scroller;
if (mapStyle.isHeavy) { if (ExtraEntryMapStyle.isHeavy(mapStyle)) {
// the map widget is too heavy for a smooth resizing animation // the map widget is too heavy for a smooth resizing animation
// so we just toggle visibility when overlay animation is done // so we just toggle visibility when overlay animation is done
scroller = ValueListenableBuilder<double>( scroller = ValueListenableBuilder<double>(

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/widget_displayed_item.dart';
import 'package:aves/model/settings/enums/widget_open_action.dart'; import 'package:aves/model/settings/enums/widget_open_action.dart';
import 'package:aves/model/settings/enums/widget_shape.dart'; import 'package:aves/model/settings/enums/widget_shape.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -36,6 +37,7 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
late WidgetShape _shape; late WidgetShape _shape;
late Color? _outline; late Color? _outline;
late WidgetOpenPage _openPage; late WidgetOpenPage _openPage;
late WidgetDisplayedItem _displayedItem;
late Set<CollectionFilter> _collectionFilters; late Set<CollectionFilter> _collectionFilters;
int get widgetId => widget.widgetId; int get widgetId => widget.widgetId;
@ -59,6 +61,7 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
_shape = settings.getWidgetShape(widgetId); _shape = settings.getWidgetShape(widgetId);
_outline = settings.getWidgetOutline(widgetId); _outline = settings.getWidgetOutline(widgetId);
_openPage = settings.getWidgetOpenPage(widgetId); _openPage = settings.getWidgetOpenPage(widgetId);
_displayedItem = settings.getWidgetDisplayedItem(widgetId);
_collectionFilters = settings.getWidgetCollectionFilters(widgetId); _collectionFilters = settings.getWidgetCollectionFilters(widgetId);
} }
@ -91,6 +94,13 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
onSelection: (v) => setState(() => _openPage = v), onSelection: (v) => setState(() => _openPage = v),
tileTitle: l10n.settingsWidgetOpenPage, tileTitle: l10n.settingsWidgetOpenPage,
), ),
SettingsSelectionListTile<WidgetDisplayedItem>(
values: WidgetDisplayedItem.values,
getName: (context, v) => v.getName(context),
selector: (context, s) => _displayedItem,
onSelection: (v) => setState(() => _displayedItem = v),
tileTitle: l10n.settingsWidgetDisplayedItem,
),
SettingsCollectionTile( SettingsCollectionTile(
filters: _collectionFilters, filters: _collectionFilters,
onSelection: (v) => setState(() => _collectionFilters = v), onSelection: (v) => setState(() => _collectionFilters = v),
@ -148,11 +158,15 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
} }
void _saveSettings() { void _saveSettings() {
final invalidateUri = _displayedItem != settings.getWidgetDisplayedItem(widgetId) || !const SetEquality().equals(_collectionFilters, settings.getWidgetCollectionFilters(widgetId));
settings.setWidgetShape(widgetId, _shape); settings.setWidgetShape(widgetId, _shape);
settings.setWidgetOutline(widgetId, _outline); settings.setWidgetOutline(widgetId, _outline);
settings.setWidgetOpenPage(widgetId, _openPage); settings.setWidgetOpenPage(widgetId, _openPage);
if (!const SetEquality().equals(_collectionFilters, settings.getWidgetCollectionFilters(widgetId))) { settings.setWidgetDisplayedItem(widgetId, _displayedItem);
settings.setWidgetCollectionFilters(widgetId, _collectionFilters); settings.setWidgetCollectionFilters(widgetId, _collectionFilters);
if (invalidateUri) {
settings.setWidgetUri(widgetId, null); settings.setWidgetUri(widgetId, null);
} }
} }

View file

@ -1,3 +1,4 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart'; import 'package:aves/widgets/common/basic/outlined_text.dart';
@ -20,6 +21,7 @@ class SubtitleSample extends StatelessWidget {
return Consumer<Settings>( return Consumer<Settings>(
builder: (context, settings, child) { builder: (context, settings, child) {
final textAlign = settings.subtitleTextAlignment; final textAlign = settings.subtitleTextAlignment;
final textPosition = settings.subtitleTextPosition;
final outlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity); final outlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
final shadows = [ final shadows = [
Shadow( Shadow(
@ -40,7 +42,7 @@ class SubtitleSample extends StatelessWidget {
), ),
height: 128, height: 128,
child: AnimatedAlign( child: AnimatedAlign(
alignment: _getAlignment(textAlign), alignment: _getAlignment(textAlign, textPosition),
curve: Curves.easeInOutCubic, curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
child: Padding( child: Padding(
@ -75,15 +77,28 @@ class SubtitleSample extends StatelessWidget {
); );
} }
Alignment _getAlignment(TextAlign textAlign) { Alignment _getAlignment(TextAlign textAlign, SubtitlePosition textPosition) {
switch (textAlign) { switch (textPosition) {
case TextAlign.left: case SubtitlePosition.top:
return Alignment.bottomLeft; switch (textAlign) {
case TextAlign.right: case TextAlign.left:
return Alignment.bottomRight; return Alignment.topLeft;
case TextAlign.center: case TextAlign.right:
default: return Alignment.topRight;
return Alignment.bottomCenter; case TextAlign.center:
default:
return Alignment.topCenter;
}
case SubtitlePosition.bottom:
switch (textAlign) {
case TextAlign.left:
return Alignment.bottomLeft;
case TextAlign.right:
return Alignment.bottomRight;
case TextAlign.center:
default:
return Alignment.bottomCenter;
}
} }
} }
} }

View file

@ -1,3 +1,5 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/subtitle_position.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/basic/color_list_tile.dart'; import 'package:aves/widgets/common/basic/color_list_tile.dart';
import 'package:aves/widgets/common/basic/slider_list_tile.dart'; import 'package:aves/widgets/common/basic/slider_list_tile.dart';
@ -40,6 +42,14 @@ class SubtitleThemePage extends StatelessWidget {
tileTitle: context.l10n.settingsSubtitleThemeTextAlignmentTile, tileTitle: context.l10n.settingsSubtitleThemeTextAlignmentTile,
dialogTitle: context.l10n.settingsSubtitleThemeTextAlignmentDialogTitle, dialogTitle: context.l10n.settingsSubtitleThemeTextAlignmentDialogTitle,
), ),
SettingsSelectionListTile<SubtitlePosition>(
values: const [SubtitlePosition.top, SubtitlePosition.bottom],
getName: (context, v) => v.getName(context),
selector: (context, s) => s.subtitleTextPosition,
onSelection: (v) => settings.subtitleTextPosition = v,
tileTitle: context.l10n.settingsSubtitleThemeTextPositionTile,
dialogTitle: context.l10n.settingsSubtitleThemeTextPositionDialogTitle,
),
SliderListTile( SliderListTile(
title: context.l10n.settingsSubtitleThemeTextSize, title: context.l10n.settingsSubtitleThemeTextSize,
value: settings.subtitleFontSize, value: settings.subtitleFontSize,

View file

@ -30,6 +30,7 @@ class ViewerActionEditorPage extends StatelessWidget {
EntryAction.videoSetSpeed, EntryAction.videoSetSpeed,
EntryAction.videoSelectStreams, EntryAction.videoSelectStreams,
], ],
EntryActions.commonMetadataActions,
]; ];
@override @override

View file

@ -10,6 +10,7 @@ import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart'; 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/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -29,79 +30,191 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/action/printer.dart'; import 'package:aves/widgets/viewer/action/printer.dart';
import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart';
import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin { class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin {
@override final AvesEntry mainEntry, pageEntry;
final AvesEntry entry; final CollectionLens? collection;
final EntryInfoActionDelegate _metadataActionDelegate = EntryInfoActionDelegate();
EntryActionDelegate(this.entry); EntryActionDelegate(this.mainEntry, this.pageEntry, this.collection);
bool isVisible(EntryAction action) {
if (mainEntry.trashed) {
switch (action) {
case EntryAction.delete:
case EntryAction.restore:
return true;
case EntryAction.debug:
return kDebugMode;
default:
return false;
}
} else {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.toggleFavourite:
return collection != null;
case EntryAction.delete:
case EntryAction.rename:
case EntryAction.copy:
case EntryAction.move:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.convert:
case EntryAction.print:
return !targetEntry.isVideo && device.canPrint;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.videoCaptureFrame:
case EntryAction.videoToggleMute:
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoSettings:
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:
case EntryAction.videoSkip10:
return targetEntry.isVideo;
case EntryAction.rotateScreen:
return settings.isRotationLocked;
case EntryAction.addShortcut:
return device.canPinShortcut;
case EntryAction.info:
case EntryAction.copyToClipboard:
case EntryAction.edit:
case EntryAction.open:
case EntryAction.setAs:
case EntryAction.share:
return true;
case EntryAction.restore:
return false;
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
return _metadataActionDelegate.isVisible(targetEntry, action);
case EntryAction.debug:
return kDebugMode;
}
}
}
bool canApply(EntryAction action) {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
return _metadataActionDelegate.canApply(targetEntry, action);
default:
return true;
}
}
void onActionSelected(BuildContext context, EntryAction action) { void onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry;
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
switch (action) { switch (action) {
case EntryAction.info: case EntryAction.info:
ShowInfoNotification().dispatch(context); ShowInfoNotification().dispatch(context);
break; break;
case EntryAction.addShortcut: case EntryAction.addShortcut:
_addShortcut(context); _addShortcut(context, targetEntry);
break; break;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { androidAppService.copyToClipboard(targetEntry.uri, targetEntry.bestTitle).then((success) {
showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback); showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback);
}); });
break; break;
case EntryAction.delete: case EntryAction.delete:
_delete(context); _delete(context, targetEntry);
break; break;
case EntryAction.restore: case EntryAction.restore:
_move(context, moveType: MoveType.fromBin); _move(context, targetEntry, moveType: MoveType.fromBin);
break; break;
case EntryAction.convert: case EntryAction.convert:
_convert(context); _convert(context, targetEntry);
break; break;
case EntryAction.print: case EntryAction.print:
EntryPrinter(entry).print(context); EntryPrinter(targetEntry).print(context);
break; break;
case EntryAction.rename: case EntryAction.rename:
_rename(context); _rename(context, targetEntry);
break; break;
case EntryAction.copy: case EntryAction.copy:
_move(context, moveType: MoveType.copy); _move(context, targetEntry, moveType: MoveType.copy);
break; break;
case EntryAction.move: case EntryAction.move:
_move(context, moveType: MoveType.move); _move(context, targetEntry, moveType: MoveType.move);
break; break;
case EntryAction.share: case EntryAction.share:
androidAppService.shareEntries({entry}).then((success) { androidAppService.shareEntries({targetEntry}).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
entry.toggleFavourite(); targetEntry.toggleFavourite();
break; break;
// raster // raster
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
_rotate(context, clockwise: false); _rotate(context, targetEntry, clockwise: false);
break; break;
case EntryAction.rotateCW: case EntryAction.rotateCW:
_rotate(context, clockwise: true); _rotate(context, targetEntry, clockwise: true);
break; break;
case EntryAction.flip: case EntryAction.flip:
_flip(context); _flip(context, targetEntry);
break; break;
// vector // vector
case EntryAction.viewSource: case EntryAction.viewSource:
_goToSourceViewer(context); _goToSourceViewer(context, targetEntry);
break; break;
// video // video
case EntryAction.videoCaptureFrame: case EntryAction.videoCaptureFrame:
@ -112,28 +225,28 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.videoTogglePlay: case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10: case EntryAction.videoReplay10:
case EntryAction.videoSkip10: case EntryAction.videoSkip10:
final controller = context.read<VideoConductor>().getController(entry); final controller = context.read<VideoConductor>().getController(targetEntry);
if (controller != null) { if (controller != null) {
VideoActionNotification(controller: controller, action: action).dispatch(context); VideoActionNotification(controller: controller, action: action).dispatch(context);
} }
break; break;
case EntryAction.edit: case EntryAction.edit:
androidAppService.edit(entry.uri, entry.mimeType).then((success) { androidAppService.edit(targetEntry.uri, targetEntry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.open: case EntryAction.open:
androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { androidAppService.open(targetEntry.uri, targetEntry.mimeTypeAnySubtype).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.openMap: case EntryAction.openMap:
androidAppService.openMap(entry.latLng!).then((success) { androidAppService.openMap(targetEntry.latLng!).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.setAs: case EntryAction.setAs:
androidAppService.setAs(entry.uri, entry.mimeType).then((success) { androidAppService.setAs(targetEntry.uri, targetEntry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
@ -141,18 +254,31 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
_rotateScreen(context); _rotateScreen(context);
break; break;
// metadata
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
_metadataActionDelegate.onActionSelected(context, targetEntry, collection, action);
break;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
_goToDebug(context); _goToDebug(context, targetEntry);
break; break;
} }
} }
Future<void> _addShortcut(BuildContext context) async { Future<void> _addShortcut(BuildContext context, AvesEntry targetEntry) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>( final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context, context: context,
builder: (context) => AddShortcutDialog( builder: (context) => AddShortcutDialog(
defaultName: entry.bestTitle ?? '', defaultName: targetEntry.bestTitle ?? '',
), ),
); );
if (result == null) return; if (result == null) return;
@ -160,18 +286,18 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final name = result.item2; final name = result.item2;
if (name.isEmpty) return; if (name.isEmpty) return;
await androidAppService.pinToHomeScreen(name, entry, uri: entry.uri); await androidAppService.pinToHomeScreen(name, targetEntry, uri: targetEntry.uri);
if (!device.showPinShortcutFeedback) { if (!device.showPinShortcutFeedback) {
showFeedback(context, context.l10n.genericSuccessFeedback); showFeedback(context, context.l10n.genericSuccessFeedback);
} }
} }
Future<void> _flip(BuildContext context) async { Future<void> _flip(BuildContext context, AvesEntry targetEntry) async {
await edit(context, entry.flip); await edit(context, targetEntry, targetEntry.flip);
} }
Future<void> _rotate(BuildContext context, {required bool clockwise}) async { Future<void> _rotate(BuildContext context, AvesEntry targetEntry, {required bool clockwise}) async {
await edit(context, () => entry.rotate(clockwise: clockwise)); await edit(context, targetEntry, () => targetEntry.rotate(clockwise: clockwise));
} }
Future<void> _rotateScreen(BuildContext context) async { Future<void> _rotateScreen(BuildContext context) async {
@ -185,9 +311,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _delete(BuildContext context) async { Future<void> _delete(BuildContext context, AvesEntry targetEntry) async {
if (settings.enableBin && !entry.trashed) { if (settings.enableBin && !targetEntry.trashed) {
await _move(context, moveType: MoveType.toBin); await _move(context, targetEntry, moveType: MoveType.toBin);
return; return;
} }
@ -199,23 +325,23 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
confirmationButtonLabel: l10n.deleteButtonLabel, confirmationButtonLabel: l10n.deleteButtonLabel,
)) return; )) return;
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {targetEntry})) return;
if (!await entry.delete()) { if (!await targetEntry.delete()) {
showFeedback(context, l10n.genericFailureFeedback); showFeedback(context, l10n.genericFailureFeedback);
} else { } else {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
if (source.initState != SourceInitializationState.none) { if (source.initState != SourceInitializationState.none) {
await source.removeEntries({entry.uri}, includeTrash: true); await source.removeEntries({targetEntry.uri}, includeTrash: true);
} }
EntryDeletedNotification({entry}).dispatch(context); EntryDeletedNotification({targetEntry}).dispatch(context);
} }
} }
Future<void> _convert(BuildContext context) async { Future<void> _convert(BuildContext context, AvesEntry targetEntry) async {
final options = await showDialog<EntryExportOptions>( final options = await showDialog<EntryExportOptions>(
context: context, context: context,
builder: (context) => ExportEntryDialog(entry: entry), builder: (context) => ExportEntryDialog(entry: targetEntry),
); );
if (options == null) return; if (options == null) return;
@ -223,13 +349,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (destinationAlbum == null) return; if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; if (!await checkFreeSpaceForMove(context, {targetEntry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultiPage) { if (targetEntry.isMultiPage) {
final multiPageInfo = await entry.getMultiPageInfo(); final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) { if (multiPageInfo != null) {
if (entry.isMotionPhoto) { if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo(); await multiPageInfo.extractMotionPhotoVideo();
} }
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
@ -237,7 +363,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
} else { } else {
selection.add(entry); selection.add(targetEntry);
} }
final selectionCount = selection.length; final selectionCount = selection.length;
@ -304,32 +430,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
Future<void> _move(BuildContext context, {required MoveType moveType}) => move( Future<void> _move(BuildContext context, AvesEntry targetEntry, {required MoveType moveType}) => move(
context, context,
moveType: moveType, moveType: moveType,
entries: {entry}, entries: {targetEntry},
); );
Future<void> _rename(BuildContext context) async { Future<void> _rename(BuildContext context, AvesEntry targetEntry) async {
final newName = await showDialog<String>( final newName = await showDialog<String>(
context: context, context: context,
builder: (context) => RenameEntryDialog(entry: entry), builder: (context) => RenameEntryDialog(entry: targetEntry),
); );
if (newName == null || newName.isEmpty || newName == entry.filenameWithoutExtension) return; if (newName == null || newName.isEmpty || newName == targetEntry.filenameWithoutExtension) return;
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
await rename( await rename(
context, context,
entriesToNewName: {entry: '$newName${entry.extension}'}, entriesToNewName: {targetEntry: '$newName${targetEntry.extension}'},
persist: _isMainMode(context), persist: _isMainMode(context),
onSuccess: entry.metadataChangeNotifier.notify, onSuccess: targetEntry.metadataChangeNotifier.notify,
); );
} }
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
void _goToSourceViewer(BuildContext context) { void _goToSourceViewer(BuildContext context, AvesEntry targetEntry) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -337,9 +463,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
builder: (context) => SourceViewerPage( builder: (context) => SourceViewerPage(
loader: () async { loader: () async {
final data = await mediaFetchService.getSvg( final data = await mediaFetchService.getSvg(
entry.uri, targetEntry.uri,
entry.mimeType, targetEntry.mimeType,
sizeBytes: entry.sizeBytes, sizeBytes: targetEntry.sizeBytes,
); );
return utf8.decode(data); return utf8.decode(data);
}, },
@ -348,12 +474,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
void _goToDebug(BuildContext context) { void _goToDebug(BuildContext context, AvesEntry targetEntry) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: ViewerDebugPage.routeName), settings: const RouteSettings(name: ViewerDebugPage.routeName),
builder: (context) => ViewerDebugPage(entry: entry), builder: (context) => ViewerDebugPage(entry: targetEntry),
), ),
); );
} }

View file

@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/events.dart'; import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_info.dart';
import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/geotiff.dart'; import 'package:aves/model/geotiff.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
@ -14,162 +17,189 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart';
import 'package:aves/widgets/viewer/debug/debug_page.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin {
@override final StreamController<ActionEvent<EntryAction>> _eventStreamController = StreamController.broadcast();
final AvesEntry entry;
final CollectionLens? collection;
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController.broadcast(); Stream<ActionEvent<EntryAction>> get eventStream => _eventStreamController.stream;
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream; bool isVisible(AvesEntry targetEntry, EntryAction action) {
EntryInfoActionDelegate(this.entry, this.collection);
bool isVisible(EntryInfoAction action) {
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryAction.editDate:
case EntryInfoAction.editLocation: case EntryAction.editLocation:
case EntryInfoAction.editTitleDescription: case EntryAction.editTitleDescription:
case EntryInfoAction.editRating: case EntryAction.editRating:
case EntryInfoAction.editTags: case EntryAction.editTags:
case EntryInfoAction.removeMetadata: case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
return true; return true;
// GeoTIFF // GeoTIFF
case EntryInfoAction.showGeoTiffOnMap: case EntryAction.showGeoTiffOnMap:
return entry.isGeotiff; return targetEntry.isGeotiff;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryAction.convertMotionPhotoToStillImage:
case EntryInfoAction.viewMotionPhotoVideo: case EntryAction.viewMotionPhotoVideo:
return entry.isMotionPhoto; return targetEntry.isMotionPhoto;
// debug default:
case EntryInfoAction.debug: return false;
return kDebugMode;
} }
} }
bool canApply(EntryInfoAction action) { bool canApply(AvesEntry targetEntry, EntryAction action) {
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryAction.editDate:
return entry.canEditDate; return targetEntry.canEditDate;
case EntryInfoAction.editLocation: case EntryAction.editLocation:
return entry.canEditLocation; return targetEntry.canEditLocation;
case EntryInfoAction.editTitleDescription: case EntryAction.editTitleDescription:
return entry.canEditTitleDescription; return targetEntry.canEditTitleDescription;
case EntryInfoAction.editRating: case EntryAction.editRating:
return entry.canEditRating; return targetEntry.canEditRating;
case EntryInfoAction.editTags: case EntryAction.editTags:
return entry.canEditTags; return targetEntry.canEditTags;
case EntryInfoAction.removeMetadata: case EntryAction.removeMetadata:
return entry.canRemoveMetadata; return targetEntry.canRemoveMetadata;
case EntryAction.exportMetadata:
return true;
// GeoTIFF // GeoTIFF
case EntryInfoAction.showGeoTiffOnMap: case EntryAction.showGeoTiffOnMap:
return true; return true;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryAction.convertMotionPhotoToStillImage:
return entry.canEditXmp; return targetEntry.canEditXmp;
case EntryInfoAction.viewMotionPhotoVideo: case EntryAction.viewMotionPhotoVideo:
return true;
// debug
case EntryInfoAction.debug:
return true; return true;
default:
return false;
} }
} }
void onActionSelected(BuildContext context, EntryInfoAction action) async { void onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async {
_eventStreamController.add(ActionStartedEvent(action)); _eventStreamController.add(ActionStartedEvent(action));
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryAction.editDate:
await _editDate(context); await _editDate(context, targetEntry, collection);
break; break;
case EntryInfoAction.editLocation: case EntryAction.editLocation:
await _editLocation(context); await _editLocation(context, targetEntry, collection);
break; break;
case EntryInfoAction.editTitleDescription: case EntryAction.editTitleDescription:
await _editTitleDescription(context); await _editTitleDescription(context, targetEntry);
break; break;
case EntryInfoAction.editRating: case EntryAction.editRating:
await _editRating(context); await _editRating(context, targetEntry);
break; break;
case EntryInfoAction.editTags: case EntryAction.editTags:
await _editTags(context); await _editTags(context, targetEntry);
break; break;
case EntryInfoAction.removeMetadata: case EntryAction.removeMetadata:
await _removeMetadata(context); await _removeMetadata(context, targetEntry);
break;
case EntryAction.exportMetadata:
await _exportMetadata(context, targetEntry);
break; break;
// GeoTIFF // GeoTIFF
case EntryInfoAction.showGeoTiffOnMap: case EntryAction.showGeoTiffOnMap:
await _showGeoTiffOnMap(context); await _showGeoTiffOnMap(context, targetEntry, collection);
break; break;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryAction.convertMotionPhotoToStillImage:
await _convertMotionPhotoToStillImage(context); await _convertMotionPhotoToStillImage(context, targetEntry);
break; break;
case EntryInfoAction.viewMotionPhotoVideo: case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break; break;
// debug default:
case EntryInfoAction.debug:
_goToDebug(context);
break; break;
} }
_eventStreamController.add(ActionEndedEvent(action)); _eventStreamController.add(ActionEndedEvent(action));
} }
Future<void> _editDate(BuildContext context) async { Future<void> _editDate(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final modifier = await selectDateModifier(context, {entry}, collection); final modifier = await selectDateModifier(context, {targetEntry}, collection);
if (modifier == null) return; if (modifier == null) return;
await edit(context, () => entry.editDate(modifier)); await edit(context, targetEntry, () => targetEntry.editDate(modifier));
} }
Future<void> _editLocation(BuildContext context) async { Future<void> _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final location = await selectLocation(context, {entry}, collection); final location = await selectLocation(context, {targetEntry}, collection);
if (location == null) return; if (location == null) return;
await edit(context, () => entry.editLocation(location)); await edit(context, targetEntry, () => targetEntry.editLocation(location));
} }
Future<void> _editTitleDescription(BuildContext context) async { Future<void> _editTitleDescription(BuildContext context, AvesEntry targetEntry) async {
final modifier = await selectTitleDescriptionModifier(context, {entry}); final modifier = await selectTitleDescriptionModifier(context, {targetEntry});
if (modifier == null) return; if (modifier == null) return;
await edit(context, () => entry.editTitleDescription(modifier)); await edit(context, targetEntry, () => targetEntry.editTitleDescription(modifier));
} }
Future<void> _editRating(BuildContext context) async { Future<void> _editRating(BuildContext context, AvesEntry targetEntry) async {
final rating = await selectRating(context, {entry}); final rating = await selectRating(context, {targetEntry});
if (rating == null) return; if (rating == null) return;
await edit(context, () => entry.editRating(rating)); await edit(context, targetEntry, () => targetEntry.editRating(rating));
} }
Future<void> _editTags(BuildContext context) async { Future<void> _editTags(BuildContext context, AvesEntry targetEntry) async {
final newTagsByEntry = await selectTags(context, {entry}); final newTagsByEntry = await selectTags(context, {targetEntry});
if (newTagsByEntry == null) return; if (newTagsByEntry == null) return;
final newTags = newTagsByEntry[entry] ?? entry.tags; final newTags = newTagsByEntry[targetEntry] ?? targetEntry.tags;
final currentTags = entry.tags; final currentTags = targetEntry.tags;
if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return;
await edit(context, () => entry.editTags(newTags)); await edit(context, targetEntry, () => targetEntry.editTags(newTags));
} }
Future<void> _removeMetadata(BuildContext context) async { Future<void> _removeMetadata(BuildContext context, AvesEntry targetEntry) async {
final types = await selectMetadataToRemove(context, {entry}); final types = await selectMetadataToRemove(context, {targetEntry});
if (types == null) return; if (types == null) return;
await edit(context, () => entry.removeMetadata(types)); await edit(context, targetEntry, () => targetEntry.removeMetadata(types));
} }
Future<void> _convertMotionPhotoToStillImage(BuildContext context) async { Future<void> _exportMetadata(BuildContext context, AvesEntry targetEntry) async {
final lines = <String>[];
final padding = ' ' * 2;
final titledDirectories = await targetEntry.getMetadataDirectories(context);
titledDirectories.forEach((kv) {
final title = kv.key;
final dir = kv.value;
lines.add('[$title]');
final dirContent = dir.allTags;
final tags = dirContent.keys.toList()..sort();
tags.forEach((tag) {
final value = dirContent[tag];
lines.add('$padding$tag: $value');
});
});
final metadataString = lines.join('\n');
final success = await storageService.createFile(
'${targetEntry.filenameWithoutExtension}-metadata.txt',
MimeTypes.plainText,
Uint8List.fromList(utf8.encode(metadataString)),
);
if (success != null) {
if (success) {
showFeedback(context, context.l10n.genericSuccessFeedback);
} else {
showFeedback(context, context.l10n.genericFailureFeedback);
}
}
}
Future<void> _convertMotionPhotoToStillImage(BuildContext context, AvesEntry targetEntry) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
@ -190,16 +220,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
); );
if (confirmed == null || !confirmed) return; if (confirmed == null || !confirmed) return;
await edit(context, entry.removeTrailerVideo); await edit(context, targetEntry, targetEntry.removeTrailerVideo);
} }
Future<void> _showGeoTiffOnMap(BuildContext context) async { Future<void> _showGeoTiffOnMap(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final info = await metadataFetchService.getGeoTiffInfo(entry); final info = await metadataFetchService.getGeoTiffInfo(targetEntry);
if (info == null) return; if (info == null) return;
final mappedGeoTiff = MappedGeoTiff( final mappedGeoTiff = MappedGeoTiff(
info: info, info: info,
entry: entry, entry: targetEntry,
); );
if (!mappedGeoTiff.canOverlay) return; if (!mappedGeoTiff.canOverlay) return;
@ -214,7 +244,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
return MapPage( return MapPage(
collection: baseCollection.copyWith( collection: baseCollection.copyWith(
listenToSource: true, listenToSource: true,
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != this.entry).toList(), fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != targetEntry).toList(),
), ),
overlayEntry: mappedGeoTiff, overlayEntry: mappedGeoTiff,
); );
@ -222,14 +252,4 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
), ),
); );
} }
void _goToDebug(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: ViewerDebugPage.routeName),
builder: (context) => ViewerDebugPage(entry: entry),
),
);
}
} }

View file

@ -12,12 +12,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin { mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
AvesEntry get entry;
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> edit(BuildContext context, Future<Set<EntryDataType>> Function() apply) async { Future<void> edit(BuildContext context, AvesEntry targetEntry, Future<Set<EntryDataType>> Function() apply) async {
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {targetEntry})) return;
// check before applying, because it relies on provider // check before applying, because it relies on provider
// but the widget tree may be disposed if the user navigated away // but the widget tree may be disposed if the user navigated away
@ -32,10 +30,10 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
try { try {
if (success) { if (success) {
if (isMainMode && source != null) { if (isMainMode && source != null) {
Set<String> obsoleteTags = entry.tags; Set<String> obsoleteTags = targetEntry.tags;
String? obsoleteCountryCode = entry.addressDetails?.countryCode; String? obsoleteCountryCode = targetEntry.addressDetails?.countryCode;
await source.refreshEntry(entry, dataTypes); await source.refreshEntry(targetEntry, dataTypes);
// invalidate filters derived from values before edition // invalidate filters derived from values before edition
// this invalidation must happen after the source is refreshed, // this invalidation must happen after the source is refreshed,
@ -47,7 +45,7 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
source.invalidateTagFilterSummary(tags: obsoleteTags); source.invalidateTagFilterSummary(tags: obsoleteTags);
} }
} else { } else {
await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); await targetEntry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
} }
showFeedback(context, l10n.genericSuccessFeedback); showFeedback(context, l10n.genericSuccessFeedback);
} else { } else {

View file

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -255,7 +256,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
} else if (notification is VideoActionNotification) { } else if (notification is VideoActionNotification) {
final controller = notification.controller; final controller = notification.controller;
final action = notification.action; final action = notification.action;
_videoActionDelegate.onActionSelected(context, controller, action); _onVideoAction(context, controller, action);
} else { } else {
return false; return false;
} }
@ -396,7 +397,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
scale: _overlayVideoControlScale, scale: _overlayVideoControlScale,
onActionSelected: (action) { onActionSelected: (action) {
if (videoController != null) { if (videoController != null) {
_videoActionDelegate.onActionSelected(context, videoController, action); _onVideoAction(context, videoController, action);
} }
}, },
onActionMenuOpened: () { onActionMenuOpened: () {
@ -440,7 +441,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
ViewerBottomOverlay( ViewerBottomOverlay(
entries: entries, entries: entries,
index: _currentEntryIndex, index: _currentEntryIndex,
hasCollection: hasCollection, collection: collection,
animationController: _overlayAnimationController, animationController: _overlayAnimationController,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,
@ -482,6 +483,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
); );
} }
Future<void> _onVideoAction(BuildContext context, AvesVideoController controller, EntryAction action) async {
await _videoActionDelegate.onActionSelected(context, controller, action);
if (action == EntryAction.videoToggleMute) {
final override = controller.isMuted;
videoMutedOverride = override;
await context.read<VideoConductor>().muteAll(override);
}
}
void _onVerticalPageControllerChange() { void _onVerticalPageControllerChange() {
if (!_isEntryTracked && _verticalPager.hasClients && _verticalPager.page?.floor() == transitionPage) { if (!_isEntryTracked && _verticalPager.hasClients && _verticalPager.page?.floor() == transitionPage) {
_trackEntry(); _trackEntry();

View file

@ -1,6 +1,6 @@
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
@ -29,7 +29,7 @@ class BasicSection extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final CollectionLens? collection; final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate; final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<EntryInfoAction?> isEditingMetadataNotifier; final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
final FilterCallback onFilter; final FilterCallback onFilter;
const BasicSection({ const BasicSection({
@ -100,9 +100,9 @@ class BasicSection extends StatelessWidget {
Widget _buildEditButtons(BuildContext context) { Widget _buildEditButtons(BuildContext context) {
final children = [ final children = [
EntryInfoAction.editRating, EntryAction.editRating,
EntryInfoAction.editTags, EntryAction.editTags,
].where(actionDelegate.canApply).map((v) => _buildEditMetadataButton(context, v)).toList(); ].where((v) => actionDelegate.canApply(entry, v)).map((v) => _buildEditMetadataButton(context, v)).toList();
return children.isEmpty return children.isEmpty
? const SizedBox() ? const SizedBox()
@ -121,8 +121,8 @@ class BasicSection extends StatelessWidget {
); );
} }
Widget _buildEditMetadataButton(BuildContext context, EntryInfoAction action) { Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
return ValueListenableBuilder<EntryInfoAction?>( return ValueListenableBuilder<EntryAction?>(
valueListenable: isEditingMetadataNotifier, valueListenable: isEditingMetadataNotifier,
builder: (context, editingAction, child) { builder: (context, editingAction, child) {
final isEditing = editingAction != null; final isEditing = editingAction != null;
@ -138,7 +138,7 @@ class BasicSection extends StatelessWidget {
), ),
child: IconButton( child: IconButton(
icon: action.getIcon(), icon: action.getIcon(),
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action), onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, entry, collection, action),
tooltip: action.getText(context), tooltip: action.getText(context),
), ),
), ),

View file

@ -1,20 +1,22 @@
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/info_search.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
class InfoAppBar extends StatelessWidget { class InfoAppBar extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate; final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier; final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
final VoidCallback onBackPressed; final VoidCallback onBackPressed;
@ -22,6 +24,7 @@ class InfoAppBar extends StatelessWidget {
const InfoAppBar({ const InfoAppBar({
super.key, super.key,
required this.entry, required this.entry,
required this.collection,
required this.actionDelegate, required this.actionDelegate,
required this.metadataNotifier, required this.metadataNotifier,
required this.onBackPressed, required this.onBackPressed,
@ -29,8 +32,8 @@ class InfoAppBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final commonActions = EntryInfoActions.common.where(actionDelegate.isVisible); final commonActions = EntryActions.commonMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
final formatSpecificActions = EntryInfoActions.formatSpecific.where(actionDelegate.isVisible); final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
return SliverAppBar( return SliverAppBar(
leading: IconButton( leading: IconButton(
@ -54,22 +57,22 @@ class InfoAppBar extends StatelessWidget {
), ),
if (entry.canEdit) if (entry.canEdit)
MenuIconTheme( MenuIconTheme(
child: PopupMenuButton<EntryInfoAction>( child: PopupMenuButton<EntryAction>(
itemBuilder: (context) => [ itemBuilder: (context) => [
...commonActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), ...commonActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(entry, action))),
if (formatSpecificActions.isNotEmpty) ...[ if (formatSpecificActions.isNotEmpty) ...[
const PopupMenuDivider(), const PopupMenuDivider(),
...formatSpecificActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), ...formatSpecificActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(entry, action))),
], ],
if (!kReleaseMode) ...[ if (!kReleaseMode) ...[
const PopupMenuDivider(), const PopupMenuDivider(),
_toMenuItem(context, EntryInfoAction.debug, enabled: true), _toMenuItem(context, EntryAction.debug, enabled: true),
] ]
], ],
onSelected: (action) async { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation); await Future.delayed(Durations.popupMenuAnimation * timeDilation);
actionDelegate.onActionSelected(context, action); actionDelegate.onActionSelected(context, entry, collection, action);
}, },
), ),
), ),
@ -78,7 +81,7 @@ class InfoAppBar extends StatelessWidget {
); );
} }
PopupMenuItem<EntryInfoAction> _toMenuItem(BuildContext context, EntryInfoAction action, {required bool enabled}) { PopupMenuItem<EntryAction> _toMenuItem(BuildContext context, EntryAction action, {required bool enabled}) {
return PopupMenuItem( return PopupMenuItem(
value: action, value: action,
enabled: enabled, enabled: enabled,

View file

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/events.dart'; import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -14,6 +14,7 @@ import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/basic_section.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart';
import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';
@ -149,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
late EntryInfoActionDelegate _actionDelegate; late EntryInfoActionDelegate _actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({}); final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
final ValueNotifier<EntryInfoAction?> _isEditingMetadataNotifier = ValueNotifier(null); final ValueNotifier<EntryAction?> _isEditingMetadataNotifier = ValueNotifier(null);
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
@ -180,7 +181,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
} }
void _registerWidget(_InfoPageContent widget) { void _registerWidget(_InfoPageContent widget) {
_actionDelegate = EntryInfoActionDelegate(widget.entry, collection); _actionDelegate = EntryInfoActionDelegate();
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent)); _subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
} }
@ -241,6 +242,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
slivers: [ slivers: [
InfoAppBar( InfoAppBar(
entry: entry, entry: entry,
collection: collection,
actionDelegate: _actionDelegate, actionDelegate: _actionDelegate,
metadataNotifier: _metadataNotifier, metadataNotifier: _metadataNotifier,
onBackPressed: widget.goToViewer, onBackPressed: widget.goToViewer,
@ -259,7 +261,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
); );
} }
void _onActionDelegateEvent(ActionEvent<EntryInfoAction> event) { void _onActionDelegateEvent(ActionEvent<EntryAction> event) {
Future.delayed(Durations.dialogTransitionAnimation).then((_) { Future.delayed(Durations.dialogTransitionAnimation).then((_) {
if (event is ActionStartedEvent) { if (event is ActionStartedEvent) {
_isEditingMetadataNotifier.value = event.action; _isEditingMetadataNotifier.value = event.action;

View file

@ -4,8 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -0,0 +1,40 @@
import 'dart:collection';
import 'package:flutter/material.dart';
class MetadataDirectory {
final String name;
final Color? color;
final String? parent;
final int? index;
final SplayTreeMap<String, String> allTags;
final SplayTreeMap<String, String> tags;
// special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const mediaDirectory = 'Media'; // custom
static const coverDirectory = 'Cover'; // custom
static const geoTiffDirectory = 'GeoTIFF'; // custom
const MetadataDirectory(
this.name,
this.allTags, {
SplayTreeMap<String, String>? tags,
this.color,
this.parent,
this.index,
}) : tags = tags ?? allTags;
MetadataDirectory filterKeys(bool Function(String key) testKey) {
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
return MetadataDirectory(
name,
tags,
tags: filteredTags,
color: color,
parent: parent,
index: index,
);
}
}

View file

@ -10,7 +10,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/geotiff.dart'; import 'package:aves/widgets/viewer/info/metadata/geotiff.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart';

View file

@ -1,18 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/video/keys.dart'; import 'package:aves/model/entry_info.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/metadata/svg_metadata_service.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
@ -39,10 +33,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier; ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
// directory names may contain the name of their parent directory (as prefix + '/')
// directory names may contain an index (as suffix in '[]')
static final directoryNamePattern = RegExp(r'^((?<parent>.*?)/)?(?<name>.*?)(\[(?<index>\d+)\])?$');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -132,173 +122,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
} }
Future<void> _getMetadata() async { Future<void> _getMetadata() async {
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataFetchService.getAllMetadata(entry)); final titledDirectories = await entry.getMetadataDirectories(context);
final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String;
String? parent;
int? index;
final match = directoryNamePattern.firstMatch(directoryName);
if (match != null) {
parent = match.namedGroup('parent');
final nameMatch = match.namedGroup('name');
if (nameMatch != null) {
directoryName = nameMatch;
}
final indexMatch = match.namedGroup('index');
if (indexMatch != null) {
index = int.tryParse(indexMatch);
}
}
final rawTags = dirKV.value as Map;
return MetadataDirectory(
directoryName,
_toSortedTags(rawTags),
parent: parent,
index: index,
);
}).toList();
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
directories.addAll(await _getStreamDirectories());
}
final titledDirectories = directories.map((dir) {
var title = dir.name;
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
title = '${dir.parent}/$title';
}
if (dir.index != null) {
title += ' ${dir.index}';
}
return MapEntry(title, dir);
}).toList()
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
metadataNotifier.value = Map.fromEntries(titledDirectories); metadataNotifier.value = Map.fromEntries(titledDirectories);
_expandedDirectoryNotifier.value = null; _expandedDirectoryNotifier.value = null;
} }
Future<List<MetadataDirectory>> _getStreamDirectories() async {
final directories = <MetadataDirectory>[];
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry);
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
if (formattedMediaTags.isNotEmpty) {
// overwrite generic directory found from the platform side
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags)));
}
if (mediaInfo.containsKey(Keys.streams)) {
String getTypeText(Map stream) {
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
switch (type) {
case StreamTypes.attachment:
return 'Attachment';
case StreamTypes.audio:
return 'Audio';
case StreamTypes.metadata:
return 'Metadata';
case StreamTypes.subtitle:
case StreamTypes.timedText:
return 'Text';
case StreamTypes.video:
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
case StreamTypes.unknown:
default:
return 'Unknown';
}
}
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
// display known streams as separate directories (e.g. video, audio, subs)
if (knownStreams.isNotEmpty) {
final indexDigits = knownStreams.length.toString().length;
final colors = context.read<AvesColorsData>();
for (final stream in knownStreams) {
final index = (stream[Keys.index] ?? 0) + 1;
final typeText = getTypeText(stream);
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')}$typeText';
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
if (formattedStreamTags.isNotEmpty) {
final color = colors.fromString(typeText);
directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color));
}
}
}
// group attachments by format (e.g. TTF fonts)
if (attachmentStreams.isNotEmpty) {
final formatCount = <String, List<String?>>{};
for (final stream in attachmentStreams) {
final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase();
if (!formatCount.containsKey(codec)) {
formatCount[codec] = [];
}
formatCount[codec]!.add(stream[Keys.filename]);
}
if (formatCount.isNotEmpty) {
final rawTags = formatCount.map((key, value) {
final count = value.length;
// remove duplicate names, so number of displayed names may not match displayed count
final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase);
return MapEntry(key, '$count items: ${names.join(', ')}');
});
directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags)));
}
}
}
return directories;
}
SplayTreeMap<String, String> _toSortedTags(Map rawTags) {
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
var value = (tagKV.value as String? ?? '').trim();
if (value.isEmpty) return null;
final tagName = tagKV.key as String;
return MapEntry(tagName, value);
}).whereNotNull()));
return tags;
}
}
class MetadataDirectory {
final String name;
final Color? color;
final String? parent;
final int? index;
final SplayTreeMap<String, String> allTags;
final SplayTreeMap<String, String> tags;
// special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const mediaDirectory = 'Media'; // custom
static const coverDirectory = 'Cover'; // custom
static const geoTiffDirectory = 'GeoTIFF'; // custom
const MetadataDirectory(
this.name,
this.allTags, {
SplayTreeMap<String, String>? tags,
this.color,
this.parent,
this.index,
}) : tags = tags ?? allTags;
MetadataDirectory filterKeys(bool Function(String key) testKey) {
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
return MetadataDirectory(
name,
tags,
tags: filteredTags,
color: color,
parent: parent,
index: index,
);
}
} }

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/multipage.dart'; import 'package:aves/widgets/viewer/overlay/multipage.dart';
@ -17,7 +18,7 @@ import 'package:tuple/tuple.dart';
class ViewerBottomOverlay extends StatefulWidget { class ViewerBottomOverlay extends StatefulWidget {
final List<AvesEntry> entries; final List<AvesEntry> entries;
final int index; final int index;
final bool hasCollection; final CollectionLens? collection;
final AnimationController animationController; final AnimationController animationController;
final EdgeInsets? viewInsets, viewPadding; final EdgeInsets? viewInsets, viewPadding;
final MultiPageController? multiPageController; final MultiPageController? multiPageController;
@ -26,7 +27,7 @@ class ViewerBottomOverlay extends StatefulWidget {
super.key, super.key,
required this.entries, required this.entries,
required this.index, required this.index,
required this.hasCollection, required this.collection,
required this.animationController, required this.animationController,
this.viewInsets, this.viewInsets,
this.viewPadding, this.viewPadding,
@ -65,7 +66,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
index: widget.index, index: widget.index,
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry, pageEntry: pageEntry ?? mainEntry,
hasCollection: widget.hasCollection, collection: widget.collection,
viewInsets: widget.viewInsets, viewInsets: widget.viewInsets,
viewPadding: widget.viewPadding, viewPadding: widget.viewPadding,
multiPageController: multiPageController, multiPageController: multiPageController,
@ -96,7 +97,7 @@ class _BottomOverlayContent extends StatefulWidget {
final List<AvesEntry> entries; final List<AvesEntry> entries;
final int index; final int index;
final AvesEntry mainEntry, pageEntry; final AvesEntry mainEntry, pageEntry;
final bool hasCollection; final CollectionLens? collection;
final EdgeInsets? viewInsets, viewPadding; final EdgeInsets? viewInsets, viewPadding;
final MultiPageController? multiPageController; final MultiPageController? multiPageController;
final AnimationController animationController; final AnimationController animationController;
@ -106,7 +107,7 @@ class _BottomOverlayContent extends StatefulWidget {
required this.index, required this.index,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.hasCollection, required this.collection,
required this.viewInsets, required this.viewInsets,
required this.viewPadding, required this.viewPadding,
required this.multiPageController, required this.multiPageController,
@ -167,8 +168,8 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
: ViewerButtons( : ViewerButtons(
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry, pageEntry: pageEntry,
collection: widget.collection,
scale: _buttonScale, scale: _buttonScale,
canToggleFavourite: widget.hasCollection,
), ),
); );

View file

@ -60,16 +60,38 @@ class OverlayButton extends StatelessWidget {
static double getSize(BuildContext context) => 48.0 + AvesBorder.curvedBorderWidth * 2; static double getSize(BuildContext context) => 48.0 + AvesBorder.curvedBorderWidth * 2;
} }
class OverlayTextButton extends StatelessWidget { class ScalingOverlayTextButton extends StatelessWidget {
final Animation<double> scale; final Animation<double> scale;
final String buttonLabel;
final VoidCallback? onPressed; final VoidCallback? onPressed;
final Widget child;
const ScalingOverlayTextButton({
super.key,
required this.scale,
this.onPressed,
required this.child,
});
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: scale,
child: OverlayTextButton(
onPressed: onPressed,
child: child,
),
);
}
}
class OverlayTextButton extends StatelessWidget {
final VoidCallback? onPressed;
final Widget child;
const OverlayTextButton({ const OverlayTextButton({
super.key, super.key,
required this.scale,
required this.buttonLabel,
this.onPressed, this.onPressed,
required this.child,
}); });
static const _borderRadius = 123.0; static const _borderRadius = 123.0;
@ -79,25 +101,22 @@ class OverlayTextButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final blurred = settings.enableBlurEffect; final blurred = settings.enableBlurEffect;
final theme = Theme.of(context); final theme = Theme.of(context);
return SizeTransition( return BlurredRRect.all(
sizeFactor: scale, enabled: blurred,
child: BlurredRRect.all( borderRadius: _borderRadius,
enabled: blurred, child: OutlinedButton(
borderRadius: _borderRadius, onPressed: onPressed,
child: OutlinedButton( style: ButtonStyle(
onPressed: onPressed, backgroundColor: MaterialStateProperty.all<Color>(Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred)),
style: ButtonStyle( foregroundColor: MaterialStateProperty.all<Color>(theme.colorScheme.onSurface),
backgroundColor: MaterialStateProperty.all<Color>(Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred)), overlayColor: theme.brightness == Brightness.dark ? MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)) : null,
foregroundColor: MaterialStateProperty.all<Color>(theme.colorScheme.onSurface), minimumSize: _minSize,
overlayColor: theme.brightness == Brightness.dark ? MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)) : null, side: MaterialStateProperty.all<BorderSide>(AvesBorder.curvedSide(context)),
minimumSize: _minSize, shape: MaterialStateProperty.all<OutlinedBorder>(const RoundedRectangleBorder(
side: MaterialStateProperty.all<BorderSide>(AvesBorder.curvedSide(context)), borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
shape: MaterialStateProperty.all<OutlinedBorder>(const RoundedRectangleBorder( )),
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
)),
),
child: Text(buttonLabel),
), ),
child: child,
), ),
); );
} }

View file

@ -22,9 +22,8 @@ class PanoramaOverlay extends StatelessWidget {
return Row( return Row(
children: [ children: [
const Spacer(), const Spacer(),
OverlayTextButton( ScalingOverlayTextButton(
scale: scale, scale: scale,
buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel,
onPressed: () async { onPressed: () async {
final info = await metadataFetchService.getPanoramaInfo(entry); final info = await metadataFetchService.getPanoramaInfo(entry);
if (info != null) { if (info != null) {
@ -40,7 +39,8 @@ class PanoramaOverlay extends StatelessWidget {
)); ));
} }
}, },
) child: Text(context.l10n.viewerOpenPanoramaButtonLabel),
),
], ],
); );
} }

View file

@ -1,7 +1,7 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar/favourite_toggler.dart'; import 'package:aves/widgets/common/app_bar/favourite_toggler.dart';
@ -9,7 +9,6 @@ import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/video/mute_toggler.dart'; import 'package:aves/widgets/viewer/overlay/video/mute_toggler.dart';
@ -23,10 +22,9 @@ import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ViewerButtons extends StatelessWidget { class ViewerButtons extends StatelessWidget {
final AvesEntry mainEntry; final AvesEntry mainEntry, pageEntry;
final AvesEntry pageEntry; final CollectionLens? collection;
final Animation<double> scale; final Animation<double> scale;
final bool canToggleFavourite;
static const double outerPadding = 8; static const double outerPadding = 8;
static const double innerPadding = 8; static const double innerPadding = 8;
@ -39,75 +37,15 @@ class ViewerButtons extends StatelessWidget {
super.key, super.key,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.collection,
required this.scale, required this.scale,
required this.canToggleFavourite,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
final trashed = mainEntry.trashed; final trashed = mainEntry.trashed;
bool _isVisible(EntryAction action) {
if (trashed) {
switch (action) {
case EntryAction.delete:
case EntryAction.restore:
return true;
case EntryAction.debug:
return kDebugMode;
default:
return false;
}
} else {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.toggleFavourite:
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
case EntryAction.copy:
case EntryAction.move:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.convert:
case EntryAction.print:
return !targetEntry.isVideo && device.canPrint;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.videoCaptureFrame:
case EntryAction.videoToggleMute:
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoSettings:
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:
case EntryAction.videoSkip10:
return targetEntry.isVideo;
case EntryAction.rotateScreen:
return settings.isRotationLocked;
case EntryAction.addShortcut:
return device.canPinShortcut;
case EntryAction.info:
case EntryAction.copyToClipboard:
case EntryAction.edit:
case EntryAction.open:
case EntryAction.setAs:
case EntryAction.share:
return true;
case EntryAction.restore:
return false;
case EntryAction.debug:
return kDebugMode;
}
}
}
return SafeArea( return SafeArea(
top: false, top: false,
bottom: false, bottom: false,
@ -118,10 +56,10 @@ class ViewerButtons extends StatelessWidget {
return Selector<Settings, bool>( return Selector<Settings, bool>(
selector: (context, s) => s.isRotationLocked, selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) { builder: (context, s, child) {
final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).toList(); final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(actionDelegate.isVisible).where(actionDelegate.canApply).take(availableCount - 1).toList();
final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
return ViewerButtonRowContent( return ViewerButtonRowContent(
quickActions: quickActions, quickActions: quickActions,
topLevelActions: topLevelActions, topLevelActions: topLevelActions,
@ -130,6 +68,7 @@ class ViewerButtons extends StatelessWidget {
scale: scale, scale: scale,
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry, pageEntry: pageEntry,
collection: collection,
); );
}, },
); );
@ -143,6 +82,7 @@ class ViewerButtonRowContent extends StatelessWidget {
final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions; final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions;
final Animation<double> scale; final Animation<double> scale;
final AvesEntry mainEntry, pageEntry; final AvesEntry mainEntry, pageEntry;
final CollectionLens? collection;
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null); final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
@ -158,6 +98,7 @@ class ViewerButtonRowContent extends StatelessWidget {
required this.scale, required this.scale,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.collection,
}); });
@override @override
@ -358,17 +299,7 @@ class ViewerButtonRowContent extends StatelessWidget {
} }
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) { PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) {
bool canApply(EntryAction action) { final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
switch (action) {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return pageEntry.canRotate;
case EntryAction.flip:
return pageEntry.canFlip;
default:
return true;
}
}
Widget buildDivider() => const SizedBox( Widget buildDivider() => const SizedBox(
height: 16, height: 16,
@ -386,7 +317,7 @@ class ViewerButtonRowContent extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: PopupMenuItem( child: PopupMenuItem(
value: action, value: action,
enabled: canApply(action), enabled: actionDelegate.canApply(action),
child: Tooltip( child: Tooltip(
message: action.getText(context), message: action.getText(context),
child: Center(child: action.getIcon()), child: Center(child: action.getIcon()),
@ -423,17 +354,6 @@ class ViewerButtonRowContent extends StatelessWidget {
} }
void _onActionSelected(BuildContext context, EntryAction action) { void _onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry; EntryActionDelegate(mainEntry, pageEntry, collection).onActionSelected(context, action);
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
EntryActionDelegate(targetEntry).onActionSelected(context, action);
} }
} }

View file

@ -42,10 +42,10 @@ class WallpaperButtons extends StatelessWidget with FeedbackMixin {
const Spacer(), const Spacer(),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: padding / 2), padding: const EdgeInsets.symmetric(horizontal: padding / 2),
child: OverlayTextButton( child: ScalingOverlayTextButton(
scale: scale, scale: scale,
buttonLabel: context.l10n.viewerSetWallpaperButtonLabel,
onPressed: () => _setWallpaper(context), onPressed: () => _setWallpaper(context),
child: Text(context.l10n.viewerSetWallpaperButtonLabel),
), ),
), ),
], ],

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.dart'; import 'package:aves/widgets/viewer/video/fijkplayer.dart';
@ -34,5 +36,9 @@ class VideoConductor {
return _controllers.firstWhereOrNull((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId); return _controllers.firstWhereOrNull((c) => c.entry.uri == entry.uri && c.entry.pageId == entry.pageId);
} }
Future<void> pauseAll() => Future.forEach<AvesVideoController>(_controllers, (controller) => controller.pause()); Future<void> _applyToAll(FutureOr Function(AvesVideoController controller) action) => Future.forEach<AvesVideoController>(_controllers, action);
Future<void> pauseAll() => _applyToAll((controller) => controller.pause());
Future<void> muteAll(bool muted) => _applyToAll((controller) => controller.mute(muted));
} }

View file

@ -144,7 +144,7 @@ abstract class AvesVideoController {
Future<Uint8List> captureFrame(); Future<Uint8List> captureFrame();
Future<void> toggleMute(); Future<void> mute(bool muted);
Widget buildPlayerWidget(BuildContext context); Widget buildPlayerWidget(BuildContext context);
} }

View file

@ -363,8 +363,8 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
bool get isMuted => _volume == 0; bool get isMuted => _volume == 0;
@override @override
Future<void> toggleMute() async { Future<void> mute(bool muted) async {
_volume = isMuted ? 1 : 0; _volume = muted ? 0 : 1;
_volumeStreamController.add(_volume); _volumeStreamController.add(_volume);
await _applyVolume(); await _applyVolume();
} }

View file

@ -35,39 +35,39 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
stopOverlayHidingTimer(); stopOverlayHidingTimer();
} }
void onActionSelected(BuildContext context, AvesVideoController controller, EntryAction action) { Future<void> onActionSelected(BuildContext context, AvesVideoController controller, EntryAction action) async {
// make sure overlay is not disappearing when selecting an action // make sure overlay is not disappearing when selecting an action
stopOverlayHidingTimer(); stopOverlayHidingTimer();
const ToggleOverlayNotification(visible: true).dispatch(context); const ToggleOverlayNotification(visible: true).dispatch(context);
switch (action) { switch (action) {
case EntryAction.videoCaptureFrame: case EntryAction.videoCaptureFrame:
_captureFrame(context, controller); await _captureFrame(context, controller);
break; break;
case EntryAction.videoToggleMute: case EntryAction.videoToggleMute:
controller.toggleMute(); await controller.mute(!controller.isMuted);
break; break;
case EntryAction.videoSelectStreams: case EntryAction.videoSelectStreams:
_showStreamSelectionDialog(context, controller); await _showStreamSelectionDialog(context, controller);
break; break;
case EntryAction.videoSetSpeed: case EntryAction.videoSetSpeed:
_showSpeedDialog(context, controller); await _showSpeedDialog(context, controller);
break; break;
case EntryAction.videoSettings: case EntryAction.videoSettings:
_showSettings(context, controller); await _showSettings(context, controller);
break; break;
case EntryAction.videoTogglePlay: case EntryAction.videoTogglePlay:
_togglePlayPause(context, controller); await _togglePlayPause(context, controller);
break; break;
case EntryAction.videoReplay10: case EntryAction.videoReplay10:
controller.seekTo(controller.currentPosition - 10000); await controller.seekTo(controller.currentPosition - 10000);
break; break;
case EntryAction.videoSkip10: case EntryAction.videoSkip10:
controller.seekTo(controller.currentPosition + 10000); await controller.seekTo(controller.currentPosition + 10000);
break; break;
case EntryAction.open: case EntryAction.open:
final entry = controller.entry; final entry = controller.entry;
androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { await androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;

View file

@ -17,6 +17,8 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
final Map<AvesEntry, VoidCallback> _metadataChangeListeners = {}; final Map<AvesEntry, VoidCallback> _metadataChangeListeners = {};
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {}; final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
bool? videoMutedOverride;
bool get isViewingImage; bool get isViewingImage;
ValueNotifier<AvesEntry?> get entryNotifier; ValueNotifier<AvesEntry?> get entryNotifier;
@ -89,6 +91,10 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
} }
bool get shouldAutoPlayVideoMuted { bool get shouldAutoPlayVideoMuted {
if (videoMutedOverride != null) {
return videoMutedOverride!;
}
switch (videoPlaybackOverride) { switch (videoPlaybackOverride) {
case SlideshowVideoPlayback.skip: case SlideshowVideoPlayback.skip:
case SlideshowVideoPlayback.playWithSound: case SlideshowVideoPlayback.playWithSound:
@ -189,7 +195,7 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
await Future.delayed(const Duration(milliseconds: 300) * timeDilation); await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
if (!videoController.isMuted && shouldAutoPlayVideoMuted) { if (!videoController.isMuted && shouldAutoPlayVideoMuted) {
await videoController.toggleMute(); await videoController.mute(true);
} }
if (resumeTimeMillis != null) { if (resumeTimeMillis != null) {

View file

@ -113,8 +113,11 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
viewerController.startAutopilotAnimation( viewerController.startAutopilotAnimation(
vsync: this, vsync: this,
onUpdate: ({required scaleLevel}) { onUpdate: ({required scaleLevel}) {
final scale = _magnifierController.scaleBoundaries.scaleForLevel(scaleLevel); final boundaries = _magnifierController.scaleBoundaries;
_magnifierController.update(scale: scale, source: ChangeSource.animation); if (boundaries != null) {
final scale = boundaries.scaleForLevel(scaleLevel);
_magnifierController.update(scale: scale, source: ChangeSource.animation);
}
}); });
} }
@ -318,11 +321,14 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
// while cover is fading out, the same controller is used for both the cover and the video, // while cover is fading out, the same controller is used for both the cover and the video,
// and both fire scale boundaries events, so we make sure that in the end // and both fire scale boundaries events, so we make sure that in the end
// the scale boundaries from the video are used after the cover is gone // the scale boundaries from the video are used after the cover is gone
_magnifierController.setScaleBoundaries( final boundaries = _magnifierController.scaleBoundaries;
_magnifierController.scaleBoundaries.copyWith( if (boundaries != null) {
childSize: videoDisplaySize, _magnifierController.setScaleBoundaries(
), boundaries.copyWith(
); childSize: videoDisplaySize,
),
);
}
}, },
child: ValueListenableBuilder<ImageInfo?>( child: ValueListenableBuilder<ImageInfo?>(
valueListenable: _videoCoverInfoNotifier, valueListenable: _videoCoverInfoNotifier,

View file

@ -1,3 +1,4 @@
import 'package:aves/model/settings/enums/subtitle_position.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart'; import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/basic/text_background_painter.dart'; import 'package:aves/widgets/common/basic/text_background_painter.dart';
@ -33,6 +34,7 @@ class VideoSubtitles extends StatelessWidget {
child: Consumer<Settings>( child: Consumer<Settings>(
builder: (context, settings, child) { builder: (context, settings, child) {
final baseTextAlign = settings.subtitleTextAlignment; final baseTextAlign = settings.subtitleTextAlignment;
final baseTextAlignY = settings.subtitleTextPosition.toTextAlignVertical();
final baseOutlineWidth = settings.subtitleShowOutline ? 1 : 0; final baseOutlineWidth = settings.subtitleShowOutline ? 1 : 0;
final baseOutlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity); final baseOutlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
final baseShadows = [ final baseShadows = [
@ -119,7 +121,8 @@ class VideoSubtitles extends StatelessWidget {
); );
}).toList(); }).toList();
final drawingPaths = extraStyle.drawingPaths; final drawingPaths = extraStyle.drawingPaths;
final textAlign = extraStyle.hAlign ?? (position != null ? TextAlign.center : baseTextAlign); final textHAlign = extraStyle.hAlign ?? (position != null ? TextAlign.center : baseTextAlign);
final textVAlign = extraStyle.vAlign ?? (position != null ? TextAlignVertical.bottom : baseTextAlignY);
Widget child; Widget child;
if (drawingPaths != null) { if (drawingPaths != null) {
@ -138,7 +141,7 @@ class VideoSubtitles extends StatelessWidget {
outlineWidth: outlineWidth * (position != null ? viewScale : baseOutlineWidth), outlineWidth: outlineWidth * (position != null ? viewScale : baseOutlineWidth),
outlineColor: extraStyle.borderColor ?? baseOutlineColor, outlineColor: extraStyle.borderColor ?? baseOutlineColor,
outlineBlurSigma: extraStyle.edgeBlur ?? 0, outlineBlurSigma: extraStyle.edgeBlur ?? 0,
textAlign: textAlign, textAlign: textHAlign,
); );
} }
@ -154,7 +157,7 @@ class VideoSubtitles extends StatelessWidget {
final textHeight = para.getMaxIntrinsicHeight(double.infinity); final textHeight = para.getMaxIntrinsicHeight(double.infinity);
late double anchorOffsetX, anchorOffsetY; late double anchorOffsetX, anchorOffsetY;
switch (textAlign) { switch (textHAlign) {
case TextAlign.left: case TextAlign.left:
anchorOffsetX = 0; anchorOffsetX = 0;
break; break;
@ -166,7 +169,7 @@ class VideoSubtitles extends StatelessWidget {
anchorOffsetX = -textWidth / 2; anchorOffsetX = -textWidth / 2;
break; break;
} }
switch (extraStyle.vAlign ?? TextAlignVertical.bottom) { switch (textVAlign) {
case TextAlignVertical.top: case TextAlignVertical.top:
anchorOffsetY = 0; anchorOffsetY = 0;
break; break;
@ -214,7 +217,7 @@ class VideoSubtitles extends StatelessWidget {
if (position == null) { if (position == null) {
late double alignX; late double alignX;
switch (textAlign) { switch (textHAlign) {
case TextAlign.left: case TextAlign.left:
alignX = -1; alignX = -1;
break; break;
@ -227,7 +230,7 @@ class VideoSubtitles extends StatelessWidget {
break; break;
} }
late double alignY; late double alignY;
switch (extraStyle.vAlign) { switch (textVAlign) {
case TextAlignVertical.top: case TextAlignVertical.top:
alignY = -bottom; alignY = -bottom;
break; break;
@ -248,7 +251,7 @@ class VideoSubtitles extends StatelessWidget {
style: DefaultTextStyle.of(context).style.merge(spans.first.style!.copyWith( style: DefaultTextStyle.of(context).style.merge(spans.first.style!.copyWith(
backgroundColor: settings.subtitleBackgroundColor, backgroundColor: settings.subtitleBackgroundColor,
)), )),
textAlign: textAlign, textAlign: textHAlign,
child: child, child: child,
), ),
), ),

View file

@ -211,7 +211,7 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
ViewerBottomOverlay( ViewerBottomOverlay(
entries: [widget.entry], entries: [widget.entry],
index: 0, index: 0,
hasCollection: false, collection: null,
animationController: _overlayAnimationController, animationController: _overlayAnimationController,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,

View file

@ -31,7 +31,6 @@ class _WelcomePageState extends State<WelcomePage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
settings.setContextualDefaults();
_termsLoader = rootBundle.loadString(termsPath); _termsLoader = rootBundle.loadString(termsPath);
WidgetsBinding.instance.addPostFrameCallback((_) => _initWelcomeSettings()); WidgetsBinding.instance.addPostFrameCallback((_) => _initWelcomeSettings());
} }
@ -40,6 +39,7 @@ class _WelcomePageState extends State<WelcomePage> {
// so they are not subject to future default changes // so they are not subject to future default changes
void _initWelcomeSettings() { void _initWelcomeSettings() {
// this should be done outside of `initState`/`build` // this should be done outside of `initState`/`build`
settings.setContextualDefaults(context.read<AppFlavor>());
settings.isInstalledAppAccessAllowed = SettingsDefaults.isInstalledAppAccessAllowed; settings.isInstalledAppAccessAllowed = SettingsDefaults.isInstalledAppAccessAllowed;
settings.isErrorReportingAllowed = SettingsDefaults.isErrorReportingAllowed; settings.isErrorReportingAllowed = SettingsDefaults.isErrorReportingAllowed;
} }

View file

@ -23,7 +23,7 @@ migrate_working_dir/
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock #/pubspec.lock
**/doc/api/ **/doc/api/
.dart_tool/ .dart_tool/
.packages .packages

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