Merge branch 'develop'

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

View file

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

View file

@ -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
View file

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

View file

@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="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

View file

@ -15,6 +15,9 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/huawei-appgallery-badge-english-black.png"
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

View file

@ -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")
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
}
if (tasks.contains(flavor.name) && flavor.ext.useHMS) {
println("Building flavor [${flavor.name}] with HMS plugin")
apply plugin: 'com.huawei.agconnect'
}
if (useCrashlytics) {
println("Building flavor with Crashlytics plugin")
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
}
if (useHms) {
println("Building flavor with HMS plugin")
apply plugin: 'com.huawei.agconnect'
}

View file

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

View file

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

View file

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

View file

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

View file

@ -172,9 +172,10 @@ This change eventually prevents building the app with Flutter v3.3.3.
</intent-filter>
</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" />

View file

@ -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

View file

@ -21,6 +21,8 @@ import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.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")
}

View file

@ -14,6 +14,8 @@ import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
import deckers.thibault.aves.channel.calls.window.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")

View file

@ -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
}
}

View file

@ -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,7 +326,16 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
context.startActivity(intent)
return true
} catch (e: SecurityException) {
Log.w(LOG_TAG, "failed to start activity for intent=$intent", e)
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
// in some environments, providing the write flag yields a `SecurityException`:
// "UID XXXX does not have permission to content://XXXX"
// so we retry without it
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
return safeStartActivity(intent)
} else {
Log.w(LOG_TAG, "failed to start activity for intent=$intent", e)
}
}
return false
}

View file

@ -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 {
// parsing `MediaDataBox` can take a long time
// parsing `SampleTableBox` may yield OOM
skippingBoxes(MediaDataBox.TYPE, SampleTableBox.TYPE)
val skippedTypes = listOf(
// parsing `MediaDataBox` can take a long time
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)

View file

@ -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)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
fallbackProcessXmp(dir.xmpMeta)
}
// `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)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
// `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,11 +951,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
// `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)
}
}
@ -1026,11 +1036,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
if (isLargeMp4(mimeType, sizeBytes)) {
XMP.checkMp4(context, mimeType, uri) { dirs ->
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
processXmp(dir.xmpMeta)
}
// `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)
}
}

View file

@ -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()
}

View file

@ -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 ->

View file

@ -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 {
// parsing `MediaDataBox` can take a long time
// parsing `SampleTableBox` may yield OOM
skippingBoxes(MediaDataBox.TYPE, SampleTableBox.TYPE)
val skippedTypes = listOf(
// parsing `MediaDataBox` can take a long time
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 bytes = box.toBytes()
val payload = bytes.copyOfRange(8, bytes.size)
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())
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")
}
}
}
}

View file

@ -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"))
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,35 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
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()
maven { url 'https://developer.huawei.com/repo/' }
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"
// 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'
// HMS (used by some flavors only)
classpath 'com.huawei.agconnect:agcp:1.7.2.300'
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'
}
}
}
@ -22,8 +37,13 @@ allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://developer.huawei.com/repo/' }
if (useHms) {
// HMS (used by some flavors only)
maven { url 'https://developer.huawei.com/repo/' }
}
}
// gradle.projectsEvaluated {
// tasks.withType(JavaCompile) {
// options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
enum AppFlavor { play, huawei, izzy }
enum AppFlavor { play, huawei, izzy, libre }
extension ExtraAppFlavor on AppFlavor {
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
View file

@ -0,0 +1 @@
{}

View file

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

View file

@ -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": {}
}

View file

@ -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",

View file

@ -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": {}
}

View file

@ -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": {}
}

View file

@ -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 unapplicazione di galleria preinstallata per spostare gli elementi in unaltra directory.",
"restrictedAccessDialogMessage": "Questa applicazione non è autorizzata a modificare i file nella {directory} di «{volume}».\n\nUsa un gestore file o unapp galleria preinstallata per spostare gli elementi in unaltra cartella.",
"@restrictedAccessDialogMessage": {},
"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": {}
}

View file

@ -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
View file

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

View file

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

View file

@ -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
View file

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

View file

@ -38,6 +38,19 @@ enum EntryAction {
setAs,
// 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;

View file

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

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

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

View file

@ -7,7 +7,6 @@ import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/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;

View file

@ -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 }

View file

@ -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:

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart: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,18 +205,20 @@ 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
final defaultMapStyle = mobileServices.defaultMapStyle;
if (mobileServices.mapStyles.contains(defaultMapStyle)) {
mapStyle = defaultMapStyle;
} else {
final styles = EntryMapStyle.values.whereNot((v) => v.needMobileService).toList();
mapStyle = styles[Random().nextInt(styles.length)];
if (flavor.hasMapStyleDefault) {
final defaultMapStyle = mobileServices.defaultMapStyle;
if (mobileServices.mapStyles.contains(defaultMapStyle)) {
mapStyle = defaultMapStyle;
} else {
final styles = EntryMapStyle.values.whereNot((v) => v.needMobileService).toList();
mapStyle = styles[Random().nextInt(styles.length)];
}
}
}
@ -570,6 +575,10 @@ class Settings extends ChangeNotifier {
set subtitleTextAlignment(TextAlign newValue) => setAndNotify(subtitleTextAlignmentKey, newValue.toString());
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:

View file

@ -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:

View file

@ -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');

View file

@ -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,
];

View file

@ -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;
entries.shuffle();
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);

View file

@ -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'}',

View file

@ -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

View file

@ -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,32 +207,43 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
lightAccent = Color(tonalPalette?.get(60) ?? defaultAccent.value);
darkAccent = Color(tonalPalette?.get(70) ?? defaultAccent.value);
}
return MaterialApp(
navigatorKey: AvesApp.navigatorKey,
home: home,
navigatorObservers: _navigatorObservers,
builder: (context, child) {
if (initialized) {
WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context));
}
return AvesColorsProvider(
child: Theme(
data: Theme.of(context).copyWith(
pageTransitionsTheme: pageTransitionsTheme,
),
child: child!,
),
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,
navigatorObservers: _navigatorObservers,
builder: (context, child) {
if (initialized) {
WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context));
}
return MediaQuery(
data: MediaQuery.of(context).copyWith(boldText: shouldUseBoldFont),
child: AvesColorsProvider(
child: Theme(
data: Theme.of(context).copyWith(
pageTransitionsTheme: pageTransitionsTheme,
),
child: child!,
),
),
);
},
onGenerateTitle: (context) => context.l10n.appName,
theme: Themes.lightTheme(lightAccent, initialized),
darkTheme: themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized),
themeMode: themeBrightness.appThemeMode,
locale: settingsLocale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AvesApp.supportedLocales,
// TODO TLAD remove custom scroll behavior when this is fixed: https://github.com/flutter/flutter/issues/82906
scrollBehavior: StretchMaterialScrollBehavior(),
);
},
onGenerateTitle: (context) => context.l10n.appName,
theme: Themes.lightTheme(lightAccent, initialized),
darkTheme: themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized),
themeMode: themeBrightness.appThemeMode,
locale: settingsLocale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AvesApp.supportedLocales,
// TODO TLAD remove custom scroll behavior when this is fixed: https://github.com/flutter/flutter/issues/82906
scrollBehavior: StretchMaterialScrollBehavior(),
);
},
);

View file

@ -28,14 +28,14 @@ class FixedExtentSectionLayout extends SectionLayout {
@override
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;
}
}

View file

@ -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,

View file

@ -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,

View file

@ -7,14 +7,18 @@ import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/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,60 +168,85 @@ class _GeoMapState extends State<GeoMap> {
bool _isMarkerImageReady(MarkerKey<AvesEntry> key) => key.entry.isThumbnailReady(extent: MapThemeData.markerImageExtent);
Widget child = const SizedBox();
switch (mapStyle) {
case EntryMapStyle.googleNormal:
case EntryMapStyle.googleHybrid:
case EntryMapStyle.googleTerrain:
case EntryMapStyle.hmsNormal:
case EntryMapStyle.hmsTerrain:
child = mobileServices.buildMap<AvesEntry>(
controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier,
style: mapStyle,
decoratorBuilder: _decorateMap,
buttonPanelBuilder: _buildButtonPanel,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
markerImageReadyChecker: _isMarkerImageReady,
dotLocationNotifier: widget.dotLocationNotifier,
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
);
break;
case EntryMapStyle.osmHot:
case EntryMapStyle.stamenToner:
case EntryMapStyle.stamenWatercolor:
child = EntryLeafletMap<AvesEntry>(
controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier,
minZoom: 2,
maxZoom: 16,
style: mapStyle,
decoratorBuilder: _decorateMap,
buttonPanelBuilder: _buildButtonPanel,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
dotLocationNotifier: widget.dotLocationNotifier,
markerSize: Size(
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2,
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2 + MapThemeData.markerArrowSize.height,
if (mapStyle != null) {
switch (mapStyle) {
case EntryMapStyle.googleNormal:
case EntryMapStyle.googleHybrid:
case EntryMapStyle.googleTerrain:
case EntryMapStyle.hmsNormal:
case EntryMapStyle.hmsTerrain:
child = mobileServices.buildMap<AvesEntry>(
controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier,
style: mapStyle,
decoratorBuilder: _decorateMap,
buttonPanelBuilder: _buildButtonPanel,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
markerImageReadyChecker: _isMarkerImageReady,
dotLocationNotifier: widget.dotLocationNotifier,
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
);
break;
case EntryMapStyle.osmHot:
case EntryMapStyle.stamenToner:
case EntryMapStyle.stamenWatercolor:
child = EntryLeafletMap<AvesEntry>(
controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier,
minZoom: 2,
maxZoom: 16,
style: mapStyle,
decoratorBuilder: _decorateMap,
buttonPanelBuilder: _buildButtonPanel,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
dotLocationNotifier: widget.dotLocationNotifier,
markerSize: Size(
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2,
MapThemeData.markerImageExtent + MapThemeData.markerOuterBorderWidth * 2 + MapThemeData.markerArrowSize.height,
),
dotMarkerSize: const Size(
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2,
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2,
),
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
);
break;
}
} else {
final overlay = Center(
child: OverlayTextButton(
onPressed: () => showSelectionDialog<EntryMapStyle>(
context: context,
builder: (context) => AvesSelectionDialog<EntryMapStyle?>(
initialValue: settings.mapStyle,
options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.mapStyleDialogTitle,
),
onSelection: (v) => settings.mapStyle = v,
),
dotMarkerSize: const Size(
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2,
DotMarker.diameter + MapThemeData.markerOuterBorderWidth * 2,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(AIcons.layers),
const SizedBox(width: 8),
Text(context.l10n.mapStyleTooltip),
],
),
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
);
break;
),
);
child = _decorateMap(context, overlay);
}
final mapHeight = context.select<MapThemeData, double?>((v) => v.mapHeight);
@ -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,

View file

@ -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;
}

View file

@ -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;

View file

@ -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>(

View file

@ -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.setWidgetCollectionFilters(widgetId, _collectionFilters);
settings.setWidgetDisplayedItem(widgetId, _displayedItem);
settings.setWidgetCollectionFilters(widgetId, _collectionFilters);
if (invalidateUri) {
settings.setWidgetUri(widgetId, null);
}
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/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,15 +77,28 @@ class SubtitleSample extends StatelessWidget {
);
}
Alignment _getAlignment(TextAlign textAlign) {
switch (textAlign) {
case TextAlign.left:
return Alignment.bottomLeft;
case TextAlign.right:
return Alignment.bottomRight;
case TextAlign.center:
default:
return Alignment.bottomCenter;
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;
case TextAlign.right:
return Alignment.bottomRight;
case TextAlign.center:
default:
return Alignment.bottomCenter;
}
}
}
}

View file

@ -1,3 +1,5 @@
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/subtitle_position.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/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,

View file

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

View file

@ -10,6 +10,7 @@ import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/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),
),
);
}

View file

@ -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),
),
);
}
}

View file

@ -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 {

View file

@ -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();

View file

@ -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),
),
),

View file

@ -1,20 +1,22 @@
import 'package:aves/model/actions/entry_info_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/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,

View file

@ -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;

View file

@ -4,8 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/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';

View file

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

View file

@ -10,7 +10,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:aves/widgets/viewer/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';

View file

@ -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,
);
}
}

View file

@ -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,
),
);

View file

@ -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,25 +101,22 @@ class OverlayTextButton extends StatelessWidget {
Widget build(BuildContext context) {
final blurred = settings.enableBlurEffect;
final theme = Theme.of(context);
return SizeTransition(
sizeFactor: scale,
child: BlurredRRect.all(
enabled: blurred,
borderRadius: _borderRadius,
child: OutlinedButton(
onPressed: onPressed,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred)),
foregroundColor: MaterialStateProperty.all<Color>(theme.colorScheme.onSurface),
overlayColor: theme.brightness == Brightness.dark ? MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)) : null,
minimumSize: _minSize,
side: MaterialStateProperty.all<BorderSide>(AvesBorder.curvedSide(context)),
shape: MaterialStateProperty.all<OutlinedBorder>(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
)),
),
child: Text(buttonLabel),
return BlurredRRect.all(
enabled: blurred,
borderRadius: _borderRadius,
child: OutlinedButton(
onPressed: onPressed,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Themes.overlayBackgroundColor(brightness: theme.brightness, blurred: blurred)),
foregroundColor: MaterialStateProperty.all<Color>(theme.colorScheme.onSurface),
overlayColor: theme.brightness == Brightness.dark ? MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)) : null,
minimumSize: _minSize,
side: MaterialStateProperty.all<BorderSide>(AvesBorder.curvedSide(context)),
shape: MaterialStateProperty.all<OutlinedBorder>(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(_borderRadius)),
)),
),
child: child,
),
);
}

View file

@ -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),
),
],
);
}

View file

@ -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);
}
}

View file

@ -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),
),
),
],

View file

@ -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));
}

View file

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

View file

@ -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();
}

View file

@ -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;

View file

@ -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) {

View file

@ -113,8 +113,11 @@ class _EntryPageViewState extends State<EntryPageView> with SingleTickerProvider
viewerController.startAutopilotAnimation(
vsync: this,
onUpdate: ({required scaleLevel}) {
final scale = _magnifierController.scaleBoundaries.scaleForLevel(scaleLevel);
_magnifierController.update(scale: scale, source: ChangeSource.animation);
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
_magnifierController.setScaleBoundaries(
_magnifierController.scaleBoundaries.copyWith(
childSize: videoDisplaySize,
),
);
final boundaries = _magnifierController.scaleBoundaries;
if (boundaries != null) {
_magnifierController.setScaleBoundaries(
boundaries.copyWith(
childSize: videoDisplaySize,
),
);
}
},
child: ValueListenableBuilder<ImageInfo?>(
valueListenable: _videoCoverInfoNotifier,

View file

@ -1,3 +1,4 @@
import 'package:aves/model/settings/enums/subtitle_position.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/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,
),
),

View file

@ -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,

View file

@ -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;
}

View file

@ -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