Merge branch 'develop'
This commit is contained in:
commit
af636f175d
153 changed files with 5411 additions and 1037 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
# Available versions may lag behind https://github.com/flutter/flutter.git
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.3.8'
|
||||
flutter-version: '3.3.9'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Clone the repository.
|
||||
|
|
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
|
@ -19,14 +19,9 @@ jobs:
|
|||
# Available versions may lag behind https://github.com/flutter/flutter.git
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.3.8'
|
||||
flutter-version: '3.3.9'
|
||||
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.
|
||||
uses: actions/checkout@v2
|
||||
|
||||
|
@ -50,22 +45,28 @@ jobs:
|
|||
# `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
|
||||
# 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: |
|
||||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
mkdir outputs
|
||||
(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
|
||||
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
|
||||
(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
|
||||
(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
|
||||
(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
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,6 +8,7 @@
|
|||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <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
|
||||
|
||||
### Added
|
||||
|
|
10
README.md
10
README.md
|
@ -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"
|
||||
alt='Get it on Huawei AppGallery'
|
||||
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"
|
||||
alt='Get it on Amazon Appstore'
|
||||
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:
|
||||
```
|
||||
# (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.
|
||||
|
@ -125,10 +128,5 @@ To run the app:
|
|||
# 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
|
||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
||||
|
|
|
@ -4,7 +4,7 @@ plugins {
|
|||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
def appId = "deckers.thibault.aves"
|
||||
def packageName = "deckers.thibault.aves"
|
||||
|
||||
// Flutter properties
|
||||
|
||||
|
@ -49,7 +49,7 @@ android {
|
|||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId appId
|
||||
applicationId packageName
|
||||
// minSdkVersion constraints:
|
||||
// - Flutter & other plugins: 16
|
||||
// - google_maps_flutter v2.1.1: 20
|
||||
|
@ -63,7 +63,6 @@ android {
|
|||
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey'],
|
||||
huaweiApiKey: keystoreProperties['huaweiApiKey']]
|
||||
multiDexEnabled true
|
||||
resValue 'string', 'search_provider', "${appId}.search_provider"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
@ -81,8 +80,6 @@ android {
|
|||
play {
|
||||
// Google Play
|
||||
dimension "store"
|
||||
ext.useCrashlytics = true
|
||||
ext.useHMS = false
|
||||
// generate a universal APK without x86 native libs
|
||||
ext.useNdkAbiFilters = true
|
||||
}
|
||||
|
@ -90,8 +87,6 @@ android {
|
|||
huawei {
|
||||
// Huawei AppGallery
|
||||
dimension "store"
|
||||
ext.useCrashlytics = false
|
||||
ext.useHMS = true
|
||||
// generate a universal APK without x86 native libs
|
||||
ext.useNdkAbiFilters = true
|
||||
}
|
||||
|
@ -101,21 +96,27 @@ android {
|
|||
// check offending libraries with `scanapk`
|
||||
// cf https://android.izzysoft.de/articles/named/app-modules-2
|
||||
dimension "store"
|
||||
ext.useCrashlytics = false
|
||||
ext.useHMS = false
|
||||
// generate APK by ABI, but NDK ABI filters are incompatible with split APK generation
|
||||
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 {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
resValue 'string', 'search_provider', "${appId}.debug.search_provider"
|
||||
}
|
||||
profile {
|
||||
applicationIdSuffix ".profile"
|
||||
resValue 'string', 'search_provider', "${appId}.profile.search_provider"
|
||||
}
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
|
@ -124,6 +125,11 @@ android {
|
|||
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 ->
|
||||
def tasks = gradle.startParameter.taskNames.toString().toLowerCase()
|
||||
if (tasks.contains(flavor.name) && flavor.ext.useNdkAbiFilters) {
|
||||
|
@ -138,6 +144,7 @@ android {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable 'InvalidPackage'
|
||||
}
|
||||
|
@ -183,8 +190,8 @@ dependencies {
|
|||
// - https://jitpack.io/p/deckerst/mp4parser
|
||||
// - https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||
implementation 'com.github.deckerst.mp4parser:isoparser:64b571fdfb'
|
||||
implementation 'com.github.deckerst.mp4parser:muxer:64b571fdfb'
|
||||
implementation 'com.github.deckerst.mp4parser:isoparser:7b698ab674'
|
||||
implementation 'com.github.deckerst.mp4parser:muxer:7b698ab674'
|
||||
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||
|
||||
// huawei flavor only
|
||||
|
@ -196,15 +203,13 @@ dependencies {
|
|||
compileOnly rootProject.findProject(':streams_channel')
|
||||
}
|
||||
|
||||
android.productFlavors.each { flavor ->
|
||||
def tasks = gradle.startParameter.taskRequests.toString().toLowerCase()
|
||||
if (tasks.contains(flavor.name) && flavor.ext.useCrashlytics) {
|
||||
println("Building flavor [${flavor.name}] with Crashlytics plugin")
|
||||
if (useCrashlytics) {
|
||||
println("Building flavor with Crashlytics plugin")
|
||||
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")
|
||||
|
||||
if (useHms) {
|
||||
println("Building flavor with HMS plugin")
|
||||
apply plugin: 'com.huawei.agconnect'
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
|
@ -1,2 +0,0 @@
|
|||
<dream xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:settingsActivity="deckers.thibault.aves.debug/deckers.thibault.aves.ScreenSaverSettingsActivity" />
|
4
android/app/src/libre/res/values/strings.xml
Normal file
4
android/app/src/libre/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Aves Libre</string>
|
||||
</resources>
|
4
android/app/src/libreDebug/res/values/strings.xml
Normal file
4
android/app/src/libreDebug/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Aves Libre [Debug]</string>
|
||||
</resources>
|
4
android/app/src/libreProfile/res/values/strings.xml
Normal file
4
android/app/src/libreProfile/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Aves Libre [Profile]</string>
|
||||
</resources>
|
|
@ -172,9 +172,10 @@ This change eventually prevents building the app with Flutter v3.3.3.
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- exported for Android API 19 launcher to access this activity -->
|
||||
<activity
|
||||
android:name=".HomeWidgetSettingsActivity"
|
||||
android:exported="false"
|
||||
android:exported="true"
|
||||
android:theme="@style/NormalTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
|
|
|
@ -3,12 +3,17 @@ package deckers.thibault.aves
|
|||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class HomeWidgetSettingsActivity : MainActivity() {
|
||||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (FlutterUtils.isSoftwareRenderingRequired()) {
|
||||
intent.enableSoftwareRendering()
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// cancel if user does not complete widget setup
|
||||
|
|
|
@ -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.WindowHandler
|
||||
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.getParcelableExtraCompat
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
@ -40,6 +42,14 @@ open class MainActivity : FlutterActivity() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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 {
|
||||
Log.i(LOG_TAG, "onCreate intent extras=$it")
|
||||
}
|
||||
|
|
|
@ -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.WindowHandler
|
||||
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.getParcelableExtraCompat
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
@ -24,6 +26,9 @@ class WallpaperActivity : FlutterActivity() {
|
|||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (FlutterUtils.isSoftwareRenderingRequired()) {
|
||||
intent.enableSoftwareRendering()
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls
|
|||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
|
@ -19,6 +20,7 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
|||
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
||||
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
||||
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
||||
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -76,8 +78,28 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
|||
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 {
|
||||
private val LOG_TAG = LogUtils.createTag<AccessibilityHandler>()
|
||||
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
|
||||
}
|
||||
}
|
|
@ -213,7 +213,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
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 mimeType = call.argument<String>("mimeType")
|
||||
if (uri == null) {
|
||||
|
@ -224,7 +223,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
val intent = Intent(Intent.ACTION_EDIT)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
.setDataAndType(getShareableUri(context, uri), mimeType)
|
||||
val started = safeStartActivityChooser(title, intent)
|
||||
val started = safeStartActivity(intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
@ -327,8 +326,17 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
context.startActivity(intent)
|
||||
return true
|
||||
} catch (e: SecurityException) {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -15,11 +15,8 @@ import android.util.Log
|
|||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -42,6 +39,7 @@ import kotlinx.coroutines.launch
|
|||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import org.mp4parser.IsoFile
|
||||
import org.mp4parser.PropertyBoxParserImpl
|
||||
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
||||
import org.mp4parser.boxes.iso14496.part12.SampleTableBox
|
||||
import java.io.FileInputStream
|
||||
|
@ -344,9 +342,20 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
FileInputStream(it.fileDescriptor).use { stream ->
|
||||
stream.channel.use { channel ->
|
||||
val boxParser = PropertyBoxParserImpl().apply {
|
||||
val skippedTypes = listOf(
|
||||
// parsing `MediaDataBox` can take a long time
|
||||
// parsing `SampleTableBox` may yield OOM
|
||||
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.dumpBoxes(sb)
|
||||
|
|
|
@ -124,6 +124,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
val metadataMap = HashMap<String, MutableMap<String, String>>()
|
||||
var foundExif = false
|
||||
var foundXmp = false
|
||||
var foundMp4Uuid = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>, allowMultiple: Boolean = false) {
|
||||
if (foundXmp && !allowMultiple) return
|
||||
|
@ -209,6 +210,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input)
|
||||
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 {
|
||||
(it.tagCount > 0 || it.errorCount > 0)
|
||||
|
@ -383,16 +385,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
fun fallbackProcessXmp(xmpMeta: XMPMeta) {
|
||||
val thisDirName = XmpDirectory().name
|
||||
val dirMap = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = dirMap
|
||||
processXmp(xmpMeta, dirMap)
|
||||
if (dirMap.isNotEmpty()) {
|
||||
metadataMap[thisDirName] = dirMap
|
||||
}
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
// `metadata-extractor` may fail to get UUID boxes for some MP4 files,
|
||||
// so we always check with `mp4parser`, even for smaller files
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
fallbackProcessXmp(dir.xmpMeta)
|
||||
}
|
||||
if (!foundMp4Uuid) {
|
||||
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
|
||||
processMp4Uuid(dir)
|
||||
}
|
||||
|
@ -491,6 +497,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||
var foundExif = false
|
||||
var foundXmp = false
|
||||
var foundMp4Uuid = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) {
|
||||
if (foundXmp && !allowMultiple) return
|
||||
|
@ -543,6 +550,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||
foundMp4Uuid = metadata.directories.any { it is Mp4UuidBoxDirectory && it.tagCount > 0 }
|
||||
|
||||
// File type
|
||||
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)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
// `metadata-extractor` may fail to get UUID boxes for some MP4 files,
|
||||
// so we always check with `mp4parser`, even for smaller files
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
processXmp(dir.xmpMeta)
|
||||
}
|
||||
if (!foundMp4Uuid) {
|
||||
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
|
||||
processMp4Uuid(dir)
|
||||
}
|
||||
|
@ -941,13 +951,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
// `metadata-extractor` may fail to get UUID boxes for some MP4 files,
|
||||
// so we always check with `mp4parser`, even for smaller files
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
processXmp(dir.xmpMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.isEmpty()) {
|
||||
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
|
||||
|
@ -1026,13 +1036,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
// `metadata-extractor` may fail to get UUID boxes for some MP4 files,
|
||||
// so we always check with `mp4parser`, even for smaller files
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
processXmp(dir.xmpMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.success(xmpStrings)
|
||||
}
|
||||
|
|
|
@ -96,7 +96,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes)
|
||||
success(granted)
|
||||
} 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()
|
||||
}
|
||||
|
|
|
@ -15,6 +15,9 @@ import java.io.FileInputStream
|
|||
import java.nio.channels.Channels
|
||||
|
||||
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>> {
|
||||
// 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")
|
||||
|
@ -22,9 +25,17 @@ object Mp4ParserHelper {
|
|||
FileInputStream(it.fileDescriptor).use { stream ->
|
||||
stream.channel.use { channel ->
|
||||
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
|
||||
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`
|
||||
IsoFile(channel, boxParser).use { isoFile ->
|
||||
|
|
|
@ -17,11 +17,13 @@ import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
|
|||
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
|
||||
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MemoryUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import org.mp4parser.IsoFile
|
||||
import org.mp4parser.PropertyBoxParserImpl
|
||||
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.SampleTableBox
|
||||
import java.io.FileInputStream
|
||||
|
@ -143,19 +145,32 @@ object XMP {
|
|||
FileInputStream(it.fileDescriptor).use { stream ->
|
||||
stream.channel.use { channel ->
|
||||
val boxParser = PropertyBoxParserImpl().apply {
|
||||
val skippedTypes = listOf(
|
||||
// parsing `MediaDataBox` can take a long time
|
||||
// parsing `SampleTableBox` may yield OOM
|
||||
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`
|
||||
IsoFile(channel, boxParser).use { isoFile ->
|
||||
isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
|
||||
val boxSize = box.size
|
||||
if (MemoryUtils.canAllocate(boxSize)) {
|
||||
val bytes = box.toBytes()
|
||||
val payload = bytes.copyOfRange(8, bytes.size)
|
||||
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null)
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
@ -63,4 +65,29 @@ object FlutterUtils {
|
|||
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"))
|
||||
}
|
2
android/app/src/main/res/values-ar/strings.xml
Normal file
2
android/app/src/main/res/values-ar/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
12
android/app/src/main/res/values-ro/strings.xml
Normal file
12
android/app/src/main/res/values-ro/strings.xml
Normal 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>
|
|
@ -1,2 +1,2 @@
|
|||
<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" />
|
|
@ -1,2 +0,0 @@
|
|||
<dream xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:settingsActivity="deckers.thibault.aves.profile/deckers.thibault.aves.ScreenSaverSettingsActivity" />
|
|
@ -1,29 +1,49 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
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 {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
if (useHms) {
|
||||
// HMS (used by some flavors only)
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
if (useCrashlytics) {
|
||||
// GMS & Firebase Crashlytics (used by some flavors only)
|
||||
classpath 'com.google.gms:google-services:4.3.14'
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
if (useHms) {
|
||||
// HMS (used by some flavors only)
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
}
|
||||
}
|
||||
|
||||
// gradle.projectsEvaluated {
|
||||
// tasks.withType(JavaCompile) {
|
||||
// options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
|
||||
|
|
5
fastlane/metadata/android/ar/full_description.txt
Normal file
5
fastlane/metadata/android/ar/full_description.txt
Normal file
|
@ -0,0 +1,5 @@
|
|||
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
|
||||
|
||||
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||
|
||||
<i>Aves</i> integrates with Android (from <b>API 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>.
|
1
fastlane/metadata/android/ar/short_description.txt
Normal file
1
fastlane/metadata/android/ar/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
المعرض ومستكشف البيانات الوصفية
|
5
fastlane/metadata/android/en-US/changelogs/1085.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/1085.txt
Normal 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
|
5
fastlane/metadata/android/ro/full_description.txt
Normal file
5
fastlane/metadata/android/ro/full_description.txt
Normal 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>.
|
1
fastlane/metadata/android/ro/short_description.txt
Normal file
1
fastlane/metadata/android/ro/short_description.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Galeria și exploratorul de metadate
|
|
@ -1,4 +1,4 @@
|
|||
enum AppFlavor { play, huawei, izzy }
|
||||
enum AppFlavor { play, huawei, izzy, libre }
|
||||
|
||||
extension ExtraAppFlavor on AppFlavor {
|
||||
bool get canEnableErrorReporting {
|
||||
|
@ -7,6 +7,18 @@ extension ExtraAppFlavor on AppFlavor {
|
|||
return true;
|
||||
case AppFlavor.huawei:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
1
lib/l10n/app_ar.arb
Normal file
1
lib/l10n/app_ar.arb
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -1156,5 +1156,7 @@
|
|||
"tagPlaceholderPlace": "Ort",
|
||||
"@tagPlaceholderPlace": {},
|
||||
"editEntryLocationDialogSetCustom": "Benutzerdefinierten Standort festlegen",
|
||||
"@editEntryLocationDialogSetCustom": {}
|
||||
"@editEntryLocationDialogSetCustom": {},
|
||||
"entryInfoActionExportMetadata": "Metadaten exportieren",
|
||||
"@entryInfoActionExportMetadata": {}
|
||||
}
|
||||
|
|
|
@ -1154,5 +1154,23 @@
|
|||
"tagPlaceholderCountry": "Χώρα",
|
||||
"@tagPlaceholderCountry": {},
|
||||
"tagPlaceholderPlace": "Μέρος",
|
||||
"@tagPlaceholderPlace": {}
|
||||
"@tagPlaceholderPlace": {},
|
||||
"settingsWidgetDisplayedItem": "Εμφανιζόμενο αρχείο",
|
||||
"@settingsWidgetDisplayedItem": {},
|
||||
"tagEditorSectionPlaceholders": "Καταχώρηση τοποθεσίας",
|
||||
"@tagEditorSectionPlaceholders": {},
|
||||
"subtitlePositionBottom": "Κάτω",
|
||||
"@subtitlePositionBottom": {},
|
||||
"settingsSubtitleThemeTextPositionTile": "Θέση κειμένου",
|
||||
"@settingsSubtitleThemeTextPositionTile": {},
|
||||
"subtitlePositionTop": "Πάνω",
|
||||
"@subtitlePositionTop": {},
|
||||
"widgetDisplayedItemRandom": "Τυχαίο",
|
||||
"@widgetDisplayedItemRandom": {},
|
||||
"widgetDisplayedItemMostRecent": "Πιο πρόσφατο",
|
||||
"@widgetDisplayedItemMostRecent": {},
|
||||
"settingsSubtitleThemeTextPositionDialogTitle": "Θεση κειμενου",
|
||||
"@settingsSubtitleThemeTextPositionDialogTitle": {},
|
||||
"entryInfoActionExportMetadata": "Εξαγωγή μεταδεδομένων",
|
||||
"@entryInfoActionExportMetadata": {}
|
||||
}
|
||||
|
|
|
@ -122,6 +122,7 @@
|
|||
"entryInfoActionEditRating": "Edit rating",
|
||||
"entryInfoActionEditTags": "Edit tags",
|
||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||
"entryInfoActionExportMetadata": "Export metadata",
|
||||
|
||||
"filterBinLabel": "Recycle bin",
|
||||
"filterFavouriteLabel": "Favorite",
|
||||
|
@ -197,6 +198,9 @@
|
|||
"displayRefreshRatePreferHighest": "Highest rate",
|
||||
"displayRefreshRatePreferLowest": "Lowest rate",
|
||||
|
||||
"subtitlePositionTop": "Top",
|
||||
"subtitlePositionBottom": "Bottom",
|
||||
|
||||
"videoPlaybackSkip": "Skip",
|
||||
"videoPlaybackMuted": "Play muted",
|
||||
"videoPlaybackWithSound": "Play with sound",
|
||||
|
@ -215,6 +219,9 @@
|
|||
"wallpaperTargetLock": "Lock screen",
|
||||
"wallpaperTargetHomeLock": "Home and lock screens",
|
||||
|
||||
"widgetDisplayedItemRandom": "Random",
|
||||
"widgetDisplayedItemMostRecent": "Most recent",
|
||||
|
||||
"widgetOpenPageHome": "Open home",
|
||||
"widgetOpenPageCollection": "Open collection",
|
||||
"widgetOpenPageViewer": "Open viewer",
|
||||
|
@ -737,6 +744,8 @@
|
|||
"settingsSubtitleThemeSample": "This is a sample.",
|
||||
"settingsSubtitleThemeTextAlignmentTile": "Text alignment",
|
||||
"settingsSubtitleThemeTextAlignmentDialogTitle": "Text Alignment",
|
||||
"settingsSubtitleThemeTextPositionTile": "Text position",
|
||||
"settingsSubtitleThemeTextPositionDialogTitle": "Text Position",
|
||||
"settingsSubtitleThemeTextSize": "Text size",
|
||||
"settingsSubtitleThemeShowOutline": "Show outline and shadow",
|
||||
"settingsSubtitleThemeTextColor": "Text color",
|
||||
|
@ -805,6 +814,7 @@
|
|||
"settingsWidgetPageTitle": "Photo Frame",
|
||||
"settingsWidgetShowOutline": "Outline",
|
||||
"settingsWidgetOpenPage": "When tapping on the widget",
|
||||
"settingsWidgetDisplayedItem": "Displayed item",
|
||||
|
||||
"settingsCollectionTile": "Collection",
|
||||
|
||||
|
|
|
@ -1156,5 +1156,21 @@
|
|||
"tagEditorSectionPlaceholders": "Marcadores de la posición",
|
||||
"@tagEditorSectionPlaceholders": {},
|
||||
"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": {}
|
||||
}
|
||||
|
|
|
@ -1156,5 +1156,21 @@
|
|||
"tagPlaceholderCountry": "Pays",
|
||||
"@tagPlaceholderCountry": {},
|
||||
"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": {}
|
||||
}
|
||||
|
|
|
@ -325,7 +325,7 @@
|
|||
"@otherDirectoryDescription": {},
|
||||
"storageAccessDialogMessage": "Si prega di selezionare la {directory} di «{volume}» nella prossima schermata per dare accesso a questa applicazione.",
|
||||
"@storageAccessDialogMessage": {},
|
||||
"restrictedAccessDialogMessage": "Questa applicazione non è autorizzata a modificare i file nella {directory} di «{volume}».\n\nUtilizzare un gestore di file o un’applicazione di galleria preinstallata per spostare gli elementi in un’altra directory.",
|
||||
"restrictedAccessDialogMessage": "Questa applicazione non è autorizzata a modificare i file nella {directory} di «{volume}».\n\nUsa un gestore file o un’app galleria preinstallata per spostare gli elementi in un’altra cartella.",
|
||||
"@restrictedAccessDialogMessage": {},
|
||||
"notEnoughSpaceDialogMessage": "Questa operazione ha bisogno di {neededSize} di spazio libero su «{volume}» per essere completata, ma è rimasto solo {freeSize}.",
|
||||
"@notEnoughSpaceDialogMessage": {},
|
||||
|
@ -449,7 +449,7 @@
|
|||
"@videoStreamSelectionDialogAudio": {},
|
||||
"videoStreamSelectionDialogText": "Sottotitoli",
|
||||
"@videoStreamSelectionDialogText": {},
|
||||
"videoStreamSelectionDialogOff": "Off",
|
||||
"videoStreamSelectionDialogOff": "Spento",
|
||||
"@videoStreamSelectionDialogOff": {},
|
||||
"videoStreamSelectionDialogTrack": "Traccia",
|
||||
"@videoStreamSelectionDialogTrack": {},
|
||||
|
@ -1156,5 +1156,21 @@
|
|||
"widgetOpenPageCollection": "Apri collezione",
|
||||
"@widgetOpenPageCollection": {},
|
||||
"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": {}
|
||||
}
|
||||
|
|
|
@ -1156,5 +1156,21 @@
|
|||
"tagPlaceholderPlace": "장소",
|
||||
"@tagPlaceholderPlace": {},
|
||||
"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
718
lib/l10n/app_ro.arb
Normal 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": {}
|
||||
}
|
|
@ -1156,5 +1156,9 @@
|
|||
"editEntryLocationDialogSetCustom": "Редактировать местоположение",
|
||||
"@editEntryLocationDialogSetCustom": {},
|
||||
"settingsAllowMediaManagement": "Разрешить управление медиа",
|
||||
"@settingsAllowMediaManagement": {}
|
||||
"@settingsAllowMediaManagement": {},
|
||||
"entryInfoActionExportMetadata": "Экспорт метаданных",
|
||||
"@entryInfoActionExportMetadata": {},
|
||||
"subtitlePositionBottom": "Внизу",
|
||||
"@subtitlePositionBottom": {}
|
||||
}
|
||||
|
|
|
@ -527,8 +527,6 @@
|
|||
"@aboutLicensesSectionTitle": {},
|
||||
"aboutLicensesBanner": "本应用使用以下开源软件包和库",
|
||||
"@aboutLicensesBanner": {},
|
||||
"aboutLicensesAndroidLibrariesSectionTitle": "Android Libraries",
|
||||
"@aboutLicensesAndroidLibrariesSectionTitle": {},
|
||||
"aboutLicensesFlutterPluginsSectionTitle": "Flutter Plugins",
|
||||
"@aboutLicensesFlutterPluginsSectionTitle": {},
|
||||
"aboutLicensesFlutterPackagesSectionTitle": "Flutter Packages",
|
||||
|
@ -1146,5 +1144,15 @@
|
|||
"widgetOpenPageCollection": "打开媒体集",
|
||||
"@widgetOpenPageCollection": {},
|
||||
"durationDialogSeconds": "秒",
|
||||
"@durationDialogSeconds": {}
|
||||
"@durationDialogSeconds": {},
|
||||
"settingsAllowMediaManagement": "允许媒体管理",
|
||||
"@settingsAllowMediaManagement": {},
|
||||
"tagEditorSectionPlaceholders": "占位符",
|
||||
"@tagEditorSectionPlaceholders": {},
|
||||
"editEntryLocationDialogSetCustom": "设置自定义位置",
|
||||
"@editEntryLocationDialogSetCustom": {},
|
||||
"tagPlaceholderCountry": "国家",
|
||||
"@tagPlaceholderCountry": {},
|
||||
"tagPlaceholderPlace": "地方",
|
||||
"@tagPlaceholderPlace": {}
|
||||
}
|
||||
|
|
11
lib/main_libre.dart
Normal file
11
lib/main_libre.dart
Normal 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);
|
|
@ -38,6 +38,19 @@ enum EntryAction {
|
|||
setAs,
|
||||
// platform
|
||||
rotateScreen,
|
||||
// metadata
|
||||
editDate,
|
||||
editLocation,
|
||||
editTitleDescription,
|
||||
editRating,
|
||||
editTags,
|
||||
removeMetadata,
|
||||
exportMetadata,
|
||||
// metadata / GeoTIFF
|
||||
showGeoTiffOnMap,
|
||||
// metadata / motion photo
|
||||
convertMotionPhotoToStillImage,
|
||||
viewMotionPhotoVideo,
|
||||
// debug
|
||||
debug,
|
||||
}
|
||||
|
@ -99,6 +112,22 @@ class EntryActions {
|
|||
EntryAction.videoSelectStreams,
|
||||
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 {
|
||||
|
@ -170,6 +199,29 @@ extension ExtraEntryAction on EntryAction {
|
|||
// platform
|
||||
case EntryAction.rotateScreen:
|
||||
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
|
||||
case EntryAction.debug:
|
||||
return 'Debug';
|
||||
|
@ -258,6 +310,29 @@ extension ExtraEntryAction on EntryAction {
|
|||
// platform
|
||||
case EntryAction.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
|
||||
case EntryAction.debug:
|
||||
return AIcons.debug;
|
||||
|
|
|
@ -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
154
lib/model/entry_info.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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/countries_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||
import 'package:aves_map/aves_map.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsDefaults {
|
||||
|
@ -99,12 +98,12 @@ class SettingsDefaults {
|
|||
// subtitles
|
||||
static const subtitleFontSize = 20.0;
|
||||
static const subtitleTextAlignment = TextAlign.center;
|
||||
static const subtitleTextPosition = SubtitlePosition.bottom;
|
||||
static const subtitleShowOutline = true;
|
||||
static const subtitleTextColor = Colors.white;
|
||||
static const subtitleBackgroundColor = Colors.transparent;
|
||||
|
||||
// info
|
||||
static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value
|
||||
static const infoMapZoom = 12.0;
|
||||
static const coordinateFormat = CoordinateFormat.dms;
|
||||
static const unitSystem = UnitSystem.metric;
|
||||
|
@ -138,6 +137,7 @@ class SettingsDefaults {
|
|||
static const widgetOutline = false;
|
||||
static const widgetShape = WidgetShape.rrect;
|
||||
static const widgetOpenPage = WidgetOpenPage.viewer;
|
||||
static const widgetDisplayedItem = WidgetDisplayedItem.random;
|
||||
|
||||
// platform settings
|
||||
static const isRotationLocked = false;
|
||||
|
|
|
@ -20,6 +20,8 @@ enum KeepScreenOn { never, viewerOnly, always }
|
|||
|
||||
enum SlideshowVideoPlayback { skip, playMuted, playWithSound }
|
||||
|
||||
enum SubtitlePosition { top, bottom }
|
||||
|
||||
enum UnitSystem { metric, imperial }
|
||||
|
||||
enum VideoControls { play, playSeek, playOutside, none }
|
||||
|
@ -30,6 +32,8 @@ enum VideoAutoPlayMode { disabled, playMuted, playWithSound }
|
|||
|
||||
enum ViewerTransition { slide, parallax, fade, zoomIn, none }
|
||||
|
||||
enum WidgetDisplayedItem { random, mostRecent }
|
||||
|
||||
enum WidgetOpenPage { home, collection, viewer }
|
||||
|
||||
enum WidgetShape { rrect, circle, heart }
|
||||
|
|
|
@ -3,6 +3,19 @@ import 'package:aves_map/aves_map.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
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) {
|
||||
switch (this) {
|
||||
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 {
|
||||
switch (this) {
|
||||
case EntryMapStyle.osmHot:
|
||||
|
|
24
lib/model/settings/enums/subtitle_position.dart
Normal file
24
lib/model/settings/enums/subtitle_position.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
14
lib/model/settings/enums/widget_displayed_item.dart
Normal file
14
lib/model/settings/enums/widget_displayed_item.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
|
@ -122,6 +123,7 @@ class Settings extends ChangeNotifier {
|
|||
// subtitles
|
||||
static const subtitleFontSizeKey = 'subtitle_font_size';
|
||||
static const subtitleTextAlignmentKey = 'subtitle_text_alignment';
|
||||
static const subtitleTextPositionKey = 'subtitle_text_position';
|
||||
static const subtitleShowOutlineKey = 'subtitle_show_outline';
|
||||
static const subtitleTextColorKey = 'subtitle_text_color';
|
||||
static const subtitleBackgroundColorKey = 'subtitle_background_color';
|
||||
|
@ -171,6 +173,7 @@ class Settings extends ChangeNotifier {
|
|||
static const widgetShapePrefixKey = '${_widgetKeyPrefix}shape_';
|
||||
static const widgetCollectionFiltersPrefixKey = '${_widgetKeyPrefix}collection_filters_';
|
||||
static const widgetOpenPagePrefixKey = '${_widgetKeyPrefix}open_page_';
|
||||
static const widgetDisplayedItemPrefixKey = '${_widgetKeyPrefix}displayed_item_';
|
||||
static const widgetUriPrefixKey = '${_widgetKeyPrefix}uri_';
|
||||
|
||||
// platform settings
|
||||
|
@ -202,12 +205,13 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
bool isInternalKey(String key) => _internalKeys.contains(key) || key.startsWith(_widgetKeyPrefix);
|
||||
|
||||
Future<void> setContextualDefaults() async {
|
||||
Future<void> setContextualDefaults(AppFlavor flavor) async {
|
||||
// performance
|
||||
final performanceClass = await deviceService.getPerformanceClass();
|
||||
enableBlurEffect = performanceClass >= 29;
|
||||
|
||||
// availability
|
||||
if (flavor.hasMapStyleDefault) {
|
||||
final defaultMapStyle = mobileServices.defaultMapStyle;
|
||||
if (mobileServices.mapStyles.contains(defaultMapStyle)) {
|
||||
mapStyle = defaultMapStyle;
|
||||
|
@ -216,6 +220,7 @@ class Settings extends ChangeNotifier {
|
|||
mapStyle = styles[Random().nextInt(styles.length)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// app
|
||||
|
||||
|
@ -570,6 +575,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
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;
|
||||
|
||||
set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue);
|
||||
|
@ -598,13 +607,15 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// map
|
||||
|
||||
EntryMapStyle get mapStyle {
|
||||
final preferred = getEnumOrDefault(mapStyleKey, SettingsDefaults.infoMapStyle, EntryMapStyle.values);
|
||||
EntryMapStyle? get mapStyle {
|
||||
final preferred = getEnumOrDefault(mapStyleKey, null, EntryMapStyle.values);
|
||||
if (preferred == null) return null;
|
||||
|
||||
final available = availability.mapStyles;
|
||||
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 {
|
||||
final json = getString(mapDefaultCenterKey);
|
||||
|
@ -722,6 +733,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
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');
|
||||
|
||||
void setWidgetUri(int widgetId, String? newValue) => setAndNotify('$widgetUriPrefixKey$widgetId', newValue);
|
||||
|
@ -958,6 +973,7 @@ class Settings extends ChangeNotifier {
|
|||
case videoLoopModeKey:
|
||||
case videoControlsKey:
|
||||
case subtitleTextAlignmentKey:
|
||||
case subtitleTextPositionKey:
|
||||
case mapStyleKey:
|
||||
case mapDefaultCenterKey:
|
||||
case coordinateFormatKey:
|
||||
|
|
|
@ -52,7 +52,9 @@ class MimeTypes {
|
|||
static const v3gpp = 'video/3gpp';
|
||||
static const asf = 'video/x-ms-asf';
|
||||
static const avi = 'video/avi';
|
||||
static const aviMSVideo = 'video/msvideo';
|
||||
static const aviVnd = 'video/vnd.avi';
|
||||
static const aviXMSVideo = 'video/x-msvideo';
|
||||
static const flv = 'video/flv';
|
||||
static const flvX = 'video/x-flv';
|
||||
static const mkv = 'video/mkv';
|
||||
|
@ -87,7 +89,7 @@ class MimeTypes {
|
|||
|
||||
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 = {
|
||||
anyImage,
|
||||
|
@ -108,7 +110,9 @@ class MimeTypes {
|
|||
static bool refersToSameType(String a, b) {
|
||||
switch (a) {
|
||||
case avi:
|
||||
case aviMSVideo:
|
||||
case aviVnd:
|
||||
case aviXMSVideo:
|
||||
return [avi, aviVnd].contains(b);
|
||||
case bmp:
|
||||
case bmpX:
|
||||
|
|
|
@ -4,6 +4,16 @@ import 'package:flutter/services.dart';
|
|||
class AccessibilityService {
|
||||
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 {
|
||||
try {
|
||||
final result = await _platform.invokeMethod('areAnimationsRemoved');
|
||||
|
|
|
@ -223,6 +223,8 @@ class Constants {
|
|||
..._googleMobileServices,
|
||||
];
|
||||
|
||||
static const List<Dependency> _flutterPluginsLibreOnly = [];
|
||||
|
||||
static const List<Dependency> _flutterPluginsPlayOnly = [
|
||||
..._googleMobileServices,
|
||||
Dependency(
|
||||
|
@ -236,6 +238,7 @@ class Constants {
|
|||
..._flutterPluginsCommon,
|
||||
if (flavor == AppFlavor.huawei) ..._flutterPluginsHuaweiOnly,
|
||||
if (flavor == AppFlavor.izzy) ..._flutterPluginsIzzyOnly,
|
||||
if (flavor == AppFlavor.libre) ..._flutterPluginsLibreOnly,
|
||||
if (flavor == AppFlavor.play) ..._flutterPluginsPlayOnly,
|
||||
];
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/app_flavor.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/source/collection_lens.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;
|
||||
|
||||
final entries = CollectionLens(source: source, filters: filters).sortedEntries;
|
||||
switch (settings.getWidgetDisplayedItem(widgetId)) {
|
||||
case WidgetDisplayedItem.random:
|
||||
entries.shuffle();
|
||||
break;
|
||||
case WidgetDisplayedItem.mostRecent:
|
||||
entries.sort(AvesEntry.compareByDate);
|
||||
break;
|
||||
}
|
||||
final entry = entries.firstOrNull;
|
||||
if (entry != null) {
|
||||
settings.setWidgetUri(widgetId, entry.uri);
|
||||
|
|
|
@ -145,9 +145,13 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final flavor = context.read<AppFlavor>().toString().split('.')[1];
|
||||
return [
|
||||
'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})',
|
||||
'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})',
|
||||
'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})',
|
||||
'Package: ${packageInfo.packageName}',
|
||||
'Aves version: ${packageInfo.version}-$flavor',
|
||||
'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}',
|
||||
'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
|
||||
'Geocoder: ${device.hasGeocoder ? 'ready' : 'not available'}',
|
||||
|
|
|
@ -17,9 +17,9 @@ class AboutTranslators extends StatelessWidget {
|
|||
Contributor('MeFinity', 'me.dot.finity@gmail.com'),
|
||||
Contributor('Maki', null),
|
||||
Contributor('HiSubway', 'shenyusoftware@gmail.com'),
|
||||
Contributor('glemco', null),
|
||||
Contributor('glemco', 'glemco@posteo.net'),
|
||||
Contributor('Aerowolf', null),
|
||||
Contributor('小默', null),
|
||||
Contributor('小默', 'duzhe163908@gmail.com'),
|
||||
Contributor('metezd', 'itoldyouthat@protonmail.com'),
|
||||
Contributor('Martijn Fabrie', null),
|
||||
Contributor('Koen Koppens', 'koenkoppens@proton.me'),
|
||||
|
@ -33,6 +33,9 @@ class AboutTranslators extends StatelessWidget {
|
|||
// Contributor('Allan Nordhøy', 'epost@anotheragency.no'),
|
||||
// Contributor('Piotr K', '1337.kelt@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
|
||||
|
|
|
@ -49,7 +49,7 @@ class AvesApp extends StatefulWidget {
|
|||
final AppFlavor flavor;
|
||||
|
||||
// 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 GlobalKey<NavigatorState> navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
|
@ -108,6 +108,7 @@ class AvesApp extends StatefulWidget {
|
|||
class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
||||
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
|
||||
late final Future<void> _appSetup;
|
||||
late final Future<bool> _shouldUseBoldFontLoader;
|
||||
late final Future<CorePalette?> _dynamicColorPaletteLoader;
|
||||
final CollectionSource _mediaStoreSource = MediaStoreSource();
|
||||
final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay);
|
||||
|
@ -129,6 +130,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
|||
_appSetup = _setup();
|
||||
// remember screen size to use it later, when `context` and `window` are no longer reliable
|
||||
_screenSize = _getScreenSize();
|
||||
_shouldUseBoldFontLoader = AccessibilityService.shouldUseBoldFont();
|
||||
_dynamicColorPaletteLoader = DynamicColorPlugin.getCorePalette();
|
||||
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
|
||||
|
@ -205,6 +207,12 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
|||
lightAccent = Color(tonalPalette?.get(60) ?? defaultAccent.value);
|
||||
darkAccent = Color(tonalPalette?.get(70) ?? defaultAccent.value);
|
||||
}
|
||||
return FutureBuilder<bool>(
|
||||
future: _shouldUseBoldFontLoader,
|
||||
builder: (context, snapshot) {
|
||||
// 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
|
||||
final shouldUseBoldFont = snapshot.data ?? false;
|
||||
return MaterialApp(
|
||||
navigatorKey: AvesApp.navigatorKey,
|
||||
home: home,
|
||||
|
@ -213,13 +221,16 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
|||
if (initialized) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context));
|
||||
}
|
||||
return AvesColorsProvider(
|
||||
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,
|
||||
|
@ -237,6 +248,8 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
|
|||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -28,14 +28,14 @@ class FixedExtentSectionLayout extends SectionLayout {
|
|||
@override
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= bodyMinOffset;
|
||||
if (scrollOffset < 0) return firstIndex;
|
||||
if (scrollOffset < 0 || mainAxisStride == 0) return firstIndex;
|
||||
return bodyFirstIndex + scrollOffset ~/ mainAxisStride;
|
||||
}
|
||||
|
||||
@override
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= bodyMinOffset;
|
||||
if (scrollOffset < 0) return firstIndex;
|
||||
if (scrollOffset < 0 || mainAxisStride == 0) return firstIndex;
|
||||
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
|||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class Attribution extends StatelessWidget {
|
||||
final EntryMapStyle style;
|
||||
final EntryMapStyle? style;
|
||||
|
||||
const Attribution({
|
||||
super.key,
|
||||
|
|
|
@ -128,7 +128,7 @@ class MapButtonPanel extends StatelessWidget {
|
|||
icon: const Icon(AIcons.layers),
|
||||
onPressed: () => showSelectionDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<EntryMapStyle>(
|
||||
builder: (context) => AvesSelectionDialog<EntryMapStyle?>(
|
||||
initialValue: settings.mapStyle,
|
||||
options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.mapStyleDialogTitle,
|
||||
|
|
|
@ -7,14 +7,18 @@ import 'package:aves/model/settings/enums/map_style.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/constants.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/buttons/panel.dart';
|
||||
import 'package:aves/widgets/common/map/decorator.dart';
|
||||
import 'package:aves/widgets/common/map/leaflet/map.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:collection/collection.dart';
|
||||
import 'package:fluster/fluster.dart';
|
||||
|
@ -148,10 +152,10 @@ class _GeoMapState extends State<GeoMap> {
|
|||
onTap(clusterAverageLocation, markerEntry, getClusterEntries);
|
||||
}
|
||||
|
||||
return Selector<Settings, EntryMapStyle>(
|
||||
return Selector<Settings, EntryMapStyle?>(
|
||||
selector: (context, s) => s.mapStyle,
|
||||
builder: (context, mapStyle, child) {
|
||||
final isHeavy = mapStyle.isHeavy;
|
||||
final isHeavy = ExtraEntryMapStyle.isHeavy(mapStyle);
|
||||
Widget _buildMarkerWidget(MarkerKey<AvesEntry> key) => ImageMarker(
|
||||
key: key,
|
||||
count: key.count,
|
||||
|
@ -164,6 +168,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
bool _isMarkerImageReady(MarkerKey<AvesEntry> key) => key.entry.isThumbnailReady(extent: MapThemeData.markerImageExtent);
|
||||
|
||||
Widget child = const SizedBox();
|
||||
if (mapStyle != null) {
|
||||
switch (mapStyle) {
|
||||
case EntryMapStyle.googleNormal:
|
||||
case EntryMapStyle.googleHybrid:
|
||||
|
@ -219,6 +224,30 @@ class _GeoMapState extends State<GeoMap> {
|
|||
);
|
||||
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,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(AIcons.layers),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.mapStyleTooltip),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
child = _decorateMap(context, overlay);
|
||||
}
|
||||
|
||||
final mapHeight = context.select<MapThemeData, double?>((v) => v.mapHeight);
|
||||
child = Column(
|
||||
|
@ -308,7 +337,11 @@ class _GeoMapState extends State<GeoMap> {
|
|||
}
|
||||
if (bounds == null) {
|
||||
// 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(
|
||||
points: {center},
|
||||
collocationZoom: settings.infoMapZoom,
|
||||
|
|
|
@ -77,16 +77,16 @@ class TileExtentController {
|
|||
|
||||
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) {
|
||||
if (extent > 0) {
|
||||
final columnCount = _columnCountForExtent(extent);
|
||||
final countMin = _effectiveColumnCountMin();
|
||||
final countMax = _effectiveColumnCountMax();
|
||||
return columnCount.clamp(countMin, max(countMin, countMax)).round();
|
||||
return columnCount.round().clamp(countMin, countMax);
|
||||
}
|
||||
return columnCountDefault;
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (settings.mapStyle.isHeavy) {
|
||||
if (ExtraEntryMapStyle.isHeavy(settings.mapStyle)) {
|
||||
_isPageAnimatingNotifier = ValueNotifier(true);
|
||||
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
|
||||
if (!mounted) return;
|
||||
|
|
|
@ -108,7 +108,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (settings.mapStyle.isHeavy) {
|
||||
if (ExtraEntryMapStyle.isHeavy(settings.mapStyle)) {
|
||||
_isPageAnimatingNotifier.value = true;
|
||||
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
|
||||
if (!mounted) return;
|
||||
|
@ -176,11 +176,11 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
}
|
||||
return true;
|
||||
},
|
||||
child: Selector<Settings, EntryMapStyle>(
|
||||
child: Selector<Settings, EntryMapStyle?>(
|
||||
selector: (context, s) => s.mapStyle,
|
||||
builder: (context, mapStyle, child) {
|
||||
late Widget scroller;
|
||||
if (mapStyle.isHeavy) {
|
||||
if (ExtraEntryMapStyle.isHeavy(mapStyle)) {
|
||||
// the map widget is too heavy for a smooth resizing animation
|
||||
// so we just toggle visibility when overlay animation is done
|
||||
scroller = ValueListenableBuilder<double>(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/filters/filters.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_shape.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -36,6 +37,7 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
|
|||
late WidgetShape _shape;
|
||||
late Color? _outline;
|
||||
late WidgetOpenPage _openPage;
|
||||
late WidgetDisplayedItem _displayedItem;
|
||||
late Set<CollectionFilter> _collectionFilters;
|
||||
|
||||
int get widgetId => widget.widgetId;
|
||||
|
@ -59,6 +61,7 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
|
|||
_shape = settings.getWidgetShape(widgetId);
|
||||
_outline = settings.getWidgetOutline(widgetId);
|
||||
_openPage = settings.getWidgetOpenPage(widgetId);
|
||||
_displayedItem = settings.getWidgetDisplayedItem(widgetId);
|
||||
_collectionFilters = settings.getWidgetCollectionFilters(widgetId);
|
||||
}
|
||||
|
||||
|
@ -91,6 +94,13 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
|
|||
onSelection: (v) => setState(() => _openPage = v),
|
||||
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(
|
||||
filters: _collectionFilters,
|
||||
onSelection: (v) => setState(() => _collectionFilters = v),
|
||||
|
@ -148,11 +158,15 @@ class _HomeWidgetSettingsPageState extends State<HomeWidgetSettingsPage> {
|
|||
}
|
||||
|
||||
void _saveSettings() {
|
||||
final invalidateUri = _displayedItem != settings.getWidgetDisplayedItem(widgetId) || !const SetEquality().equals(_collectionFilters, settings.getWidgetCollectionFilters(widgetId));
|
||||
|
||||
settings.setWidgetShape(widgetId, _shape);
|
||||
settings.setWidgetOutline(widgetId, _outline);
|
||||
settings.setWidgetOpenPage(widgetId, _openPage);
|
||||
if (!const SetEquality().equals(_collectionFilters, settings.getWidgetCollectionFilters(widgetId))) {
|
||||
settings.setWidgetDisplayedItem(widgetId, _displayedItem);
|
||||
settings.setWidgetCollectionFilters(widgetId, _collectionFilters);
|
||||
|
||||
if (invalidateUri) {
|
||||
settings.setWidgetUri(widgetId, null);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
||||
|
@ -20,6 +21,7 @@ class SubtitleSample extends StatelessWidget {
|
|||
return Consumer<Settings>(
|
||||
builder: (context, settings, child) {
|
||||
final textAlign = settings.subtitleTextAlignment;
|
||||
final textPosition = settings.subtitleTextPosition;
|
||||
final outlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
|
||||
final shadows = [
|
||||
Shadow(
|
||||
|
@ -40,7 +42,7 @@ class SubtitleSample extends StatelessWidget {
|
|||
),
|
||||
height: 128,
|
||||
child: AnimatedAlign(
|
||||
alignment: _getAlignment(textAlign),
|
||||
alignment: _getAlignment(textAlign, textPosition),
|
||||
curve: Curves.easeInOutCubic,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: Padding(
|
||||
|
@ -75,7 +77,19 @@ class SubtitleSample extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Alignment _getAlignment(TextAlign textAlign) {
|
||||
Alignment _getAlignment(TextAlign textAlign, SubtitlePosition textPosition) {
|
||||
switch (textPosition) {
|
||||
case SubtitlePosition.top:
|
||||
switch (textAlign) {
|
||||
case TextAlign.left:
|
||||
return Alignment.topLeft;
|
||||
case TextAlign.right:
|
||||
return Alignment.topRight;
|
||||
case TextAlign.center:
|
||||
default:
|
||||
return Alignment.topCenter;
|
||||
}
|
||||
case SubtitlePosition.bottom:
|
||||
switch (textAlign) {
|
||||
case TextAlign.left:
|
||||
return Alignment.bottomLeft;
|
||||
|
@ -87,3 +101,4 @@ class SubtitleSample extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/widgets/common/basic/color_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,
|
||||
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(
|
||||
title: context.l10n.settingsSubtitleThemeTextSize,
|
||||
value: settings.subtitleFontSize,
|
||||
|
|
|
@ -30,6 +30,7 @@ class ViewerActionEditorPage extends StatelessWidget {
|
|||
EntryAction.videoSetSpeed,
|
||||
EntryAction.videoSelectStreams,
|
||||
],
|
||||
EntryActions.commonMetadataActions,
|
||||
];
|
||||
|
||||
@override
|
||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/model/entry_metadata_edition.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/settings/enums/enums.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/services/common/image_op_events.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/export_entry_dialog.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/single_entry_editor.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/source_viewer_page.dart';
|
||||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin {
|
||||
@override
|
||||
final AvesEntry entry;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
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) {
|
||||
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) {
|
||||
case EntryAction.info:
|
||||
ShowInfoNotification().dispatch(context);
|
||||
break;
|
||||
case EntryAction.addShortcut:
|
||||
_addShortcut(context);
|
||||
_addShortcut(context, targetEntry);
|
||||
break;
|
||||
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);
|
||||
});
|
||||
break;
|
||||
case EntryAction.delete:
|
||||
_delete(context);
|
||||
_delete(context, targetEntry);
|
||||
break;
|
||||
case EntryAction.restore:
|
||||
_move(context, moveType: MoveType.fromBin);
|
||||
_move(context, targetEntry, moveType: MoveType.fromBin);
|
||||
break;
|
||||
case EntryAction.convert:
|
||||
_convert(context);
|
||||
_convert(context, targetEntry);
|
||||
break;
|
||||
case EntryAction.print:
|
||||
EntryPrinter(entry).print(context);
|
||||
EntryPrinter(targetEntry).print(context);
|
||||
break;
|
||||
case EntryAction.rename:
|
||||
_rename(context);
|
||||
_rename(context, targetEntry);
|
||||
break;
|
||||
case EntryAction.copy:
|
||||
_move(context, moveType: MoveType.copy);
|
||||
_move(context, targetEntry, moveType: MoveType.copy);
|
||||
break;
|
||||
case EntryAction.move:
|
||||
_move(context, moveType: MoveType.move);
|
||||
_move(context, targetEntry, moveType: MoveType.move);
|
||||
break;
|
||||
case EntryAction.share:
|
||||
androidAppService.shareEntries({entry}).then((success) {
|
||||
androidAppService.shareEntries({targetEntry}).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
case EntryAction.toggleFavourite:
|
||||
entry.toggleFavourite();
|
||||
targetEntry.toggleFavourite();
|
||||
break;
|
||||
// raster
|
||||
case EntryAction.rotateCCW:
|
||||
_rotate(context, clockwise: false);
|
||||
_rotate(context, targetEntry, clockwise: false);
|
||||
break;
|
||||
case EntryAction.rotateCW:
|
||||
_rotate(context, clockwise: true);
|
||||
_rotate(context, targetEntry, clockwise: true);
|
||||
break;
|
||||
case EntryAction.flip:
|
||||
_flip(context);
|
||||
_flip(context, targetEntry);
|
||||
break;
|
||||
// vector
|
||||
case EntryAction.viewSource:
|
||||
_goToSourceViewer(context);
|
||||
_goToSourceViewer(context, targetEntry);
|
||||
break;
|
||||
// video
|
||||
case EntryAction.videoCaptureFrame:
|
||||
|
@ -112,28 +225,28 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
case EntryAction.videoTogglePlay:
|
||||
case EntryAction.videoReplay10:
|
||||
case EntryAction.videoSkip10:
|
||||
final controller = context.read<VideoConductor>().getController(entry);
|
||||
final controller = context.read<VideoConductor>().getController(targetEntry);
|
||||
if (controller != null) {
|
||||
VideoActionNotification(controller: controller, action: action).dispatch(context);
|
||||
}
|
||||
break;
|
||||
case EntryAction.edit:
|
||||
androidAppService.edit(entry.uri, entry.mimeType).then((success) {
|
||||
androidAppService.edit(targetEntry.uri, targetEntry.mimeType).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
case EntryAction.open:
|
||||
androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) {
|
||||
androidAppService.open(targetEntry.uri, targetEntry.mimeTypeAnySubtype).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
case EntryAction.openMap:
|
||||
androidAppService.openMap(entry.latLng!).then((success) {
|
||||
androidAppService.openMap(targetEntry.latLng!).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
case EntryAction.setAs:
|
||||
androidAppService.setAs(entry.uri, entry.mimeType).then((success) {
|
||||
androidAppService.setAs(targetEntry.uri, targetEntry.mimeType).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
break;
|
||||
|
@ -141,18 +254,31 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
case EntryAction.rotateScreen:
|
||||
_rotateScreen(context);
|
||||
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
|
||||
case EntryAction.debug:
|
||||
_goToDebug(context);
|
||||
_goToDebug(context, targetEntry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addShortcut(BuildContext context) async {
|
||||
Future<void> _addShortcut(BuildContext context, AvesEntry targetEntry) async {
|
||||
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
||||
context: context,
|
||||
builder: (context) => AddShortcutDialog(
|
||||
defaultName: entry.bestTitle ?? '',
|
||||
defaultName: targetEntry.bestTitle ?? '',
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
@ -160,18 +286,18 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
final name = result.item2;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
await androidAppService.pinToHomeScreen(name, entry, uri: entry.uri);
|
||||
await androidAppService.pinToHomeScreen(name, targetEntry, uri: targetEntry.uri);
|
||||
if (!device.showPinShortcutFeedback) {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _flip(BuildContext context) async {
|
||||
await edit(context, entry.flip);
|
||||
Future<void> _flip(BuildContext context, AvesEntry targetEntry) async {
|
||||
await edit(context, targetEntry, targetEntry.flip);
|
||||
}
|
||||
|
||||
Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
|
||||
await edit(context, () => entry.rotate(clockwise: clockwise));
|
||||
Future<void> _rotate(BuildContext context, AvesEntry targetEntry, {required bool clockwise}) async {
|
||||
await edit(context, targetEntry, () => targetEntry.rotate(clockwise: clockwise));
|
||||
}
|
||||
|
||||
Future<void> _rotateScreen(BuildContext context) async {
|
||||
|
@ -185,9 +311,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
if (settings.enableBin && !entry.trashed) {
|
||||
await _move(context, moveType: MoveType.toBin);
|
||||
Future<void> _delete(BuildContext context, AvesEntry targetEntry) async {
|
||||
if (settings.enableBin && !targetEntry.trashed) {
|
||||
await _move(context, targetEntry, moveType: MoveType.toBin);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -199,23 +325,23 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
confirmationButtonLabel: l10n.deleteButtonLabel,
|
||||
)) 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);
|
||||
} else {
|
||||
final source = context.read<CollectionSource>();
|
||||
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>(
|
||||
context: context,
|
||||
builder: (context) => ExportEntryDialog(entry: entry),
|
||||
builder: (context) => ExportEntryDialog(entry: targetEntry),
|
||||
);
|
||||
if (options == null) return;
|
||||
|
||||
|
@ -223,13 +349,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
if (destinationAlbum == null) 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>{};
|
||||
if (entry.isMultiPage) {
|
||||
final multiPageInfo = await entry.getMultiPageInfo();
|
||||
if (targetEntry.isMultiPage) {
|
||||
final multiPageInfo = await targetEntry.getMultiPageInfo();
|
||||
if (multiPageInfo != null) {
|
||||
if (entry.isMotionPhoto) {
|
||||
if (targetEntry.isMotionPhoto) {
|
||||
await multiPageInfo.extractMotionPhotoVideo();
|
||||
}
|
||||
if (multiPageInfo.pageCount > 1) {
|
||||
|
@ -237,7 +363,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
}
|
||||
}
|
||||
} else {
|
||||
selection.add(entry);
|
||||
selection.add(targetEntry);
|
||||
}
|
||||
|
||||
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,
|
||||
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>(
|
||||
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
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
await rename(
|
||||
context,
|
||||
entriesToNewName: {entry: '$newName${entry.extension}'},
|
||||
entriesToNewName: {targetEntry: '$newName${targetEntry.extension}'},
|
||||
persist: _isMainMode(context),
|
||||
onSuccess: entry.metadataChangeNotifier.notify,
|
||||
onSuccess: targetEntry.metadataChangeNotifier.notify,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
|
||||
void _goToSourceViewer(BuildContext context) {
|
||||
void _goToSourceViewer(BuildContext context, AvesEntry targetEntry) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
@ -337,9 +463,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
builder: (context) => SourceViewerPage(
|
||||
loader: () async {
|
||||
final data = await mediaFetchService.getSvg(
|
||||
entry.uri,
|
||||
entry.mimeType,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
targetEntry.uri,
|
||||
targetEntry.mimeType,
|
||||
sizeBytes: targetEntry.sizeBytes,
|
||||
);
|
||||
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(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: ViewerDebugPage.routeName),
|
||||
builder: (context) => ViewerDebugPage(entry: entry),
|
||||
builder: (context) => ViewerDebugPage(entry: targetEntry),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
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/entry.dart';
|
||||
import 'package:aves/model/entry_info.dart';
|
||||
import 'package:aves/model/entry_metadata_edition.dart';
|
||||
import 'package:aves/model/geotiff.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/widgets/common/action_mixins/entry_editor.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/map/map_page.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:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin {
|
||||
@override
|
||||
final AvesEntry entry;
|
||||
final CollectionLens? collection;
|
||||
final StreamController<ActionEvent<EntryAction>> _eventStreamController = StreamController.broadcast();
|
||||
|
||||
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController.broadcast();
|
||||
Stream<ActionEvent<EntryAction>> get eventStream => _eventStreamController.stream;
|
||||
|
||||
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;
|
||||
|
||||
EntryInfoActionDelegate(this.entry, this.collection);
|
||||
|
||||
bool isVisible(EntryInfoAction action) {
|
||||
bool isVisible(AvesEntry targetEntry, EntryAction action) {
|
||||
switch (action) {
|
||||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
case EntryInfoAction.editLocation:
|
||||
case EntryInfoAction.editTitleDescription:
|
||||
case EntryInfoAction.editRating:
|
||||
case EntryInfoAction.editTags:
|
||||
case EntryInfoAction.removeMetadata:
|
||||
case EntryAction.editDate:
|
||||
case EntryAction.editLocation:
|
||||
case EntryAction.editTitleDescription:
|
||||
case EntryAction.editRating:
|
||||
case EntryAction.editTags:
|
||||
case EntryAction.removeMetadata:
|
||||
case EntryAction.exportMetadata:
|
||||
return true;
|
||||
// GeoTIFF
|
||||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
return entry.isGeotiff;
|
||||
case EntryAction.showGeoTiffOnMap:
|
||||
return targetEntry.isGeotiff;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return entry.isMotionPhoto;
|
||||
// debug
|
||||
case EntryInfoAction.debug:
|
||||
return kDebugMode;
|
||||
case EntryAction.convertMotionPhotoToStillImage:
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
return targetEntry.isMotionPhoto;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool canApply(EntryInfoAction action) {
|
||||
bool canApply(AvesEntry targetEntry, EntryAction action) {
|
||||
switch (action) {
|
||||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
return entry.canEditDate;
|
||||
case EntryInfoAction.editLocation:
|
||||
return entry.canEditLocation;
|
||||
case EntryInfoAction.editTitleDescription:
|
||||
return entry.canEditTitleDescription;
|
||||
case EntryInfoAction.editRating:
|
||||
return entry.canEditRating;
|
||||
case EntryInfoAction.editTags:
|
||||
return entry.canEditTags;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return entry.canRemoveMetadata;
|
||||
case EntryAction.editDate:
|
||||
return targetEntry.canEditDate;
|
||||
case EntryAction.editLocation:
|
||||
return targetEntry.canEditLocation;
|
||||
case EntryAction.editTitleDescription:
|
||||
return targetEntry.canEditTitleDescription;
|
||||
case EntryAction.editRating:
|
||||
return targetEntry.canEditRating;
|
||||
case EntryAction.editTags:
|
||||
return targetEntry.canEditTags;
|
||||
case EntryAction.removeMetadata:
|
||||
return targetEntry.canRemoveMetadata;
|
||||
case EntryAction.exportMetadata:
|
||||
return true;
|
||||
// GeoTIFF
|
||||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
case EntryAction.showGeoTiffOnMap:
|
||||
return true;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
return entry.canEditXmp;
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return true;
|
||||
// debug
|
||||
case EntryInfoAction.debug:
|
||||
case EntryAction.convertMotionPhotoToStillImage:
|
||||
return targetEntry.canEditXmp;
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
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));
|
||||
switch (action) {
|
||||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
await _editDate(context);
|
||||
case EntryAction.editDate:
|
||||
await _editDate(context, targetEntry, collection);
|
||||
break;
|
||||
case EntryInfoAction.editLocation:
|
||||
await _editLocation(context);
|
||||
case EntryAction.editLocation:
|
||||
await _editLocation(context, targetEntry, collection);
|
||||
break;
|
||||
case EntryInfoAction.editTitleDescription:
|
||||
await _editTitleDescription(context);
|
||||
case EntryAction.editTitleDescription:
|
||||
await _editTitleDescription(context, targetEntry);
|
||||
break;
|
||||
case EntryInfoAction.editRating:
|
||||
await _editRating(context);
|
||||
case EntryAction.editRating:
|
||||
await _editRating(context, targetEntry);
|
||||
break;
|
||||
case EntryInfoAction.editTags:
|
||||
await _editTags(context);
|
||||
case EntryAction.editTags:
|
||||
await _editTags(context, targetEntry);
|
||||
break;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
await _removeMetadata(context);
|
||||
case EntryAction.removeMetadata:
|
||||
await _removeMetadata(context, targetEntry);
|
||||
break;
|
||||
case EntryAction.exportMetadata:
|
||||
await _exportMetadata(context, targetEntry);
|
||||
break;
|
||||
// GeoTIFF
|
||||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
await _showGeoTiffOnMap(context);
|
||||
case EntryAction.showGeoTiffOnMap:
|
||||
await _showGeoTiffOnMap(context, targetEntry, collection);
|
||||
break;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
await _convertMotionPhotoToStillImage(context);
|
||||
case EntryAction.convertMotionPhotoToStillImage:
|
||||
await _convertMotionPhotoToStillImage(context, targetEntry);
|
||||
break;
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||
break;
|
||||
// debug
|
||||
case EntryInfoAction.debug:
|
||||
_goToDebug(context);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
_eventStreamController.add(ActionEndedEvent(action));
|
||||
}
|
||||
|
||||
Future<void> _editDate(BuildContext context) async {
|
||||
final modifier = await selectDateModifier(context, {entry}, collection);
|
||||
Future<void> _editDate(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
|
||||
final modifier = await selectDateModifier(context, {targetEntry}, collection);
|
||||
if (modifier == null) return;
|
||||
|
||||
await edit(context, () => entry.editDate(modifier));
|
||||
await edit(context, targetEntry, () => targetEntry.editDate(modifier));
|
||||
}
|
||||
|
||||
Future<void> _editLocation(BuildContext context) async {
|
||||
final location = await selectLocation(context, {entry}, collection);
|
||||
Future<void> _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
|
||||
final location = await selectLocation(context, {targetEntry}, collection);
|
||||
if (location == null) return;
|
||||
|
||||
await edit(context, () => entry.editLocation(location));
|
||||
await edit(context, targetEntry, () => targetEntry.editLocation(location));
|
||||
}
|
||||
|
||||
Future<void> _editTitleDescription(BuildContext context) async {
|
||||
final modifier = await selectTitleDescriptionModifier(context, {entry});
|
||||
Future<void> _editTitleDescription(BuildContext context, AvesEntry targetEntry) async {
|
||||
final modifier = await selectTitleDescriptionModifier(context, {targetEntry});
|
||||
if (modifier == null) return;
|
||||
|
||||
await edit(context, () => entry.editTitleDescription(modifier));
|
||||
await edit(context, targetEntry, () => targetEntry.editTitleDescription(modifier));
|
||||
}
|
||||
|
||||
Future<void> _editRating(BuildContext context) async {
|
||||
final rating = await selectRating(context, {entry});
|
||||
Future<void> _editRating(BuildContext context, AvesEntry targetEntry) async {
|
||||
final rating = await selectRating(context, {targetEntry});
|
||||
if (rating == null) return;
|
||||
|
||||
await edit(context, () => entry.editRating(rating));
|
||||
await edit(context, targetEntry, () => targetEntry.editRating(rating));
|
||||
}
|
||||
|
||||
Future<void> _editTags(BuildContext context) async {
|
||||
final newTagsByEntry = await selectTags(context, {entry});
|
||||
Future<void> _editTags(BuildContext context, AvesEntry targetEntry) async {
|
||||
final newTagsByEntry = await selectTags(context, {targetEntry});
|
||||
if (newTagsByEntry == null) return;
|
||||
|
||||
final newTags = newTagsByEntry[entry] ?? entry.tags;
|
||||
final currentTags = entry.tags;
|
||||
final newTags = newTagsByEntry[targetEntry] ?? targetEntry.tags;
|
||||
final currentTags = targetEntry.tags;
|
||||
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 {
|
||||
final types = await selectMetadataToRemove(context, {entry});
|
||||
Future<void> _removeMetadata(BuildContext context, AvesEntry targetEntry) async {
|
||||
final types = await selectMetadataToRemove(context, {targetEntry});
|
||||
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>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
|
@ -190,16 +220,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
await edit(context, entry.removeTrailerVideo);
|
||||
await edit(context, targetEntry, targetEntry.removeTrailerVideo);
|
||||
}
|
||||
|
||||
Future<void> _showGeoTiffOnMap(BuildContext context) async {
|
||||
final info = await metadataFetchService.getGeoTiffInfo(entry);
|
||||
Future<void> _showGeoTiffOnMap(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
|
||||
final info = await metadataFetchService.getGeoTiffInfo(targetEntry);
|
||||
if (info == null) return;
|
||||
|
||||
final mappedGeoTiff = MappedGeoTiff(
|
||||
info: info,
|
||||
entry: entry,
|
||||
entry: targetEntry,
|
||||
);
|
||||
if (!mappedGeoTiff.canOverlay) return;
|
||||
|
||||
|
@ -214,7 +244,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
return MapPage(
|
||||
collection: baseCollection.copyWith(
|
||||
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,
|
||||
);
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,12 +12,10 @@ import 'package:flutter/material.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
|
||||
AvesEntry get entry;
|
||||
|
||||
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
|
||||
Future<void> edit(BuildContext context, Future<Set<EntryDataType>> Function() apply) async {
|
||||
if (!await checkStoragePermission(context, {entry})) return;
|
||||
Future<void> edit(BuildContext context, AvesEntry targetEntry, Future<Set<EntryDataType>> Function() apply) async {
|
||||
if (!await checkStoragePermission(context, {targetEntry})) return;
|
||||
|
||||
// check before applying, because it relies on provider
|
||||
// but the widget tree may be disposed if the user navigated away
|
||||
|
@ -32,10 +30,10 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
|
|||
try {
|
||||
if (success) {
|
||||
if (isMainMode && source != null) {
|
||||
Set<String> obsoleteTags = entry.tags;
|
||||
String? obsoleteCountryCode = entry.addressDetails?.countryCode;
|
||||
Set<String> obsoleteTags = targetEntry.tags;
|
||||
String? obsoleteCountryCode = targetEntry.addressDetails?.countryCode;
|
||||
|
||||
await source.refreshEntry(entry, dataTypes);
|
||||
await source.refreshEntry(targetEntry, dataTypes);
|
||||
|
||||
// invalidate filters derived from values before edition
|
||||
// this invalidation must happen after the source is refreshed,
|
||||
|
@ -47,7 +45,7 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
|
|||
source.invalidateTagFilterSummary(tags: obsoleteTags);
|
||||
}
|
||||
} 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);
|
||||
} else {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
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/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
|
@ -255,7 +256,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
|||
} else if (notification is VideoActionNotification) {
|
||||
final controller = notification.controller;
|
||||
final action = notification.action;
|
||||
_videoActionDelegate.onActionSelected(context, controller, action);
|
||||
_onVideoAction(context, controller, action);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
@ -396,7 +397,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
|||
scale: _overlayVideoControlScale,
|
||||
onActionSelected: (action) {
|
||||
if (videoController != null) {
|
||||
_videoActionDelegate.onActionSelected(context, videoController, action);
|
||||
_onVideoAction(context, videoController, action);
|
||||
}
|
||||
},
|
||||
onActionMenuOpened: () {
|
||||
|
@ -440,7 +441,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
|
|||
ViewerBottomOverlay(
|
||||
entries: entries,
|
||||
index: _currentEntryIndex,
|
||||
hasCollection: hasCollection,
|
||||
collection: collection,
|
||||
animationController: _overlayAnimationController,
|
||||
viewInsets: _frozenViewInsets,
|
||||
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() {
|
||||
if (!_isEntryTracked && _verticalPager.hasClients && _verticalPager.page?.floor() == transitionPage) {
|
||||
_trackEntry();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/app_mode.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/favourites.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
|
@ -29,7 +29,7 @@ class BasicSection extends StatelessWidget {
|
|||
final AvesEntry entry;
|
||||
final CollectionLens? collection;
|
||||
final EntryInfoActionDelegate actionDelegate;
|
||||
final ValueNotifier<EntryInfoAction?> isEditingMetadataNotifier;
|
||||
final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
|
||||
final FilterCallback onFilter;
|
||||
|
||||
const BasicSection({
|
||||
|
@ -100,9 +100,9 @@ class BasicSection extends StatelessWidget {
|
|||
|
||||
Widget _buildEditButtons(BuildContext context) {
|
||||
final children = [
|
||||
EntryInfoAction.editRating,
|
||||
EntryInfoAction.editTags,
|
||||
].where(actionDelegate.canApply).map((v) => _buildEditMetadataButton(context, v)).toList();
|
||||
EntryAction.editRating,
|
||||
EntryAction.editTags,
|
||||
].where((v) => actionDelegate.canApply(entry, v)).map((v) => _buildEditMetadataButton(context, v)).toList();
|
||||
|
||||
return children.isEmpty
|
||||
? const SizedBox()
|
||||
|
@ -121,8 +121,8 @@ class BasicSection extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildEditMetadataButton(BuildContext context, EntryInfoAction action) {
|
||||
return ValueListenableBuilder<EntryInfoAction?>(
|
||||
Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
|
||||
return ValueListenableBuilder<EntryAction?>(
|
||||
valueListenable: isEditingMetadataNotifier,
|
||||
builder: (context, editingAction, child) {
|
||||
final isEditing = editingAction != null;
|
||||
|
@ -138,7 +138,7 @@ class BasicSection extends StatelessWidget {
|
|||
),
|
||||
child: IconButton(
|
||||
icon: action.getIcon(),
|
||||
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action),
|
||||
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, entry, collection, action),
|
||||
tooltip: action.getText(context),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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/source/collection_lens.dart';
|
||||
import 'package:aves/theme/durations.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/sliver_app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.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/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/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class InfoAppBar extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final CollectionLens? collection;
|
||||
final EntryInfoActionDelegate actionDelegate;
|
||||
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||
final VoidCallback onBackPressed;
|
||||
|
@ -22,6 +24,7 @@ class InfoAppBar extends StatelessWidget {
|
|||
const InfoAppBar({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.collection,
|
||||
required this.actionDelegate,
|
||||
required this.metadataNotifier,
|
||||
required this.onBackPressed,
|
||||
|
@ -29,8 +32,8 @@ class InfoAppBar extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commonActions = EntryInfoActions.common.where(actionDelegate.isVisible);
|
||||
final formatSpecificActions = EntryInfoActions.formatSpecific.where(actionDelegate.isVisible);
|
||||
final commonActions = EntryActions.commonMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
|
||||
final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
|
||||
|
||||
return SliverAppBar(
|
||||
leading: IconButton(
|
||||
|
@ -54,22 +57,22 @@ class InfoAppBar extends StatelessWidget {
|
|||
),
|
||||
if (entry.canEdit)
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<EntryInfoAction>(
|
||||
child: PopupMenuButton<EntryAction>(
|
||||
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) ...[
|
||||
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) ...[
|
||||
const PopupMenuDivider(),
|
||||
_toMenuItem(context, EntryInfoAction.debug, enabled: true),
|
||||
_toMenuItem(context, EntryAction.debug, enabled: true),
|
||||
]
|
||||
],
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
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(
|
||||
value: action,
|
||||
enabled: enabled,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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/entry.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/info_app_bar.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/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/notifications.dart';
|
||||
|
@ -149,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
|||
final List<StreamSubscription> _subscriptions = [];
|
||||
late EntryInfoActionDelegate _actionDelegate;
|
||||
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);
|
||||
|
||||
|
@ -180,7 +181,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
|||
}
|
||||
|
||||
void _registerWidget(_InfoPageContent widget) {
|
||||
_actionDelegate = EntryInfoActionDelegate(widget.entry, collection);
|
||||
_actionDelegate = EntryInfoActionDelegate();
|
||||
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
|
||||
}
|
||||
|
||||
|
@ -241,6 +242,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
|||
slivers: [
|
||||
InfoAppBar(
|
||||
entry: entry,
|
||||
collection: collection,
|
||||
actionDelegate: _actionDelegate,
|
||||
metadataNotifier: _metadataNotifier,
|
||||
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((_) {
|
||||
if (event is ActionStartedEvent) {
|
||||
_isEditingMetadataNotifier.value = event.action;
|
||||
|
|
|
@ -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/providers/media_query_data_provider.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_section.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
40
lib/widgets/viewer/info/metadata/metadata_dir.dart
Normal file
40
lib/widgets/viewer/info/metadata/metadata_dir.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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/info/common.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/xmp_tile.dart';
|
||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||
|
|
|
@ -1,18 +1,12 @@
|
|||
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/model/entry_info.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.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:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.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;
|
||||
|
||||
// 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -132,173 +122,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
|
|||
}
|
||||
|
||||
Future<void> _getMetadata() async {
|
||||
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataFetchService.getAllMetadata(entry));
|
||||
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));
|
||||
final titledDirectories = await entry.getMetadataDirectories(context);
|
||||
metadataNotifier.value = Map.fromEntries(titledDirectories);
|
||||
_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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry.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/viewer/multipage/controller.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/multipage.dart';
|
||||
|
@ -17,7 +18,7 @@ import 'package:tuple/tuple.dart';
|
|||
class ViewerBottomOverlay extends StatefulWidget {
|
||||
final List<AvesEntry> entries;
|
||||
final int index;
|
||||
final bool hasCollection;
|
||||
final CollectionLens? collection;
|
||||
final AnimationController animationController;
|
||||
final EdgeInsets? viewInsets, viewPadding;
|
||||
final MultiPageController? multiPageController;
|
||||
|
@ -26,7 +27,7 @@ class ViewerBottomOverlay extends StatefulWidget {
|
|||
super.key,
|
||||
required this.entries,
|
||||
required this.index,
|
||||
required this.hasCollection,
|
||||
required this.collection,
|
||||
required this.animationController,
|
||||
this.viewInsets,
|
||||
this.viewPadding,
|
||||
|
@ -65,7 +66,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
|||
index: widget.index,
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
hasCollection: widget.hasCollection,
|
||||
collection: widget.collection,
|
||||
viewInsets: widget.viewInsets,
|
||||
viewPadding: widget.viewPadding,
|
||||
multiPageController: multiPageController,
|
||||
|
@ -96,7 +97,7 @@ class _BottomOverlayContent extends StatefulWidget {
|
|||
final List<AvesEntry> entries;
|
||||
final int index;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final bool hasCollection;
|
||||
final CollectionLens? collection;
|
||||
final EdgeInsets? viewInsets, viewPadding;
|
||||
final MultiPageController? multiPageController;
|
||||
final AnimationController animationController;
|
||||
|
@ -106,7 +107,7 @@ class _BottomOverlayContent extends StatefulWidget {
|
|||
required this.index,
|
||||
required this.mainEntry,
|
||||
required this.pageEntry,
|
||||
required this.hasCollection,
|
||||
required this.collection,
|
||||
required this.viewInsets,
|
||||
required this.viewPadding,
|
||||
required this.multiPageController,
|
||||
|
@ -167,8 +168,8 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
|
|||
: ViewerButtons(
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry,
|
||||
collection: widget.collection,
|
||||
scale: _buttonScale,
|
||||
canToggleFavourite: widget.hasCollection,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
@ -60,16 +60,38 @@ class OverlayButton extends StatelessWidget {
|
|||
static double getSize(BuildContext context) => 48.0 + AvesBorder.curvedBorderWidth * 2;
|
||||
}
|
||||
|
||||
class OverlayTextButton extends StatelessWidget {
|
||||
class ScalingOverlayTextButton extends StatelessWidget {
|
||||
final Animation<double> scale;
|
||||
final String buttonLabel;
|
||||
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({
|
||||
super.key,
|
||||
required this.scale,
|
||||
required this.buttonLabel,
|
||||
this.onPressed,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
static const _borderRadius = 123.0;
|
||||
|
@ -79,9 +101,7 @@ class OverlayTextButton extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final blurred = settings.enableBlurEffect;
|
||||
final theme = Theme.of(context);
|
||||
return SizeTransition(
|
||||
sizeFactor: scale,
|
||||
child: BlurredRRect.all(
|
||||
return BlurredRRect.all(
|
||||
enabled: blurred,
|
||||
borderRadius: _borderRadius,
|
||||
child: OutlinedButton(
|
||||
|
@ -96,8 +116,7 @@ class OverlayTextButton extends StatelessWidget {
|
|||
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
|
||||
)),
|
||||
),
|
||||
child: Text(buttonLabel),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,9 +22,8 @@ class PanoramaOverlay extends StatelessWidget {
|
|||
return Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
OverlayTextButton(
|
||||
ScalingOverlayTextButton(
|
||||
scale: scale,
|
||||
buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel,
|
||||
onPressed: () async {
|
||||
final info = await metadataFetchService.getPanoramaInfo(entry);
|
||||
if (info != null) {
|
||||
|
@ -40,7 +39,8 @@ class PanoramaOverlay extends StatelessWidget {
|
|||
));
|
||||
}
|
||||
},
|
||||
)
|
||||
child: Text(context.l10n.viewerOpenPanoramaButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/entry.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/icons.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/extensions/build_context.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/overlay/common.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';
|
||||
|
||||
class ViewerButtons extends StatelessWidget {
|
||||
final AvesEntry mainEntry;
|
||||
final AvesEntry pageEntry;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final CollectionLens? collection;
|
||||
final Animation<double> scale;
|
||||
final bool canToggleFavourite;
|
||||
|
||||
static const double outerPadding = 8;
|
||||
static const double innerPadding = 8;
|
||||
|
@ -39,75 +37,15 @@ class ViewerButtons extends StatelessWidget {
|
|||
super.key,
|
||||
required this.mainEntry,
|
||||
required this.pageEntry,
|
||||
required this.collection,
|
||||
required this.scale,
|
||||
required this.canToggleFavourite,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
|
||||
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(
|
||||
top: false,
|
||||
bottom: false,
|
||||
|
@ -118,10 +56,10 @@ class ViewerButtons extends StatelessWidget {
|
|||
return Selector<Settings, bool>(
|
||||
selector: (context, s) => s.isRotationLocked,
|
||||
builder: (context, s, child) {
|
||||
final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).toList();
|
||||
final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
|
||||
final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
|
||||
final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(_isVisible).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(actionDelegate.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(actionDelegate.isVisible).toList();
|
||||
return ViewerButtonRowContent(
|
||||
quickActions: quickActions,
|
||||
topLevelActions: topLevelActions,
|
||||
|
@ -130,6 +68,7 @@ class ViewerButtons extends StatelessWidget {
|
|||
scale: scale,
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry,
|
||||
collection: collection,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -143,6 +82,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
|||
final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions;
|
||||
final Animation<double> scale;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final CollectionLens? collection;
|
||||
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
|
||||
|
||||
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
|
||||
|
@ -158,6 +98,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
|||
required this.scale,
|
||||
required this.mainEntry,
|
||||
required this.pageEntry,
|
||||
required this.collection,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -358,17 +299,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
|||
}
|
||||
|
||||
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) {
|
||||
bool canApply(EntryAction action) {
|
||||
switch (action) {
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
return pageEntry.canRotate;
|
||||
case EntryAction.flip:
|
||||
return pageEntry.canFlip;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
|
||||
|
||||
Widget buildDivider() => const SizedBox(
|
||||
height: 16,
|
||||
|
@ -386,7 +317,7 @@ class ViewerButtonRowContent extends StatelessWidget {
|
|||
clipBehavior: Clip.antiAlias,
|
||||
child: PopupMenuItem(
|
||||
value: action,
|
||||
enabled: canApply(action),
|
||||
enabled: actionDelegate.canApply(action),
|
||||
child: Tooltip(
|
||||
message: action.getText(context),
|
||||
child: Center(child: action.getIcon()),
|
||||
|
@ -423,17 +354,6 @@ class ViewerButtonRowContent extends StatelessWidget {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntryActionDelegate(targetEntry).onActionSelected(context, action);
|
||||
EntryActionDelegate(mainEntry, pageEntry, collection).onActionSelected(context, action);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,10 +42,10 @@ class WallpaperButtons extends StatelessWidget with FeedbackMixin {
|
|||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: padding / 2),
|
||||
child: OverlayTextButton(
|
||||
child: ScalingOverlayTextButton(
|
||||
scale: scale,
|
||||
buttonLabel: context.l10n.viewerSetWallpaperButtonLabel,
|
||||
onPressed: () => _setWallpaper(context),
|
||||
child: Text(context.l10n.viewerSetWallpaperButtonLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/widgets/viewer/video/controller.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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -144,7 +144,7 @@ abstract class AvesVideoController {
|
|||
|
||||
Future<Uint8List> captureFrame();
|
||||
|
||||
Future<void> toggleMute();
|
||||
Future<void> mute(bool muted);
|
||||
|
||||
Widget buildPlayerWidget(BuildContext context);
|
||||
}
|
||||
|
|
|
@ -363,8 +363,8 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
|||
bool get isMuted => _volume == 0;
|
||||
|
||||
@override
|
||||
Future<void> toggleMute() async {
|
||||
_volume = isMuted ? 1 : 0;
|
||||
Future<void> mute(bool muted) async {
|
||||
_volume = muted ? 0 : 1;
|
||||
_volumeStreamController.add(_volume);
|
||||
await _applyVolume();
|
||||
}
|
||||
|
|
|
@ -35,39 +35,39 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
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
|
||||
stopOverlayHidingTimer();
|
||||
const ToggleOverlayNotification(visible: true).dispatch(context);
|
||||
|
||||
switch (action) {
|
||||
case EntryAction.videoCaptureFrame:
|
||||
_captureFrame(context, controller);
|
||||
await _captureFrame(context, controller);
|
||||
break;
|
||||
case EntryAction.videoToggleMute:
|
||||
controller.toggleMute();
|
||||
await controller.mute(!controller.isMuted);
|
||||
break;
|
||||
case EntryAction.videoSelectStreams:
|
||||
_showStreamSelectionDialog(context, controller);
|
||||
await _showStreamSelectionDialog(context, controller);
|
||||
break;
|
||||
case EntryAction.videoSetSpeed:
|
||||
_showSpeedDialog(context, controller);
|
||||
await _showSpeedDialog(context, controller);
|
||||
break;
|
||||
case EntryAction.videoSettings:
|
||||
_showSettings(context, controller);
|
||||
await _showSettings(context, controller);
|
||||
break;
|
||||
case EntryAction.videoTogglePlay:
|
||||
_togglePlayPause(context, controller);
|
||||
await _togglePlayPause(context, controller);
|
||||
break;
|
||||
case EntryAction.videoReplay10:
|
||||
controller.seekTo(controller.currentPosition - 10000);
|
||||
await controller.seekTo(controller.currentPosition - 10000);
|
||||
break;
|
||||
case EntryAction.videoSkip10:
|
||||
controller.seekTo(controller.currentPosition + 10000);
|
||||
await controller.seekTo(controller.currentPosition + 10000);
|
||||
break;
|
||||
case EntryAction.open:
|
||||
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);
|
||||
});
|
||||
break;
|
||||
|
|
|
@ -17,6 +17,8 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
|
|||
final Map<AvesEntry, VoidCallback> _metadataChangeListeners = {};
|
||||
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
|
||||
|
||||
bool? videoMutedOverride;
|
||||
|
||||
bool get isViewingImage;
|
||||
|
||||
ValueNotifier<AvesEntry?> get entryNotifier;
|
||||
|
@ -89,6 +91,10 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
|
|||
}
|
||||
|
||||
bool get shouldAutoPlayVideoMuted {
|
||||
if (videoMutedOverride != null) {
|
||||
return videoMutedOverride!;
|
||||
}
|
||||
|
||||
switch (videoPlaybackOverride) {
|
||||
case SlideshowVideoPlayback.skip:
|
||||
case SlideshowVideoPlayback.playWithSound:
|
||||
|
@ -189,7 +195,7 @@ mixin EntryViewControllerMixin<T extends StatefulWidget> on State<T> {
|
|||
await Future.delayed(const Duration(milliseconds: 300) * timeDilation);
|
||||
|
||||
if (!videoController.isMuted && shouldAutoPlayVideoMuted) {
|
||||
await videoController.toggleMute();
|
||||
await videoController.mute(true);
|
||||
}
|
||||
|
||||
if (resumeTimeMillis != null) {
|
||||
|
|
|
@ -113,8 +113,11 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
|
|||
viewerController.startAutopilotAnimation(
|
||||
vsync: this,
|
||||
onUpdate: ({required scaleLevel}) {
|
||||
final scale = _magnifierController.scaleBoundaries.scaleForLevel(scaleLevel);
|
||||
final boundaries = _magnifierController.scaleBoundaries;
|
||||
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,
|
||||
// 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
|
||||
final boundaries = _magnifierController.scaleBoundaries;
|
||||
if (boundaries != null) {
|
||||
_magnifierController.setScaleBoundaries(
|
||||
_magnifierController.scaleBoundaries.copyWith(
|
||||
boundaries.copyWith(
|
||||
childSize: videoDisplaySize,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: ValueListenableBuilder<ImageInfo?>(
|
||||
valueListenable: _videoCoverInfoNotifier,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/settings/enums/subtitle_position.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
||||
import 'package:aves/widgets/common/basic/text_background_painter.dart';
|
||||
|
@ -33,6 +34,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
child: Consumer<Settings>(
|
||||
builder: (context, settings, child) {
|
||||
final baseTextAlign = settings.subtitleTextAlignment;
|
||||
final baseTextAlignY = settings.subtitleTextPosition.toTextAlignVertical();
|
||||
final baseOutlineWidth = settings.subtitleShowOutline ? 1 : 0;
|
||||
final baseOutlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
|
||||
final baseShadows = [
|
||||
|
@ -119,7 +121,8 @@ class VideoSubtitles extends StatelessWidget {
|
|||
);
|
||||
}).toList();
|
||||
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;
|
||||
if (drawingPaths != null) {
|
||||
|
@ -138,7 +141,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
outlineWidth: outlineWidth * (position != null ? viewScale : baseOutlineWidth),
|
||||
outlineColor: extraStyle.borderColor ?? baseOutlineColor,
|
||||
outlineBlurSigma: extraStyle.edgeBlur ?? 0,
|
||||
textAlign: textAlign,
|
||||
textAlign: textHAlign,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -154,7 +157,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
final textHeight = para.getMaxIntrinsicHeight(double.infinity);
|
||||
|
||||
late double anchorOffsetX, anchorOffsetY;
|
||||
switch (textAlign) {
|
||||
switch (textHAlign) {
|
||||
case TextAlign.left:
|
||||
anchorOffsetX = 0;
|
||||
break;
|
||||
|
@ -166,7 +169,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
anchorOffsetX = -textWidth / 2;
|
||||
break;
|
||||
}
|
||||
switch (extraStyle.vAlign ?? TextAlignVertical.bottom) {
|
||||
switch (textVAlign) {
|
||||
case TextAlignVertical.top:
|
||||
anchorOffsetY = 0;
|
||||
break;
|
||||
|
@ -214,7 +217,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
|
||||
if (position == null) {
|
||||
late double alignX;
|
||||
switch (textAlign) {
|
||||
switch (textHAlign) {
|
||||
case TextAlign.left:
|
||||
alignX = -1;
|
||||
break;
|
||||
|
@ -227,7 +230,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
break;
|
||||
}
|
||||
late double alignY;
|
||||
switch (extraStyle.vAlign) {
|
||||
switch (textVAlign) {
|
||||
case TextAlignVertical.top:
|
||||
alignY = -bottom;
|
||||
break;
|
||||
|
@ -248,7 +251,7 @@ class VideoSubtitles extends StatelessWidget {
|
|||
style: DefaultTextStyle.of(context).style.merge(spans.first.style!.copyWith(
|
||||
backgroundColor: settings.subtitleBackgroundColor,
|
||||
)),
|
||||
textAlign: textAlign,
|
||||
textAlign: textHAlign,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -211,7 +211,7 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
|
|||
ViewerBottomOverlay(
|
||||
entries: [widget.entry],
|
||||
index: 0,
|
||||
hasCollection: false,
|
||||
collection: null,
|
||||
animationController: _overlayAnimationController,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
|
|
|
@ -31,7 +31,6 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
settings.setContextualDefaults();
|
||||
_termsLoader = rootBundle.loadString(termsPath);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initWelcomeSettings());
|
||||
}
|
||||
|
@ -40,6 +39,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
// so they are not subject to future default changes
|
||||
void _initWelcomeSettings() {
|
||||
// this should be done outside of `initState`/`build`
|
||||
settings.setContextualDefaults(context.read<AppFlavor>());
|
||||
settings.isInstalledAppAccessAllowed = SettingsDefaults.isInstalledAppAccessAllowed;
|
||||
settings.isErrorReportingAllowed = SettingsDefaults.isErrorReportingAllowed;
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ migrate_working_dir/
|
|||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
#/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.packages
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue