Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-06-25 11:56:55 +09:00
commit 1788fd73e7
100 changed files with 3928 additions and 888 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '2.2.1'
flutter-version: '2.2.2'
- name: Clone the repository.
uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '2.2.1'
flutter-version: '2.2.2'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
@ -50,8 +50,8 @@ jobs:
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
flutter build apk --bundle-sksl-path shaders_2.2.1.sksl.json
flutter build appbundle --bundle-sksl-path shaders_2.2.1.sksl.json
flutter build apk --bundle-sksl-path shaders_2.2.2.sksl.json
flutter build appbundle --bundle-sksl-path shaders_2.2.2.sksl.json
rm $AVES_STORE_FILE
env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks

View file

@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.4.4] - 2021-06-25
### Added
- Video: speed control, track selection, frame capture
- Video: embedded subtitle support
- Settings: custom video quick actions
- Settings: subtitle theme
### Changed
- upgraded Flutter to stable v2.2.2
### Fixed
- fixed opening SVGs from other apps
- stop video playback when leaving the app in some cases
- fixed crash when ACCESS_MEDIA_LOCATION permission is revoked
## [v1.4.3] - 2021-06-12
### Added
- Collection: snack bar action to show moved/copied/exported entries

View file

@ -252,7 +252,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
private fun getShareableUri(uri: Uri): Uri? {
return when (uri.scheme?.toLowerCase(Locale.ROOT)) {
return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
uri.path?.let { path ->
val authority = "${context.applicationContext.packageName}.fileprovider"

View file

@ -112,10 +112,20 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(contentUri)
contentUri = StorageUtils.getOriginalUri(context, contentUri)
}
}
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
val metadataMap = getContentResolverMetadataForUri(contentUri) ?: getContentResolverMetadataForUri(uri)
if (metadataMap != null) {
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getContentResolverMetadataForUri(contentUri: Uri): FieldMap? {
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val metadataMap = HashMap<String, Any?>()
@ -137,10 +147,9 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
}
cursor.close()
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
return metadataMap
}
return null
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {

View file

@ -14,6 +14,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -32,6 +33,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::captureFrame) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
@ -131,6 +133,30 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
}
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
if (uri == null || desiredName == null || bytes == null || destinationDir == null) {
result.error("captureFrame-args", "failed because of missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", throwable.message)
})
}
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
val newName = call.argument<String>("newName")

View file

@ -658,7 +658,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
contentUri = StorageUtils.getOriginalUri(contentUri)
contentUri = StorageUtils.getOriginalUri(context, contentUri)
}
}

View file

@ -18,7 +18,7 @@ import kotlin.math.roundToLong
object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
private const val precisionErrorTolerance = 1e-10

View file

@ -73,7 +73,7 @@ object Metadata {
var timeZone: TimeZone? = null
val timeZoneMatcher = VIDEO_TIMEZONE_PATTERN.matcher(dateString)
if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z".toRegex(), "")}")
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z", "")}")
dateString = timeZoneMatcher.replaceAll("")
}

View file

@ -8,7 +8,7 @@ import java.util.*
object MetadataExtractorHelper {
const val PNG_TIME_DIR_NAME = "PNG-tIME"
val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
// extensions

View file

@ -17,6 +17,7 @@ import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp
@ -97,6 +98,7 @@ abstract class ImageProvider {
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun exportSingleByTreeDocAndScan(
context: Context,
sourceEntry: AvesEntry,
@ -109,9 +111,7 @@ abstract class ImageProvider {
val pageId = sourceEntry.pageId
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
val sourcePath = sourceEntry.path
val sourceFile = File(sourcePath)
val sourceFileName = sourceFile.name
val sourceFileName = File(sourceEntry.path).name
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
} else {
sourceUri.lastPathSegment!!
@ -130,13 +130,11 @@ abstract class ImageProvider {
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
if (isVideo(sourceMimeType)) {
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext")
sourceDocFile.copyTo(destinationDocFile)
} else {
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
@ -159,14 +157,12 @@ abstract class ImageProvider {
.load(model)
.submit()
try {
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
}
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
@Suppress("BlockingMethodInNonBlockingContext")
destinationDocFile.openOutputStream().use { output ->
if (exportMimeType == MimeTypes.BMP) {
BmpWriter.writeRGB24(bitmap, output)
@ -201,6 +197,108 @@ abstract class ImageProvider {
return scanNewPath(context, destinationFullPath, exportMimeType)
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun captureFrame(
context: Context,
desiredNameWithoutExtension: String,
exifFields: FieldMap,
bytes: ByteArray,
destinationDir: String,
callback: ImageOpCallback,
) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return
}
val captureMimeType = MimeTypes.JPEG
val desiredFileName = desiredNameWithoutExtension + extensionFor(captureMimeType)
if (File(destinationDir, desiredFileName).exists()) {
callback.onFailure(Exception("file with name=$desiredFileName already exists in destination directory"))
return
}
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
try {
if (exifFields.isEmpty()) {
destinationDocFile.openOutputStream().use { output ->
output.write(bytes)
}
} else {
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
outputStream().use { output ->
ByteArrayInputStream(bytes).use { imageInput ->
imageInput.copyTo(output)
}
}
}
val exif = ExifInterface(editableFile)
val rotationDegrees = exifFields["rotationDegrees"] as Int?
if (rotationDegrees != null) {
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
// in that case we explicitly set it to `normal` first
// because ExifInterface fails to rotate an image with undefined orientation
// as of androidx.exifinterface:exifinterface:1.3.0
val currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString())
}
exif.rotate(rotationDegrees)
}
val dateTimeMillis = (exifFields["dateTimeMillis"] as Number?)?.toLong()
if (dateTimeMillis != null) {
val dateString = ExifInterfaceHelper.DATETIME_FORMAT.format(Date(dateTimeMillis))
exif.setAttribute(ExifInterface.TAG_DATETIME, dateString)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateString)
val offsetInMinutes = TimeZone.getDefault().getOffset(dateTimeMillis) / 60000
val offsetSign = if (offsetInMinutes < 0) "-" else "+"
val offsetHours = "${offsetInMinutes / 60}".padStart(2, '0')
val offsetMinutes = "${offsetInMinutes % 60}".padStart(2, '0')
val timeZoneString = "$offsetSign$offsetHours:$offsetMinutes"
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, timeZoneString)
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, timeZoneString)
val sub = dateTimeMillis % 1000
if (sub > 0) {
val subString = sub.toString()
exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME, subString)
exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, subString)
}
}
val latitude = (exifFields["latitude"] as Number?)?.toDouble()
val longitude = (exifFields["longitude"] as Number?)?.toDouble()
if (latitude != null && longitude != null) {
exif.setLatLong(latitude, longitude)
}
exif.saveAttributes()
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(destinationDocFile)
}
val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName
val newFields = scanNewPath(context, destinationFullPath, captureMimeType)
callback.onSuccess(newFields)
} catch (e: Exception) {
callback.onFailure(e)
}
}
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFilename)
@ -338,52 +436,58 @@ abstract class ImageProvider {
protected suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
suspendCoroutine { cont ->
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
var contentId: Long? = null
var contentUri: Uri? = null
if (newUri != null) {
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = newUri.tryParseId()
if (contentId != null) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
fun scanUri(uri: Uri?): FieldMap? {
uri ?: return null
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val newFields = HashMap<String, Any?>()
newFields["uri"] = uri.toString()
newFields["contentId"] = uri.tryParseId()
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
cursor.close()
return newFields
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to scan uri=$uri", e)
}
return null
}
if (contentUri == null) {
cont.resumeWithException(Exception("failed to get content URI of item at path=$path"))
if (newUri == null) {
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
return@scanFile
}
val newFields = HashMap<String, Any?>()
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
newFields["uri"] = contentUri.toString()
newFields["contentId"] = contentId
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
cursor.close()
var contentUri: Uri? = null
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
val contentId = newUri.tryParseId()
if (contentId != null) {
if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
}
} catch (e: Exception) {
cont.resumeWithException(e)
return@scanFile
}
if (newFields.isEmpty()) {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
} else {
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
val newFields = scanUri(contentUri) ?: scanUri(newUri)
if (newFields != null) {
cont.resume(newFields)
} else {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
}
}
}

View file

@ -7,11 +7,11 @@ import java.util.*
object ImageProviderFactory {
fun getProvider(uri: Uri): ImageProvider? {
return when (uri.scheme?.toLowerCase(Locale.ROOT)) {
return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_CONTENT -> {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return when (uri.host?.toLowerCase(Locale.ROOT)) {
return when (uri.host?.lowercase(Locale.ROOT)) {
MediaStore.AUTHORITY -> MediaStoreImageProvider()
else -> ContentImageProvider()
}

View file

@ -1,8 +1,10 @@
package deckers.thibault.aves.utils
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
@ -54,18 +56,18 @@ object StorageUtils {
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
var filename: String? = null
var fileName: String? = null
var relativePath: String? = null
val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1
if (lastSeparatorIndex > rootLength) {
filename = anyPath.substring(lastSeparatorIndex)
fileName = anyPath.substring(lastSeparatorIndex)
relativePath = anyPath.substring(rootLength, lastSeparatorIndex)
}
relativePath ?: return null
val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList()
if (filename?.isNotEmpty() == true) {
pathSteps.add(filename)
if (fileName?.isNotEmpty() == true) {
pathSteps.add(fileName)
}
return pathSteps.iterator()
}
@ -187,7 +189,7 @@ object StorageUtils {
return "primary"
}
volume.uuid?.let { uuid ->
return uuid.toUpperCase(Locale.ROOT)
return uuid.uppercase(Locale.ROOT)
}
}
}
@ -199,7 +201,7 @@ object StorageUtils {
return "primary"
}
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
return uuid.toUpperCase(Locale.ROOT)
return uuid.uppercase(Locale.ROOT)
}
}
@ -300,8 +302,17 @@ object StorageUtils {
Log.w(LOG_TAG, "failed to get document URI for mediaUri=$mediaUri", e)
}
}
// fallback for older APIs
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
val df = getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
if (df != null) return df
// try to strip user info, if any
if (mediaUri.userInfo != null) {
val genericMediaUri = Uri.parse(mediaUri.toString().replaceFirst("${mediaUri.userInfo}@", ""))
Log.d(LOG_TAG, "retry getDocumentFile for mediaUri=$mediaUri without userInfo: $genericMediaUri")
return getDocumentFile(context, anyPath, genericMediaUri)
}
}
// good old `File`
return DocumentFileCompat.fromFile(File(anyPath))
@ -390,21 +401,24 @@ object StorageUtils {
return ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true) && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)
}
fun getOriginalUri(uri: Uri): Uri {
fun getOriginalUri(context: Context, uri: Uri): Uri {
// we get a permission denial if we require original from a provider other than the media store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val path = uri.path
path ?: return uri
// from Android R, accessing the original URI for a file media content yields a `SecurityException`
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
return MediaStore.setRequireOriginal(uri)
if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
return MediaStore.setRequireOriginal(uri)
}
}
}
return uri
}
fun openInputStream(context: Context, uri: Uri): InputStream? {
val effectiveUri = getOriginalUri(uri)
val effectiveUri = getOriginalUri(context, uri)
return try {
context.contentResolver.openInputStream(effectiveUri)
} catch (e: FileNotFoundException) {
@ -417,7 +431,7 @@ object StorageUtils {
}
fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? {
val effectiveUri = getOriginalUri(uri)
val effectiveUri = getOriginalUri(context, uri)
return try {
MediaMetadataRetriever().apply {
setDataSource(context, effectiveUri)
@ -434,11 +448,11 @@ object StorageUtils {
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
}
// `fullPath` should match "volumePath + relativeDir + filename"
// `fullPath` should match "volumePath + relativeDir + fileName"
class PathSegments(context: Context, fullPath: String) {
var volumePath: String? = null // `volumePath` with trailing "/"
var relativeDir: String? = null // `relativeDir` with trailing "/"
private var filename: String? = null // null for directories
private var fileName: String? = null // null for directories
init {
volumePath = getVolumePath(context, fullPath)
@ -446,7 +460,7 @@ object StorageUtils {
val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1
val volumePathLength = volumePath!!.length
if (lastSeparatorIndex > volumePathLength) {
filename = fullPath.substring(lastSeparatorIndex)
fileName = fullPath.substring(lastSeparatorIndex)
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex)
}
}

View file

@ -93,6 +93,23 @@
"entryActionRemoveFavourite": "Remove from favourites",
"@entryActionRemoveFavourite": {},
"videoActionCaptureFrame": "Capture frame",
"@videoActionCaptureFrame": {},
"videoActionPause": "Pause",
"@videoActionPause": {},
"videoActionPlay": "Play",
"@videoActionPlay": {},
"videoActionReplay10": "Seek backward 10 seconds",
"@videoActionReplay10": {},
"videoActionSkip10": "Seek forward 10 seconds",
"@videoActionSkip10": {},
"videoActionSelectStreams": "Select tracks",
"@videoActionSelectStreams": {},
"videoActionSetSpeed": "Playback speed",
"@videoActionSetSpeed": {},
"videoActionSettings": "Settings",
"@videoActionSettings": {},
"filterFavouriteLabel": "Favourite",
"@filterFavouriteLabel": {},
"filterLocationEmptyLabel": "Unlocated",
@ -263,6 +280,22 @@
"renameEntryDialogLabel": "New name",
"@renameEntryDialogLabel": {},
"videoSpeedDialogLabel": "Playback speed",
"@videoSpeedDialogLabel": {},
"videoStreamSelectionDialogVideo": "Video",
"@videoStreamSelectionDialogVideo": {},
"videoStreamSelectionDialogAudio": "Audio",
"@videoStreamSelectionDialogAudio": {},
"videoStreamSelectionDialogText": "Subtitles",
"@videoStreamSelectionDialogText": {},
"videoStreamSelectionDialogOff": "Off",
"@videoStreamSelectionDialogOff": {},
"videoStreamSelectionDialogTrack": "Track",
"@videoStreamSelectionDialogTrack": {},
"videoStreamSelectionDialogNoSelection": "There are no other tracks.",
"@videoStreamSelectionDialogNoSelection": {},
"genericSuccessFeedback": "Done!",
"@genericSuccessFeedback": {},
"genericFailureFeedback": "Failed",
@ -463,6 +496,8 @@
"@albumScreenshots": {},
"albumScreenRecordings": "Screen recordings",
"@albumScreenRecordings": {},
"albumVideoCaptures": "Video Captures",
"@albumVideoCaptures": {},
"albumPageTitle": "Albums",
"@albumPageTitle": {},
@ -549,6 +584,8 @@
"settingsViewerQuickActionEmpty": "No buttons",
"@settingsViewerQuickActionEmpty": {},
"settingsVideoPageTitle": "Video Settings",
"@settingsVideoPageTitle": {},
"settingsSectionVideo": "Video",
"@settingsSectionVideo": {},
"settingsVideoShowVideos": "Show videos",
@ -561,6 +598,39 @@
"@settingsVideoLoopModeTile": {},
"settingsVideoLoopModeTitle": "Loop Mode",
"@settingsVideoLoopModeTitle": {},
"settingsVideoQuickActionsTile": "Quick video actions",
"@settingsVideoQuickActionsTile": {},
"settingsVideoQuickActionEditorTitle": "Quick Video Actions",
"@settingsVideoQuickActionEditorTitle": {},
"settingsSubtitleThemeTile": "Subtitles",
"@settingsSubtitleThemeTile": {},
"settingsSubtitleThemeTitle": "Subtitles",
"@settingsSubtitleThemeTitle": {},
"settingsSubtitleThemeSample": "This is a sample.",
"@settingsSubtitleThemeSample": {},
"settingsSubtitleThemeTextAlignmentTile": "Text alignment",
"@settingsSubtitleThemeTextAlignmentTile": {},
"settingsSubtitleThemeTextAlignmentTitle": "Text Alignment",
"@settingsSubtitleThemeTextAlignmentTitle": {},
"settingsSubtitleThemeTextSize": "Text size",
"@settingsSubtitleThemeTextSize": {},
"settingsSubtitleThemeShowOutline": "Show outline and shadow",
"@settingsSubtitleThemeShowOutline": {},
"settingsSubtitleThemeTextColor": "Text color",
"@settingsSubtitleThemeTextColor": {},
"settingsSubtitleThemeTextOpacity": "Text opacity",
"@settingsSubtitleThemeTextOpacity": {},
"settingsSubtitleThemeBackgroundColor": "Background color",
"@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Background opacity",
"@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentLeft": "Left",
"@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Center",
"@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsSubtitleThemeTextAlignmentRight": "Right",
"@settingsSubtitleThemeTextAlignmentRight": {},
"settingsSectionPrivacy": "Privacy",
"@settingsSectionPrivacy": {},
@ -629,10 +699,6 @@
"@viewerOpenPanoramaButtonLabel": {},
"viewerOpenTooltip": "Open",
"@viewerOpenTooltip": {},
"viewerPauseTooltip": "Pause",
"@viewerPauseTooltip": {},
"viewerPlayTooltip": "Play",
"@viewerPlayTooltip": {},
"viewerErrorUnknown": "Oops!",
"@viewerErrorUnknown": {},
"viewerErrorDoesNotExist": "The file no longer exists.",

View file

@ -47,6 +47,15 @@
"entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",
"videoActionCaptureFrame": "프레임 캡처",
"videoActionPause": "일시정지",
"videoActionPlay": "재생",
"videoActionReplay10": "10초 뒤로 탐색",
"videoActionSkip10": "10초 앞으로 탐색",
"videoActionSelectStreams": "트랙 선택",
"videoActionSetSpeed": "재생 배속",
"videoActionSettings": "설정",
"filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음",
@ -118,6 +127,15 @@
"renameEntryDialogLabel": "이름",
"videoSpeedDialogLabel": "재생 배속",
"videoStreamSelectionDialogVideo": "동영상",
"videoStreamSelectionDialogAudio": "오디오",
"videoStreamSelectionDialogText": "자막",
"videoStreamSelectionDialogOff": "해제",
"videoStreamSelectionDialogTrack": "트랙",
"videoStreamSelectionDialogNoSelection": "다른 트랙이 없습니다.",
"genericSuccessFeedback": "정상 처리됐습니다",
"genericFailureFeedback": "오류가 발생했습니다",
@ -208,6 +226,7 @@
"albumDownload": "다운로드",
"albumScreenshots": "스크린샷",
"albumScreenRecordings": "화면 녹화 파일",
"albumVideoCaptures": "동영상 캡처",
"albumPageTitle": "앨범",
"albumEmpty": "앨범이 없습니다",
@ -256,12 +275,30 @@
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
"settingsVideoPageTitle": "동영상 설정",
"settingsSectionVideo": "동영상",
"settingsVideoShowVideos": "미디어에 동영상 표시",
"settingsVideoEnableHardwareAcceleration": "하드웨어 가속",
"settingsVideoEnableAutoPlay": "자동 재생",
"settingsVideoLoopModeTile": "반복 모드",
"settingsVideoLoopModeTitle": "반복 모드",
"settingsVideoQuickActionsTile": "빠른 동영상 작업",
"settingsVideoQuickActionEditorTitle": "빠른 동영상 작업",
"settingsSubtitleThemeTile": "자막",
"settingsSubtitleThemeTitle": "자막",
"settingsSubtitleThemeSample": "샘플입니다.",
"settingsSubtitleThemeTextAlignmentTile": "정렬",
"settingsSubtitleThemeTextAlignmentTitle": "정렬",
"settingsSubtitleThemeTextSize": "글자 크기",
"settingsSubtitleThemeShowOutline": "윤곽 및 그림자 표시",
"settingsSubtitleThemeTextColor": "글자 색상",
"settingsSubtitleThemeTextOpacity": "글자 투명도",
"settingsSubtitleThemeBackgroundColor": "배경 색상",
"settingsSubtitleThemeBackgroundOpacity": "배경 투명도",
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
"settingsSubtitleThemeTextAlignmentCenter": "가운데",
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSectionPrivacy": "개인정보 보호",
"settingsEnableAnalytics": "진단 데이터 보내기",
@ -293,8 +330,6 @@
"viewerOpenPanoramaButtonLabel": "파노라마 열기",
"viewerOpenTooltip": "열기",
"viewerPauseTooltip": "일시정지",
"viewerPlayTooltip": "재생",
"viewerErrorUnknown": "아이구!",
"viewerErrorDoesNotExist": "파일이 존재하지 않습니다.",

View file

@ -0,0 +1,68 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum VideoAction {
captureFrame,
replay10,
skip10,
selectStreams,
setSpeed,
settings,
togglePlay,
// TODO TLAD [video] toggle mute
}
class VideoActions {
static const all = [
VideoAction.togglePlay,
VideoAction.captureFrame,
VideoAction.setSpeed,
VideoAction.selectStreams,
VideoAction.replay10,
VideoAction.skip10,
VideoAction.settings,
];
}
extension ExtraVideoAction on VideoAction {
String getText(BuildContext context) {
switch (this) {
case VideoAction.captureFrame:
return context.l10n.videoActionCaptureFrame;
case VideoAction.replay10:
return context.l10n.videoActionReplay10;
case VideoAction.skip10:
return context.l10n.videoActionSkip10;
case VideoAction.selectStreams:
return context.l10n.videoActionSelectStreams;
case VideoAction.setSpeed:
return context.l10n.videoActionSetSpeed;
case VideoAction.settings:
return context.l10n.videoActionSettings;
case VideoAction.togglePlay:
// different data depending on toggle state
return context.l10n.videoActionPlay;
}
}
IconData? getIcon() {
switch (this) {
case VideoAction.captureFrame:
return AIcons.captureFrame;
case VideoAction.replay10:
return AIcons.replay10;
case VideoAction.skip10:
return AIcons.skip10;
case VideoAction.selectStreams:
return AIcons.streams;
case VideoAction.setSpeed:
return AIcons.speed;
case VideoAction.settings:
return AIcons.videoSettings;
case VideoAction.togglePlay:
// different data depending on toggle state
return AIcons.play;
}
}
}

View file

@ -5,6 +5,7 @@ import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
@ -18,8 +19,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../ref/mime_types.dart';
class AvesEntry {
String uri;
String? _path, _directory, _filename, _extension;
@ -419,7 +418,7 @@ class AvesEntry {
addressDetails = null;
}
Future<void> catalog({bool background = false}) async {
Future<void> catalog({bool background = false, bool persist = true}) async {
if (isCatalogued) return;
if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading
@ -429,7 +428,7 @@ class AvesEntry {
await _applyNewFields({
'width': size.width.round(),
'height': size.height.round(),
});
}, persist: persist);
}
catalogMetadata = CatalogMetadata(contentId: contentId);
} else {
@ -539,7 +538,7 @@ class AvesEntry {
_addressDetails?.locality,
}.any((s) => s != null && s.toUpperCase().contains(query));
Future<void> _applyNewFields(Map newFields) async {
Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
final uri = newFields['uri'];
if (uri is String) this.uri = uri;
final path = newFields['path'];
@ -561,32 +560,34 @@ class AvesEntry {
final isFlipped = newFields['isFlipped'];
if (isFlipped is bool) this.isFlipped = isFlipped;
await metadataDb.saveEntries({this});
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
if (persist) {
await metadataDb.saveEntries({this});
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
}
metadataChangeNotifier.notifyListeners();
}
Future<bool> rotate({required bool clockwise}) async {
Future<bool> rotate({required bool clockwise, required bool persist}) async {
final newFields = await imageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields);
await _applyNewFields(newFields, persist: persist);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true;
}
Future<bool> flip() async {
Future<bool> flip({required bool persist}) async {
final newFields = await imageFileService.flip(this);
if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields);
await _applyNewFields(newFields, persist: persist);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true;
}

View file

@ -12,27 +12,28 @@ class MimeFilter extends CollectionFilter {
final String mime;
late EntryFilter _test;
late String _label;
IconData? /*late*/ _icon;
late IconData _icon;
static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo);
MimeFilter(this.mime) {
IconData? icon;
var lowMime = mime.toLowerCase();
if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
_test = (entry) => entry.mimeType.startsWith(lowMime);
_label = lowMime.toUpperCase();
if (mime == MimeTypes.anyImage) {
_icon = AIcons.image;
icon = AIcons.image;
} else if (mime == MimeTypes.anyVideo) {
_icon = AIcons.video;
icon = AIcons.video;
}
} else {
_test = (entry) => entry.mimeType == lowMime;
_label = MimeUtils.displayType(lowMime);
}
_icon ??= AIcons.vector;
_icon = icon ?? AIcons.vector;
}
MimeFilter.fromMap(Map<String, dynamic> json)

View file

@ -15,7 +15,7 @@ class TypeFilter extends CollectionFilter {
final String itemType;
late EntryFilter _test;
IconData? /*late*/ _icon;
late IconData _icon;
static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff);

View file

@ -1,21 +1,22 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/source/enums.dart';
import 'package:collection/collection.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../source/enums.dart';
import 'enums.dart';
final Settings settings = Settings._private();
class Settings extends ChangeNotifier {
static SharedPreferences? /*late final*/ _prefs;
static SharedPreferences? _prefs;
Settings._private();
@ -49,11 +50,20 @@ class Settings extends ChangeNotifier {
static const showOverlayInfoKey = 'show_overlay_info';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const viewerQuickActionsKey = 'viewer_quick_actions';
static const videoQuickActionsKey = 'video_quick_actions';
// video
static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec';
static const enableVideoAutoPlayKey = 'video_auto_play';
static const videoLoopModeKey = 'video_loop';
static const videoShowRawTimedTextKey = 'video_show_raw_timed_text';
// subtitles
static const subtitleFontSizeKey = 'subtitle_font_size';
static const subtitleTextAlignmentKey = 'subtitle_text_alignment';
static const subtitleShowOutlineKey = 'subtitle_show_outline';
static const subtitleTextColorKey = 'subtitle_text_color';
static const subtitleBackgroundColorKey = 'subtitle_background_color';
// info
static const infoMapStyleKey = 'info_map_style';
@ -76,6 +86,10 @@ class Settings extends ChangeNotifier {
EntryAction.toggleFavourite,
EntryAction.share,
];
static const videoQuickActionsDefault = [
VideoAction.replay10,
VideoAction.togglePlay,
];
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
@ -229,20 +243,50 @@ class Settings extends ChangeNotifier {
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
// video
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values);
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
// video
bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true);
set enableVideoAutoPlay(bool newValue) => setAndNotify(enableVideoAutoPlayKey, newValue);
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);
bool get enableVideoAutoPlay => getBoolOrDefault(enableVideoAutoPlayKey, false);
set enableVideoAutoPlay(bool newValue) => setAndNotify(enableVideoAutoPlayKey, newValue);
VideoLoopMode get videoLoopMode => getEnumOrDefault(videoLoopModeKey, VideoLoopMode.shortOnly, VideoLoopMode.values);
set videoLoopMode(VideoLoopMode newValue) => setAndNotify(videoLoopModeKey, newValue.toString());
bool get videoShowRawTimedText => getBoolOrDefault(videoShowRawTimedTextKey, false);
set videoShowRawTimedText(bool newValue) => setAndNotify(videoShowRawTimedTextKey, newValue);
// subtitles
double get subtitleFontSize => _prefs!.getDouble(subtitleFontSizeKey) ?? 20;
set subtitleFontSize(double newValue) => setAndNotify(subtitleFontSizeKey, newValue);
TextAlign get subtitleTextAlignment => getEnumOrDefault(subtitleTextAlignmentKey, TextAlign.center, TextAlign.values);
set subtitleTextAlignment(TextAlign newValue) => setAndNotify(subtitleTextAlignmentKey, newValue.toString());
bool get subtitleShowOutline => getBoolOrDefault(subtitleShowOutlineKey, true);
set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue);
Color get subtitleTextColor => Color(_prefs!.getInt(subtitleTextColorKey) ?? Colors.white.value);
set subtitleTextColor(Color newValue) => setAndNotify(subtitleTextColorKey, newValue.value);
Color get subtitleBackgroundColor => Color(_prefs!.getInt(subtitleBackgroundColorKey) ?? Colors.transparent.value);
set subtitleBackgroundColor(Color newValue) => setAndNotify(subtitleBackgroundColorKey, newValue.value);
// info
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);

View file

@ -34,6 +34,7 @@ mixin AlbumMixin on SourceBase {
if (type == AlbumType.download) return context.l10n.albumDownload;
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
if (type == AlbumType.videoCaptures) return context.l10n.albumVideoCaptures;
}
final dir = VolumeRelativeDirectory.fromPath(dirPath);

View file

@ -43,15 +43,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final Set<AvesEntry> _rawEntries = {};
// TODO TLAD use `Set.unmodifiable()` when possible
Set<AvesEntry> get allEntries => Set.of(_rawEntries);
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries;
@override
Set<AvesEntry> get visibleEntries {
// TODO TLAD use `Set.unmodifiable()` when possible
_visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries));
_visibleEntries ??= Set.unmodifiable(_applyHiddenFilters(_rawEntries));
return _visibleEntries!;
}
@ -124,7 +122,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateTags();
}
Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
final oldContentId = entry.contentId!;
final newContentId = newFields['contentId'] as int?;
@ -139,19 +137,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.moveEntry(oldContentId, entry);
await covers.moveEntry(oldContentId, entry);
if (persist) {
await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.moveEntry(oldContentId, entry);
await covers.moveEntry(oldContentId, entry);
}
}
Future<bool> renameEntry(AvesEntry entry, String newName) async {
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
if (newName == entry.filenameWithoutExtension) return true;
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
if (newFields.isEmpty) return false;
await _moveEntry(entry, newFields);
await _moveEntry(entry, newFields, persist: persist);
entry.metadataChangeNotifier.notifyListeners();
eventBus.fire(EntryMovedEvent({entry}));
return true;
@ -215,7 +215,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
await _moveEntry(entry, newFields);
await _moveEntry(entry, newFields, persist: true);
}
}
});

View file

@ -12,7 +12,6 @@ import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.dart';
import 'package:collection/collection.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart';

View file

@ -10,7 +10,6 @@ import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:streams_channel/streams_channel.dart';
abstract class ImageFileService {
@ -81,6 +80,14 @@ abstract class ImageFileService {
required String destinationAlbum,
});
Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, {
required String desiredName,
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
});
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
@ -335,6 +342,29 @@ class PlatformImageFileService implements ImageFileService {
}
}
@override
Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, {
required String desiredName,
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
}) async {
try {
final result = await platform.invokeMethod('captureFrame', <String, dynamic>{
'uri': entry.uri,
'desiredName': desiredName,
'exif': exif,
'bytes': bytes,
'destinationPath': destinationAlbum,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
debugPrint('captureFrame failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
@override
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
try {

View file

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:streams_channel/streams_channel.dart';
abstract class MediaStoreService {

View file

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:streams_channel/streams_channel.dart';
abstract class StorageService {

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
@ -32,6 +33,9 @@ class AIcons {
// actions
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined;
static const IconData clear = Icons.clear_outlined;
static const IconData createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined;
@ -47,6 +51,8 @@ class AIcons {
static const IconData layers = Icons.layers_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;
static const IconData play = Icons.play_arrow;
static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined;
static const IconData rename = Icons.title_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined;
@ -56,7 +62,13 @@ class AIcons {
static const IconData setCover = MdiIcons.imageEditOutline;
static const IconData share = Icons.share_outlined;
static const IconData sort = Icons.sort_outlined;
static const IconData speed = Icons.speed_outlined;
static const IconData stats = Icons.pie_chart_outlined;
static const IconData streams = Icons.translate_outlined;
static const IconData streamVideo = Icons.movie_outlined;
static const IconData streamAudio = Icons.audiotrack_outlined;
static const IconData streamText = Icons.closed_caption_outlined;
static const IconData videoSettings = Icons.video_settings_outlined;
static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_outlined;
static const IconData collapse = Icons.expand_less_outlined;
@ -68,14 +80,15 @@ class AIcons {
static const IconData album = Icons.photo_album_outlined;
static const IconData cameraAlbum = Icons.photo_camera_outlined;
static const IconData downloadAlbum = Icons.file_download;
static const IconData screenshotAlbum = Icons.smartphone_outlined;
static const IconData screenshotAlbum = Icons.screenshot_outlined;
static const IconData recordingAlbum = Icons.smartphone_outlined;
// thumbnail overlay
static const IconData animated = Icons.slideshow;
static const IconData geo = Icons.language_outlined;
static const IconData motionPhoto = Icons.motion_photos_on_outlined;
static const IconData multiPage = Icons.burst_mode_outlined;
static const IconData play = Icons.play_circle_outline;
static const IconData videoThumb = Icons.play_circle_outline;
static const IconData threeSixty = Icons.threesixty_outlined;
static const IconData selected = Icons.check_circle_outline;
static const IconData unselected = Icons.radio_button_unchecked;

View file

@ -9,6 +9,9 @@ class Themes {
static final darkTheme = ThemeData(
brightness: Brightness.dark,
accentColor: _accentColor,
// canvas color is used as background for the drawer and popups
// when using a popup menu on a dialog, lighten the background via `PopupMenuTheme`
canvasColor: Colors.grey[850],
scaffoldBackgroundColor: Colors.grey.shade900,
dialogBackgroundColor: Colors.grey[850],
toggleableActiveColor: _accentColor,

View file

@ -9,7 +9,7 @@ import 'package:flutter/widgets.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils {
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
Set<StorageVolume> storageVolumes = {};
Set<Package> _packages = {};
List<String> _potentialAppDirs = [];
@ -28,6 +28,8 @@ class AndroidFileUtils {
downloadPath = pContext.join(primaryStorage, 'Download');
moviesPath = pContext.join(primaryStorage, 'Movies');
picturesPath = pContext.join(primaryStorage, 'Pictures');
// from Aves
videoCapturesPath = pContext.join(dcimPath, 'Video Captures');
}
Future<void> initAppNames() async {
@ -42,6 +44,8 @@ class AndroidFileUtils {
bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords'));
bool isVideoCapturesPath(String path) => path == videoCapturesPath;
bool isDownloadPath(String path) => path == downloadPath;
StorageVolume? getStorageVolume(String? path) {
@ -59,6 +63,7 @@ class AndroidFileUtils {
if (isDownloadPath(albumPath)) return AlbumType.download;
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
if (isVideoCapturesPath(albumPath)) return AlbumType.videoCaptures;
final dir = pContext.split(albumPath).last;
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
@ -78,7 +83,7 @@ class AndroidFileUtils {
}
}
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots }
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots, videoCaptures }
class Package {
final String packageName;

View file

@ -142,6 +142,99 @@ class Constants {
),
];
static const List<Dependency> flutterPackages = [
Dependency(
name: 'Charts',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE',
sourceUrl: 'https://github.com/google/charts',
),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
),
Dependency(
name: 'Expansion Tile Card (Aves fork)',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
),
Dependency(
name: 'FlexColorPicker',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/rydmike/flex_color_picker/blob/master/LICENSE',
sourceUrl: 'https://github.com/rydmike/flex_color_picker',
),
Dependency(
name: 'Flutter Highlight',
license: 'MIT',
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
sourceUrl: 'https://github.com/git-touch/highlight',
),
Dependency(
name: 'Flutter Map',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
sourceUrl: 'https://github.com/fleaflet/flutter_map',
),
Dependency(
name: 'Flutter Markdown',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown',
),
Dependency(
name: 'Flutter Staggered Animations',
license: 'MIT',
licenseUrl: 'https://github.com/mobiten/flutter_staggered_animations/blob/master/LICENSE',
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
),
Dependency(
name: 'Flutter SVG',
license: 'MIT',
licenseUrl: 'https://github.com/dnfield/flutter_svg/blob/master/LICENSE',
sourceUrl: 'https://github.com/dnfield/flutter_svg',
),
Dependency(
name: 'Material Design Icons Flutter',
license: 'MIT',
licenseUrl: 'https://github.com/ziofat/material_design_icons_flutter/blob/master/LICENSE',
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
),
Dependency(
name: 'Overlay Support',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
sourceUrl: 'https://github.com/boyan01/overlay_support',
),
Dependency(
name: 'Palette Generator',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
),
Dependency(
name: 'Panorama',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
sourceUrl: 'https://github.com/zesage/panorama',
),
Dependency(
name: 'Percent Indicator',
license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE',
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/',
),
Dependency(
name: 'Provider',
license: 'MIT',
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider',
),
];
static const List<Dependency> dartPackages = [
Dependency(
name: 'Collection',
@ -216,93 +309,6 @@ class Constants {
sourceUrl: 'https://github.com/renggli/dart-xml',
),
];
static const List<Dependency> flutterPackages = [
Dependency(
name: 'Charts',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE',
sourceUrl: 'https://github.com/google/charts',
),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
),
Dependency(
name: 'Expansion Tile Card (Aves fork)',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
),
Dependency(
name: 'Flutter Highlight',
license: 'MIT',
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
sourceUrl: 'https://github.com/git-touch/highlight',
),
Dependency(
name: 'Flutter Map',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
sourceUrl: 'https://github.com/fleaflet/flutter_map',
),
Dependency(
name: 'Flutter Markdown',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown',
),
Dependency(
name: 'Flutter Staggered Animations',
license: 'MIT',
licenseUrl: 'https://github.com/mobiten/flutter_staggered_animations/blob/master/LICENSE',
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
),
Dependency(
name: 'Flutter SVG',
license: 'MIT',
licenseUrl: 'https://github.com/dnfield/flutter_svg/blob/master/LICENSE',
sourceUrl: 'https://github.com/dnfield/flutter_svg',
),
Dependency(
name: 'Material Design Icons Flutter',
license: 'MIT',
licenseUrl: 'https://github.com/ziofat/material_design_icons_flutter/blob/master/LICENSE',
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
),
Dependency(
name: 'Overlay Support',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
sourceUrl: 'https://github.com/boyan01/overlay_support',
),
Dependency(
name: 'Palette Generator',
license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
),
Dependency(
name: 'Panorama',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
sourceUrl: 'https://github.com/zesage/panorama',
),
Dependency(
name: 'Percent Indicator',
license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE',
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/',
),
Dependency(
name: 'Provider',
license: 'MIT',
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider',
),
];
}
class Dependency {

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:aves/theme/durations.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@ -100,7 +102,10 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
child: StreamBuilder<T>(
stream: opStream,
builder: (context, snapshot) {
final percent = processed.length.toDouble() / widget.itemCount;
final processedCount = processed.length.toDouble();
final total = widget.itemCount;
assert(processedCount <= total);
final percent = min(1.0, processedCount / total);
return FadeTransition(
opacity: _animation,
child: Container(

View file

@ -46,26 +46,49 @@ mixin SizeAwareMixin {
final hasEnoughSpace = needed < free;
if (!hasEnoughSpace) {
await showDialog(
context: context,
builder: (context) {
final neededSize = formatFilesize(needed);
final freeSize = formatFilesize(free);
final volume = destinationVolume.getDescription(context);
return AvesDialog(
context: context,
title: context.l10n.notEnoughSpaceDialogTitle,
content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
},
);
await _showNotEnoughSpaceDialog(context, needed, free, destinationVolume);
}
return hasEnoughSpace;
}
Future<bool> checkFreeSpace(
BuildContext context,
int needed,
String destinationAlbum,
) async {
// assume we have enough space if we cannot find the volume or its remaining free space
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
if (destinationVolume == null) return true;
final free = await storageService.getFreeSpace(destinationVolume);
if (free == null) return true;
final hasEnoughSpace = needed < free;
if (!hasEnoughSpace) {
await _showNotEnoughSpaceDialog(context, needed, free, destinationVolume);
}
return hasEnoughSpace;
}
Future<void> _showNotEnoughSpaceDialog(BuildContext context, int needed, int free, StorageVolume destinationVolume) async {
await showDialog(
context: context,
builder: (context) {
final neededSize = formatFilesize(needed);
final freeSize = formatFilesize(free);
final volume = destinationVolume.getDescription(context);
return AvesDialog(
context: context,
title: context.l10n.notEnoughSpaceDialogTitle,
content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
},
);
}
}

View file

@ -0,0 +1,100 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import 'package:flutter/material.dart';
class ColorListTile extends StatelessWidget {
final String title;
final Color value;
final ValueSetter<Color> onChanged;
static const radius = 16.0;
const ColorListTile({
Key? key,
required this.title,
required this.value,
required this.onChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
trailing: Container(
height: radius * 2,
width: radius * 2,
decoration: BoxDecoration(
color: value,
border: AvesBorder.border,
shape: BoxShape.circle,
),
),
contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 36 - radius),
onTap: () async {
final color = await showDialog<Color>(
context: context,
builder: (context) => ColorPickerDialog(
initialValue: value,
),
);
if (color != null) {
onChanged(color);
}
},
);
}
}
class ColorPickerDialog extends StatefulWidget {
final Color initialValue;
const ColorPickerDialog({
Key? key,
required this.initialValue,
}) : super(key: key);
@override
_ColorPickerDialogState createState() => _ColorPickerDialogState();
}
class _ColorPickerDialogState extends State<ColorPickerDialog> {
late Color color;
@override
void initState() {
super.initState();
color = widget.initialValue;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
context: context,
scrollableContent: [
ColorPicker(
color: color,
onColorChanged: (v) => color = v,
pickersEnabled: const {
ColorPickerType.primary: false,
ColorPickerType.accent: false,
ColorPickerType.wheel: true,
},
hasBorder: true,
borderRadius: 20,
)
],
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, color),
child: Text(context.l10n.applyButtonLabel),
),
],
);
}
}

View file

@ -295,42 +295,6 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
}
}
/// Draws 2 triangles like arrow up and arrow down
class ArrowCustomPainter extends CustomPainter {
Color color;
ArrowCustomPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
paint,
);
canvas.drawPath(
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
paint,
);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
///This cut 2 lines in arrow shape
class ArrowClipper extends CustomClipper<Path> {
@override
@ -366,7 +330,7 @@ class ArrowClipper extends CustomClipper<Path> {
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
}
class SlideFadeTransition extends StatelessWidget {

View file

@ -1,78 +1,83 @@
import 'dart:ui';
import 'package:flutter/material.dart';
typedef OutlinedWidgetBuilder = Widget Function(BuildContext context, bool isShadow);
class OutlinedText extends StatelessWidget {
final OutlinedWidgetBuilder? leadingBuilder, trailingBuilder;
final String text;
final TextStyle style;
final List<TextSpan> textSpans;
final double outlineWidth;
final Color outlineColor;
final double outlineBlurSigma;
final TextAlign? textAlign;
static const widgetSpanAlignment = PlaceholderAlignment.middle;
const OutlinedText({
Key? key,
this.leadingBuilder,
required this.text,
this.trailingBuilder,
required this.style,
required this.textSpans,
double? outlineWidth,
Color? outlineColor,
double? outlineBlurSigma,
this.textAlign,
}) : outlineWidth = outlineWidth ?? 1,
outlineColor = outlineColor ?? Colors.black,
outlineBlurSigma = outlineBlurSigma ?? 0,
super(key: key);
@override
Widget build(BuildContext context) {
// TODO TLAD [subtitles] fix background area for mixed alphabetic-ideographic text
// as of Flutter v2.2.2, the area computed for `backgroundColor` has inconsistent height
// in case of mixed alphabetic-ideographic text. The painted boxes depends on the script.
// Possible workarounds would be to use metrics from:
// - `TextPainter.getBoxesForSelection`
// - `Paragraph.getBoxesForRange`
// and paint the background at the bottom of the `Stack`
final hasOutline = outlineWidth > 0;
return Stack(
children: [
if (hasOutline)
ImageFiltered(
imageFilter: outlineBlurSigma > 0
? ImageFilter.blur(
sigmaX: outlineBlurSigma,
sigmaY: outlineBlurSigma,
)
: ImageFilter.matrix(
Matrix4.identity().storage,
),
child: Text.rich(
TextSpan(
children: textSpans.map(_toStrokeSpan).toList(),
),
textAlign: textAlign,
),
),
Text.rich(
TextSpan(
children: [
if (leadingBuilder != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: leadingBuilder!(context, true),
),
TextSpan(
text: text,
style: style.copyWith(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = outlineWidth
..color = outlineColor,
),
),
if (trailingBuilder != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: trailingBuilder!(context, true),
),
],
),
),
Text.rich(
TextSpan(
children: [
if (leadingBuilder != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: leadingBuilder!(context, false),
),
TextSpan(
text: text,
style: style,
),
if (trailingBuilder != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: trailingBuilder!(context, false),
),
],
children: hasOutline ? textSpans.map(_toFillSpan).toList() : textSpans,
),
textAlign: textAlign,
),
],
);
}
TextSpan _toStrokeSpan(TextSpan span) => TextSpan(
text: span.text,
children: span.children,
style: (span.style ?? const TextStyle()).copyWith(
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = outlineWidth
..color = outlineColor,
),
);
TextSpan _toFillSpan(TextSpan span) => TextSpan(
text: span.text,
children: span.children,
style: (span.style ?? const TextStyle()).copyWith(
backgroundColor: Colors.transparent,
),
);
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class SliderListTile extends StatelessWidget {
final String title;
final double value;
final ValueChanged<double>? onChanged;
final double min;
final double max;
final int? divisions;
const SliderListTile({
Key? key,
required this.title,
required this.value,
required this.onChanged,
this.min = 0.0,
this.max = 1.0,
this.divisions,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliderTheme(
data: const SliderThemeData(
overlayShape: RoundSliderOverlayShape(
// align `Slider`s on `Switch`es by matching their overlay/reaction radius
// `kRadialReactionRadius` is used when `SwitchThemeData.splashRadius` is undefined
overlayRadius: kRadialReactionRadius,
),
),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.subtitle1!,
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(start: 16),
child: Text(title),
),
Padding(
// match `SwitchListTile.contentPadding`
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Slider(
value: value,
onChanged: onChanged,
min: min,
max: max,
divisions: divisions,
),
),
],
),
),
),
);
}
}

View file

@ -36,5 +36,5 @@ class CheckeredPainter extends CustomPainter {
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View file

@ -153,5 +153,5 @@ class _SweepClipPath extends CustomClipper<Path> {
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => true;
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}

View file

@ -188,5 +188,5 @@ class _TransitionImagePainter extends CustomPainter {
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View file

@ -87,7 +87,7 @@ class SectionHeader extends StatelessWidget {
}
}
// TODO TLAD cache header extent computation?
// TODO TLAD [perf] cache header extent computation?
static double getPreferredHeight({
required BuildContext context,
required double maxWidth,

View file

@ -23,7 +23,7 @@ class VideoIcon extends StatelessWidget {
final thumbnailTheme = context.watch<ThumbnailThemeData>();
final showDuration = thumbnailTheme.showVideoDuration;
Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threeSixty : AIcons.play,
icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
size: thumbnailTheme.iconSize,
text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1,
@ -199,8 +199,10 @@ class IconUtils {
case AlbumType.camera:
return buildIcon(AIcons.cameraAlbum);
case AlbumType.screenshots:
case AlbumType.screenRecordings:
case AlbumType.videoCaptures:
return buildIcon(AIcons.screenshotAlbum);
case AlbumType.screenRecordings:
return buildIcon(AIcons.recordingAlbum);
case AlbumType.download:
return buildIcon(AIcons.downloadAlbum);
case AlbumType.app:

View file

@ -1,10 +1,9 @@
import 'package:aves/widgets/common/magnifier/core/scale_gesture_recognizer.dart';
import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import '../pan/corner_hit_detector.dart';
class MagnifierGestureDetector extends StatefulWidget {
const MagnifierGestureDetector({
Key? key,

View file

@ -1,10 +1,9 @@
import 'dart:math';
import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import '../pan/corner_hit_detector.dart';
class MagnifierGestureRecognizer extends ScaleGestureRecognizer {
final CornerHitDetector hitDetector;
final List<Axis> validateAxis;

View file

@ -289,5 +289,5 @@ class GridPainter extends CustomPainter {
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View file

@ -29,6 +29,11 @@ class DebugSettingsSection extends StatelessWidget {
onChanged: (v) => settings.hasAcceptedTerms = v,
title: const Text('hasAcceptedTerms'),
),
SwitchListTile(
value: settings.videoShowRawTimedText,
onChanged: (v) => settings.videoShowRawTimedText = v,
title: const Text('videoShowRawTimedText'),
),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
@ -38,6 +43,7 @@ class DebugSettingsSection extends StatelessWidget {
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
'infoMapZoom': '${settings.infoMapZoom}',
'viewerQuickActions': '${settings.viewerQuickActions}',
'videoQuickActions': '${settings.videoQuickActions}',
'pinnedFilters': toMultiline(settings.pinnedFilters),
'hiddenFilters': toMultiline(settings.hiddenFilters),
'searchHistory': toMultiline(settings.searchHistory),

View file

@ -2,10 +2,9 @@ import 'dart:io';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart';
import '../dialogs/aves_dialog.dart';
class RenameAlbumDialog extends StatefulWidget {
final String album;

View file

@ -0,0 +1,70 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'aves_dialog.dart';
class VideoSpeedDialog extends StatefulWidget {
final double current, min, max;
const VideoSpeedDialog({
required this.current,
required this.min,
required this.max,
});
@override
_VideoSpeedDialogState createState() => _VideoSpeedDialogState();
}
class _VideoSpeedDialogState extends State<VideoSpeedDialog> {
late double _speed;
static const interval = .25;
@override
void initState() {
super.initState();
_speed = widget.current;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
context: context,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const SizedBox(width: 24),
Text(context.l10n.videoSpeedDialogLabel),
const SizedBox(width: 16),
Text('x$_speed'),
],
),
const SizedBox(height: 16),
Slider(
value: _speed,
onChanged: (v) => setState(() => _speed = v),
min: widget.min,
max: widget.max,
divisions: ((widget.max - widget.min) / interval).round(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => _submit(context),
child: Text(context.l10n.applyButtonLabel),
),
],
);
}
void _submit(BuildContext context) => Navigator.pop(context, _speed);
}

View file

@ -0,0 +1,184 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/languages.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'aves_dialog.dart';
class VideoStreamSelectionDialog extends StatefulWidget {
final Map<StreamSummary, bool> streams;
const VideoStreamSelectionDialog({
required this.streams,
});
@override
_VideoStreamSelectionDialogState createState() => _VideoStreamSelectionDialogState();
}
class _VideoStreamSelectionDialogState extends State<VideoStreamSelectionDialog> {
late List<StreamSummary?> _videoStreams, _audioStreams, _textStreams;
StreamSummary? _currentVideo, _currentAudio, _currentText;
@override
void initState() {
super.initState();
final byType = groupBy<StreamSummary, StreamType>(widget.streams.keys, (v) => v.type);
// check width/height to exclude image streams (that are included among video streams)
_videoStreams = (byType[StreamType.video] ?? []).where((v) => v.width != null && v.height != null).toList();
_audioStreams = (byType[StreamType.audio] ?? []);
_textStreams = (byType[StreamType.text] ?? [])..insert(0, null);
final streamEntries = widget.streams.entries;
_currentVideo = streamEntries.firstWhereOrNull((kv) => kv.key.type == StreamType.video && kv.value)?.key;
_currentAudio = streamEntries.firstWhereOrNull((kv) => kv.key.type == StreamType.audio && kv.value)?.key;
_currentText = streamEntries.firstWhereOrNull((kv) => kv.key.type == StreamType.text && kv.value)?.key;
}
@override
Widget build(BuildContext context) {
final canSelectVideo = _videoStreams.length > 1;
final canSelectAudio = _audioStreams.length > 1;
final canSelectText = _textStreams.length > 1;
final canSelect = canSelectVideo || canSelectAudio || canSelectText;
return AvesDialog(
context: context,
content: canSelect ? null : Text(context.l10n.videoStreamSelectionDialogNoSelection),
scrollableContent: canSelect
? [
if (canSelectVideo)
..._buildSection(
icon: AIcons.streamVideo,
title: context.l10n.videoStreamSelectionDialogVideo,
streams: _videoStreams,
current: _currentVideo,
setter: (v) => _currentVideo = v,
),
if (canSelectAudio)
..._buildSection(
icon: AIcons.streamAudio,
title: context.l10n.videoStreamSelectionDialogAudio,
streams: _audioStreams,
current: _currentAudio,
setter: (v) => _currentAudio = v,
),
if (canSelectText)
..._buildSection(
icon: AIcons.streamText,
title: context.l10n.videoStreamSelectionDialogText,
streams: _textStreams,
current: _currentText,
setter: (v) => _currentText = v,
),
const SizedBox(height: 8),
]
: null,
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
if (canSelect)
TextButton(
onPressed: () => _submit(context),
child: Text(context.l10n.applyButtonLabel),
),
],
);
}
static String _formatLanguage(String value) {
final language = Language.living639_2.firstWhereOrNull((language) => language.iso639_2 == value);
return language?.native ?? value;
}
String _commonStreamName(StreamSummary? stream) {
if (stream == null) return context.l10n.videoStreamSelectionDialogOff;
final title = stream.title;
final language = stream.language;
if (language != null && language != 'und') {
final formattedLanguage = _formatLanguage(language);
return '$formattedLanguage${title != null && title != formattedLanguage ? '$title' : ''}';
} else if (title != null) {
return title;
} else {
return '${context.l10n.videoStreamSelectionDialogTrack} ${stream.index} (${stream.codecName})';
}
}
String _streamName(StreamSummary? stream) {
final common = _commonStreamName(stream);
if (stream != null && stream.type == StreamType.video) {
final w = stream.width;
final h = stream.height;
if (w != null && h != null) {
return '$common$w${AvesEntry.resolutionSeparator}$h';
}
}
return common;
}
DropdownMenuItem<StreamSummary> _buildMenuItem(StreamSummary? value) {
return DropdownMenuItem(
value: value,
child: Text(_streamName(value)),
);
}
Widget _buildSelectedItem(StreamSummary? v) {
return Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
_streamName(v),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}
List<Widget> _buildSection({
required IconData icon,
required String title,
required List<StreamSummary?> streams,
required StreamSummary? current,
required ValueSetter<StreamSummary?> setter,
}) {
return [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, right: 16),
child: Row(
children: [
Icon(icon),
const SizedBox(width: 16),
Text(title),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: DropdownButton<StreamSummary>(
items: streams.map(_buildMenuItem).toList(),
selectedItemBuilder: (context) => streams.map(_buildSelectedItem).toList(),
value: current,
onChanged: streams.length > 1 ? (newValue) => setState(() => setter(newValue)) : null,
isExpanded: true,
// use a different shade to avoid having the same background
// on the dialog (using the theme `dialogBackgroundColor`)
// and on the dropdown (using the theme `canvasColor`)
dropdownColor: Colors.grey.shade800,
),
),
];
}
void _submit(BuildContext context) => Navigator.pop(context, {
StreamType.video: _currentVideo,
StreamType.audio: _currentAudio,
StreamType.text: _currentText,
});
}

View file

@ -26,6 +26,11 @@ class AlbumListPage extends StatelessWidget {
final source = context.read<CollectionSource>();
return Selector<Settings, Tuple3<AlbumChipGroupFactor, ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => Tuple3(s.albumGroupFactor, s.albumSortFactor, s.pinnedFilters),
shouldRebuild: (t1, t2) {
// `Selector` by default uses `DeepCollectionEquality`, which does not go deep in collections within `TupleN`
const eq = DeepCollectionEquality();
return !(eq.equals(t1.item1, t2.item1) && eq.equals(t1.item2, t2.item2) && eq.equals(t1.item3, t2.item3));
},
builder: (context, s, child) {
return AnimatedBuilder(
animation: androidFileUtils.appNameChangeNotifier,

View file

@ -25,6 +25,11 @@ class CountryListPage extends StatelessWidget {
final source = context.read<CollectionSource>();
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => Tuple2(s.countrySortFactor, s.pinnedFilters),
shouldRebuild: (t1, t2) {
// `Selector` by default uses `DeepCollectionEquality`, which does not go deep in collections within `TupleN`
const eq = DeepCollectionEquality();
return !(eq.equals(t1.item1, t2.item1) && eq.equals(t1.item2, t2.item2));
},
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(),

View file

@ -25,6 +25,11 @@ class TagListPage extends StatelessWidget {
final source = context.read<CollectionSource>();
return Selector<Settings, Tuple2<ChipSortFactor, Set<CollectionFilter>>>(
selector: (context, s) => Tuple2(s.tagSortFactor, s.pinnedFilters),
shouldRebuild: (t1, t2) {
// `Selector` by default uses `DeepCollectionEquality`, which does not go deep in collections within `TupleN`
const eq = DeepCollectionEquality();
return !(eq.equals(t1.item1, t2.item1) && eq.equals(t1.item2, t2.item2));
},
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),

View file

@ -119,7 +119,7 @@ class _HomePageState extends State<HomePage> {
final entry = await imageFileService.getEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for coordinates and video rotation
await entry.catalog();
await entry.catalog(background: false, persist: false);
}
return entry;
}

View file

@ -1,6 +1,6 @@
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/quick_actions/action_button.dart';
import 'package:aves/widgets/settings/quick_actions/placeholder.dart';
import 'package:aves/widgets/settings/common/quick_actions/action_button.dart';
import 'package:aves/widgets/settings/common/quick_actions/placeholder.dart';
import 'package:flutter/widgets.dart';
class AvailableActionPanel<T extends Object> extends StatelessWidget {

View file

@ -6,17 +6,17 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/quick_actions/action_button.dart';
import 'package:aves/widgets/settings/quick_actions/action_panel.dart';
import 'package:aves/widgets/settings/quick_actions/available_actions.dart';
import 'package:aves/widgets/settings/quick_actions/placeholder.dart';
import 'package:aves/widgets/settings/quick_actions/quick_actions.dart';
import 'package:aves/widgets/settings/common/quick_actions/action_button.dart';
import 'package:aves/widgets/settings/common/quick_actions/action_panel.dart';
import 'package:aves/widgets/settings/common/quick_actions/available_actions.dart';
import 'package:aves/widgets/settings/common/quick_actions/placeholder.dart';
import 'package:aves/widgets/settings/common/quick_actions/quick_actions.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class QuickActionEditorPage<T extends Object> extends StatefulWidget {
final String bannerText;
final String title, bannerText;
final List<T> allAvailableActions;
final IconData? Function(T action) actionIcon;
final String Function(BuildContext context, T action) actionText;
@ -24,6 +24,7 @@ class QuickActionEditorPage<T extends Object> extends StatefulWidget {
final void Function(List<T> actions) save;
const QuickActionEditorPage({
required this.title,
required this.bannerText,
required this.allAvailableActions,
required this.actionIcon,
@ -96,7 +97,7 @@ class _QuickActionEditorPageState<T extends Object> extends State<QuickActionEdi
return MediaQueryDataProvider(
child: Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsViewerQuickActionEditorTitle),
title: Text(widget.title),
),
body: WillPopScope(
onWillPop: () {

View file

@ -0,0 +1,34 @@
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
class SettingsTileLeading extends StatelessWidget {
final IconData icon;
final Color color;
const SettingsTileLeading({
Key? key,
required this.icon,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: color,
width: AvesFilterChip.outlineWidth,
)),
shape: BoxShape.circle,
),
child: DecoratedIcon(
icon,
shadows: Constants.embossShadows,
size: 18,
),
);
}
}

View file

@ -0,0 +1,61 @@
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/language/locale.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class LanguageSection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier;
const LanguageSection({
Key? key,
required this.expandedNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentCoordinateFormat = context.select<Settings, CoordinateFormat>((s) => s.coordinateFormat);
return AvesExpansionTile(
// use a fixed value instead of the title to identify this expansion tile
// so that the tile state is kept when the language is modified
value: 'language',
leading: SettingsTileLeading(
icon: AIcons.language,
color: stringToColor('Language'),
),
title: context.l10n.settingsSectionLanguage,
expandedNotifier: expandedNotifier,
showHighlight: false,
children: [
LocaleTile(),
ListTile(
title: Text(context.l10n.settingsCoordinateFormatTile),
subtitle: Text(currentCoordinateFormat.getName(context)),
onTap: () async {
final value = await showDialog<CoordinateFormat>(
context: context,
builder: (context) => AvesSelectionDialog<CoordinateFormat>(
initialValue: currentCoordinateFormat,
options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.getName(context)))),
optionSubtitleBuilder: (value) => value.format(Constants.pointNemo),
title: context.l10n.settingsCoordinateFormatTitle,
),
);
if (value != null) {
settings.coordinateFormat = value;
}
},
),
],
);
}
}

View file

@ -7,10 +7,9 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LanguageTile extends StatelessWidget {
class LocaleTile extends StatelessWidget {
static const _systemLocaleOption = Locale('system');
@override

View file

@ -0,0 +1,79 @@
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class NavigationSection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier;
const NavigationSection({
Key? key,
required this.expandedNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentHomePage = context.select<Settings, HomePageSetting>((s) => s.homePage);
final currentKeepScreenOn = context.select<Settings, KeepScreenOn>((s) => s.keepScreenOn);
final currentMustBackTwiceToExit = context.select<Settings, bool>((s) => s.mustBackTwiceToExit);
return AvesExpansionTile(
leading: SettingsTileLeading(
icon: AIcons.home,
color: stringToColor('Navigation'),
),
title: context.l10n.settingsSectionNavigation,
expandedNotifier: expandedNotifier,
showHighlight: false,
children: [
ListTile(
title: Text(context.l10n.settingsHome),
subtitle: Text(currentHomePage.getName(context)),
onTap: () async {
final value = await showDialog<HomePageSetting>(
context: context,
builder: (context) => AvesSelectionDialog<HomePageSetting>(
initialValue: currentHomePage,
options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsHome,
),
);
if (value != null) {
settings.homePage = value;
}
},
),
ListTile(
title: Text(context.l10n.settingsKeepScreenOnTile),
subtitle: Text(currentKeepScreenOn.getName(context)),
onTap: () async {
final value = await showDialog<KeepScreenOn>(
context: context,
builder: (context) => AvesSelectionDialog<KeepScreenOn>(
initialValue: currentKeepScreenOn,
options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsKeepScreenOnTitle,
),
);
if (value != null) {
settings.keepScreenOn = value;
}
},
),
SwitchListTile(
value: currentMustBackTwiceToExit,
onChanged: (v) => settings.mustBackTwiceToExit = v,
title: Text(context.l10n.settingsDoubleBackExit),
),
],
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
@ -52,16 +53,16 @@ class HiddenFilterPage extends StatelessWidget {
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Consumer<Settings>(
builder: (context, settings, child) {
final hiddenFilters = settings.hiddenFilters;
final filterList = hiddenFilters.toList()..sort();
child: Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => settings.hiddenFilters,
builder: (context, hiddenFilters, child) {
if (hiddenFilters.isEmpty) {
return EmptyContent(
icon: AIcons.hide,
text: context.l10n.settingsHiddenFiltersEmpty,
);
}
final filterList = hiddenFilters.toList()..sort();
return Wrap(
spacing: 8,
runSpacing: 8,

View file

@ -0,0 +1,54 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/privacy/access_grants.dart';
import 'package:aves/widgets/settings/privacy/hidden_filters.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PrivacySection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier;
const PrivacySection({
Key? key,
required this.expandedNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentIsCrashlyticsEnabled = context.select<Settings, bool>((s) => s.isCrashlyticsEnabled);
final currentSaveSearchHistory = context.select<Settings, bool>((s) => s.saveSearchHistory);
return AvesExpansionTile(
leading: SettingsTileLeading(
icon: AIcons.privacy,
color: stringToColor('Privacy'),
),
title: context.l10n.settingsSectionPrivacy,
expandedNotifier: expandedNotifier,
showHighlight: false,
children: [
SwitchListTile(
value: currentIsCrashlyticsEnabled,
onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text(context.l10n.settingsEnableAnalytics),
),
SwitchListTile(
value: currentSaveSearchHistory,
onChanged: (v) {
settings.saveSearchHistory = v;
if (!v) {
settings.searchHistory = [];
}
},
title: Text(context.l10n.settingsSaveSearchHistory),
),
HiddenFilterTile(),
StorageAccessTile(),
],
);
}
}

View file

@ -1,29 +1,14 @@
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/settings/video_loop_mode.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/entry_background.dart';
import 'package:aves/widgets/settings/hidden_filters.dart';
import 'package:aves/widgets/settings/language.dart';
import 'package:aves/widgets/settings/entry_actions_editor.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:aves/widgets/settings/language/language.dart';
import 'package:aves/widgets/settings/navigation.dart';
import 'package:aves/widgets/settings/privacy/privacy.dart';
import 'package:aves/widgets/settings/thumbnails.dart';
import 'package:aves/widgets/settings/video/video.dart';
import 'package:aves/widgets/settings/viewer/viewer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatefulWidget {
static const routeName = '/settings';
@ -51,28 +36,26 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
child: SafeArea(
child: Consumer<Settings>(
builder: (context, settings, child) => AnimationLimiter(
child: ListView(
padding: const EdgeInsets.all(8),
children: AnimationConfiguration.toStaggeredList(
duration: Durations.staggeredAnimation,
delay: Durations.staggeredAnimationDelay,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
child: AnimationLimiter(
child: ListView(
padding: const EdgeInsets.all(8),
children: AnimationConfiguration.toStaggeredList(
duration: Durations.staggeredAnimation,
delay: Durations.staggeredAnimationDelay,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
children: [
_buildNavigationSection(context),
_buildThumbnailsSection(context),
_buildViewerSection(context),
_buildVideoSection(context),
_buildPrivacySection(context),
_buildLanguageSection(context),
],
),
children: [
NavigationSection(expandedNotifier: _expandedNotifier),
ThumbnailsSection(expandedNotifier: _expandedNotifier),
ViewerSection(expandedNotifier: _expandedNotifier),
VideoSection(expandedNotifier: _expandedNotifier),
PrivacySection(expandedNotifier: _expandedNotifier),
LanguageSection(expandedNotifier: _expandedNotifier),
],
),
),
),
@ -81,270 +64,4 @@ class _SettingsPageState extends State<SettingsPage> {
),
);
}
Widget _buildNavigationSection(BuildContext context) {
return AvesExpansionTile(
leading: _buildLeading(AIcons.home, stringToColor('Navigation')),
title: context.l10n.settingsSectionNavigation,
expandedNotifier: _expandedNotifier,
showHighlight: false,
children: [
ListTile(
title: Text(context.l10n.settingsHome),
subtitle: Text(settings.homePage.getName(context)),
onTap: () async {
final value = await showDialog<HomePageSetting>(
context: context,
builder: (context) => AvesSelectionDialog<HomePageSetting>(
initialValue: settings.homePage,
options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsHome,
),
);
if (value != null) {
settings.homePage = value;
}
},
),
ListTile(
title: Text(context.l10n.settingsKeepScreenOnTile),
subtitle: Text(settings.keepScreenOn.getName(context)),
onTap: () async {
final value = await showDialog<KeepScreenOn>(
context: context,
builder: (context) => AvesSelectionDialog<KeepScreenOn>(
initialValue: settings.keepScreenOn,
options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsKeepScreenOnTitle,
),
);
if (value != null) {
settings.keepScreenOn = value;
}
},
),
SwitchListTile(
value: settings.mustBackTwiceToExit,
onChanged: (v) => settings.mustBackTwiceToExit = v,
title: Text(context.l10n.settingsDoubleBackExit),
),
],
);
}
Widget _buildThumbnailsSection(BuildContext context) {
final iconSize = IconTheme.of(context).size! * MediaQuery.of(context).textScaleFactor;
double opacityFor(bool enabled) => enabled ? 1 : .2;
return AvesExpansionTile(
leading: _buildLeading(AIcons.grid, stringToColor('Thumbnails')),
title: context.l10n.settingsSectionThumbnails,
expandedNotifier: _expandedNotifier,
showHighlight: false,
children: [
SwitchListTile(
value: settings.showThumbnailLocation,
onChanged: (v) => settings.showThumbnailLocation = v,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
AnimatedOpacity(
opacity: opacityFor(settings.showThumbnailLocation),
duration: Durations.toggleableTransitionAnimation,
child: Icon(
AIcons.location,
size: iconSize,
),
),
],
),
),
SwitchListTile(
value: settings.showThumbnailRaw,
onChanged: (v) => settings.showThumbnailRaw = v,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
AnimatedOpacity(
opacity: opacityFor(settings.showThumbnailRaw),
duration: Durations.toggleableTransitionAnimation,
child: Icon(
AIcons.raw,
size: iconSize,
),
),
],
),
),
SwitchListTile(
value: settings.showThumbnailVideoDuration,
onChanged: (v) => settings.showThumbnailVideoDuration = v,
title: Text(context.l10n.settingsThumbnailShowVideoDuration),
),
],
);
}
Widget _buildViewerSection(BuildContext context) {
return AvesExpansionTile(
leading: _buildLeading(AIcons.image, stringToColor('Image')),
title: context.l10n.settingsSectionViewer,
expandedNotifier: _expandedNotifier,
showHighlight: false,
children: [
QuickEntryActionsTile(),
SwitchListTile(
value: settings.showOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
),
SwitchListTile(
value: settings.showOverlayInfo,
onChanged: (v) => settings.showOverlayInfo = v,
title: Text(context.l10n.settingsViewerShowInformation),
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
),
SwitchListTile(
value: settings.showOverlayShootingDetails,
onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
title: Text(context.l10n.settingsViewerShowShootingDetails),
),
ListTile(
title: Text(context.l10n.settingsRasterImageBackground),
trailing: EntryBackgroundSelector(
getter: () => settings.rasterBackground,
setter: (value) => settings.rasterBackground = value,
),
),
ListTile(
title: Text(context.l10n.settingsVectorImageBackground),
trailing: EntryBackgroundSelector(
getter: () => settings.vectorBackground,
setter: (value) => settings.vectorBackground = value,
),
),
],
);
}
Widget _buildVideoSection(BuildContext context) {
final hiddenFilters = settings.hiddenFilters;
final showVideos = !hiddenFilters.contains(MimeFilter.video);
return AvesExpansionTile(
leading: _buildLeading(AIcons.video, stringToColor('Video')),
title: context.l10n.settingsSectionVideo,
expandedNotifier: _expandedNotifier,
showHighlight: false,
children: [
SwitchListTile(
value: showVideos,
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
title: Text(context.l10n.settingsVideoShowVideos),
),
SwitchListTile(
value: settings.enableVideoHardwareAcceleration,
onChanged: (v) => settings.enableVideoHardwareAcceleration = v,
title: Text(context.l10n.settingsVideoEnableHardwareAcceleration),
),
SwitchListTile(
value: settings.enableVideoAutoPlay,
onChanged: (v) => settings.enableVideoAutoPlay = v,
title: Text(context.l10n.settingsVideoEnableAutoPlay),
),
ListTile(
title: Text(context.l10n.settingsVideoLoopModeTile),
subtitle: Text(settings.videoLoopMode.getName(context)),
onTap: () async {
final value = await showDialog<VideoLoopMode>(
context: context,
builder: (context) => AvesSelectionDialog<VideoLoopMode>(
initialValue: settings.videoLoopMode,
options: Map.fromEntries(VideoLoopMode.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsVideoLoopModeTitle,
),
);
if (value != null) {
settings.videoLoopMode = value;
}
},
),
],
);
}
Widget _buildPrivacySection(BuildContext context) {
return AvesExpansionTile(
leading: _buildLeading(AIcons.privacy, stringToColor('Privacy')),
title: context.l10n.settingsSectionPrivacy,
expandedNotifier: _expandedNotifier,
showHighlight: false,
children: [
SwitchListTile(
value: settings.isCrashlyticsEnabled,
onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text(context.l10n.settingsEnableAnalytics),
),
SwitchListTile(
value: settings.saveSearchHistory,
onChanged: (v) {
settings.saveSearchHistory = v;
if (!v) {
settings.searchHistory = [];
}
},
title: Text(context.l10n.settingsSaveSearchHistory),
),
HiddenFilterTile(),
StorageAccessTile(),
],
);
}
Widget _buildLanguageSection(BuildContext context) {
return AvesExpansionTile(
// use a fixed value instead of the title to identify this expansion tile
// so that the tile state is kept when the language is modified
value: 'language',
leading: _buildLeading(AIcons.language, stringToColor('Language')),
title: context.l10n.settingsSectionLanguage,
expandedNotifier: _expandedNotifier,
showHighlight: false,
children: [
LanguageTile(),
ListTile(
title: Text(context.l10n.settingsCoordinateFormatTile),
subtitle: Text(settings.coordinateFormat.getName(context)),
onTap: () async {
final value = await showDialog<CoordinateFormat>(
context: context,
builder: (context) => AvesSelectionDialog<CoordinateFormat>(
initialValue: settings.coordinateFormat,
options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.getName(context)))),
optionSubtitleBuilder: (value) => value.format(Constants.pointNemo),
title: context.l10n.settingsCoordinateFormatTitle,
),
);
if (value != null) {
settings.coordinateFormat = value;
}
},
),
],
);
}
Widget _buildLeading(IconData icon, Color color) => Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: color,
width: AvesFilterChip.outlineWidth,
)),
shape: BoxShape.circle,
),
child: DecoratedIcon(
icon,
shadows: Constants.embossShadows,
size: 18,
),
);
}

View file

@ -0,0 +1,79 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ThumbnailsSection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier;
const ThumbnailsSection({
Key? key,
required this.expandedNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentShowThumbnailLocation = context.select<Settings, bool>((s) => s.showThumbnailLocation);
final currentShowThumbnailRaw = context.select<Settings, bool>((s) => s.showThumbnailRaw);
final currentShowThumbnailVideoDuration = context.select<Settings, bool>((s) => s.showThumbnailVideoDuration);
final iconSize = IconTheme.of(context).size! * MediaQuery.of(context).textScaleFactor;
double opacityFor(bool enabled) => enabled ? 1 : .2;
return AvesExpansionTile(
leading: SettingsTileLeading(
icon: AIcons.grid,
color: stringToColor('Thumbnails'),
),
title: context.l10n.settingsSectionThumbnails,
expandedNotifier: expandedNotifier,
showHighlight: false,
children: [
SwitchListTile(
value: currentShowThumbnailLocation,
onChanged: (v) => settings.showThumbnailLocation = v,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowLocationIcon)),
AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailLocation),
duration: Durations.toggleableTransitionAnimation,
child: Icon(
AIcons.location,
size: iconSize,
),
),
],
),
),
SwitchListTile(
value: currentShowThumbnailRaw,
onChanged: (v) => settings.showThumbnailRaw = v,
title: Row(
children: [
Expanded(child: Text(context.l10n.settingsThumbnailShowRawIcon)),
AnimatedOpacity(
opacity: opacityFor(currentShowThumbnailRaw),
duration: Durations.toggleableTransitionAnimation,
child: Icon(
AIcons.raw,
size: iconSize,
),
),
],
),
),
SwitchListTile(
value: currentShowThumbnailVideoDuration,
onChanged: (v) => settings.showThumbnailVideoDuration = v,
title: Text(context.l10n.settingsThumbnailShowVideoDuration),
),
],
);
}
}

View file

@ -0,0 +1,82 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SubtitleSample extends StatelessWidget {
const SubtitleSample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<Settings>(
builder: (context, settings, child) {
final outlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
final shadows = [
Shadow(
color: outlineColor,
offset: VideoSubtitles.baseShadowOffset,
),
];
return Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topRight,
colors: [
// Bora Bora
Color(0xff2bc0e4),
Color(0xffeaecc6),
],
),
border: AvesBorder.border,
borderRadius: const BorderRadius.all(Radius.circular(24)),
),
height: 128,
child: AnimatedAlign(
alignment: _getAlignment(settings.subtitleTextAlignment),
curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 400),
child: Padding(
padding: const EdgeInsets.all(16),
child: AnimatedDefaultTextStyle(
style: TextStyle(
color: settings.subtitleTextColor,
backgroundColor: settings.subtitleBackgroundColor,
fontSize: settings.subtitleFontSize,
shadows: settings.subtitleShowOutline ? shadows : null,
),
textAlign: settings.subtitleTextAlignment,
duration: const Duration(milliseconds: 200),
child: OutlinedText(
textSpans: [
TextSpan(
text: context.l10n.settingsSubtitleThemeSample,
),
],
outlineWidth: settings.subtitleShowOutline ? 1 : 0,
outlineColor: outlineColor,
),
),
),
),
);
},
);
}
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;
}
}
}

View file

@ -0,0 +1,126 @@
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';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/video/subtitle_sample.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SubtitleThemeTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsSubtitleThemeTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: SubtitleThemePage.routeName),
builder: (context) => SubtitleThemePage(),
),
);
},
);
}
}
class SubtitleThemePage extends StatelessWidget {
static const routeName = '/settings/subtitle_theme';
static const textAlignOptions = [TextAlign.left, TextAlign.center, TextAlign.right];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsSubtitleThemeTitle),
),
body: SafeArea(
child: Consumer<Settings>(
builder: (context, settings, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(16),
child: SubtitleSample(),
),
const Divider(height: 0),
Expanded(
child: ListView(
children: [
ListTile(
title: Text(context.l10n.settingsSubtitleThemeTextAlignmentTile),
subtitle: Text(_getTextAlignName(context, settings.subtitleTextAlignment)),
onTap: () async {
final value = await showDialog<TextAlign>(
context: context,
builder: (context) => AvesSelectionDialog<TextAlign>(
initialValue: settings.subtitleTextAlignment,
options: Map.fromEntries(textAlignOptions.map((v) => MapEntry(v, _getTextAlignName(context, v)))),
title: context.l10n.settingsSubtitleThemeTextAlignmentTitle,
),
);
if (value != null) {
settings.subtitleTextAlignment = value;
}
},
),
SliderListTile(
title: context.l10n.settingsSubtitleThemeTextSize,
value: settings.subtitleFontSize,
onChanged: (v) => settings.subtitleFontSize = v,
min: 10,
max: 40,
divisions: 6,
),
ColorListTile(
title: context.l10n.settingsSubtitleThemeTextColor,
value: settings.subtitleTextColor.withOpacity(1),
onChanged: (v) => settings.subtitleTextColor = v.withOpacity(settings.subtitleTextColor.opacity),
),
SliderListTile(
title: context.l10n.settingsSubtitleThemeTextOpacity,
value: settings.subtitleTextColor.opacity,
onChanged: (v) => settings.subtitleTextColor = settings.subtitleTextColor.withOpacity(v),
),
ColorListTile(
title: context.l10n.settingsSubtitleThemeBackgroundColor,
value: settings.subtitleBackgroundColor.withOpacity(1),
onChanged: (v) => settings.subtitleBackgroundColor = v.withOpacity(settings.subtitleBackgroundColor.opacity),
),
SliderListTile(
title: context.l10n.settingsSubtitleThemeBackgroundOpacity,
value: settings.subtitleBackgroundColor.opacity,
onChanged: (v) => settings.subtitleBackgroundColor = settings.subtitleBackgroundColor.withOpacity(v),
),
SwitchListTile(
value: settings.subtitleShowOutline,
onChanged: (v) => settings.subtitleShowOutline = v,
title: Text(context.l10n.settingsSubtitleThemeShowOutline),
),
],
),
),
],
);
},
),
),
);
}
String _getTextAlignName(BuildContext context, TextAlign align) {
switch (align) {
case TextAlign.left:
return context.l10n.settingsSubtitleThemeTextAlignmentLeft;
case TextAlign.center:
return context.l10n.settingsSubtitleThemeTextAlignmentCenter;
case TextAlign.right:
return context.l10n.settingsSubtitleThemeTextAlignmentRight;
default:
return '';
}
}
}

View file

@ -0,0 +1,119 @@
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/settings/video_loop_mode.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/video/subtitle_theme.dart';
import 'package:aves/widgets/settings/video/video_actions_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class VideoSection extends StatelessWidget {
final ValueNotifier<String?>? expandedNotifier;
final bool standalonePage;
const VideoSection({
Key? key,
this.expandedNotifier,
this.standalonePage = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentShowVideos = context.select<Settings, bool>((s) => !s.hiddenFilters.contains(MimeFilter.video));
final currentEnableVideoHardwareAcceleration = context.select<Settings, bool>((s) => s.enableVideoHardwareAcceleration);
final currentEnableVideoAutoPlay = context.select<Settings, bool>((s) => s.enableVideoAutoPlay);
final currentVideoLoopMode = context.select<Settings, VideoLoopMode>((s) => s.videoLoopMode);
final children = [
if (!standalonePage)
SwitchListTile(
value: currentShowVideos,
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
title: Text(context.l10n.settingsVideoShowVideos),
),
VideoActionsTile(),
SwitchListTile(
value: currentEnableVideoHardwareAcceleration,
onChanged: (v) => settings.enableVideoHardwareAcceleration = v,
title: Text(context.l10n.settingsVideoEnableHardwareAcceleration),
),
SwitchListTile(
value: currentEnableVideoAutoPlay,
onChanged: (v) => settings.enableVideoAutoPlay = v,
title: Text(context.l10n.settingsVideoEnableAutoPlay),
),
ListTile(
title: Text(context.l10n.settingsVideoLoopModeTile),
subtitle: Text(currentVideoLoopMode.getName(context)),
onTap: () async {
final value = await showDialog<VideoLoopMode>(
context: context,
builder: (context) => AvesSelectionDialog<VideoLoopMode>(
initialValue: currentVideoLoopMode,
options: Map.fromEntries(VideoLoopMode.values.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.settingsVideoLoopModeTitle,
),
);
if (value != null) {
settings.videoLoopMode = value;
}
},
),
SubtitleThemeTile(),
];
return standalonePage
? ListView(
children: children,
)
: AvesExpansionTile(
leading: SettingsTileLeading(
icon: AIcons.video,
color: stringToColor('Video'),
),
title: context.l10n.settingsSectionVideo,
expandedNotifier: expandedNotifier,
showHighlight: false,
children: children,
);
}
}
class VideoSettingsPage extends StatelessWidget {
static const routeName = '/settings/video';
const VideoSettingsPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return MediaQueryDataProvider(
child: Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsVideoPageTitle),
),
body: Theme(
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
// dense style font for tile subtitles, without modifying title font
bodyText2: const TextStyle(fontSize: 12),
),
),
child: const SafeArea(
child: VideoSection(
standalonePage: true,
),
),
),
),
);
}
}

View file

@ -0,0 +1,42 @@
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart';
import 'package:flutter/material.dart';
class VideoActionsTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsVideoQuickActionsTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoActionEditorPage.routeName),
builder: (context) => const VideoActionEditorPage(),
),
);
},
);
}
}
class VideoActionEditorPage extends StatelessWidget {
static const routeName = '/settings/video_actions';
const VideoActionEditorPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return QuickActionEditorPage<VideoAction>(
title: context.l10n.settingsVideoQuickActionEditorTitle,
bannerText: context.l10n.settingsViewerQuickActionEditorBanner,
allAvailableActions: VideoActions.all,
actionIcon: (action) => action.getIcon(),
actionText: (context, action) => action.getText(context),
load: () => settings.videoQuickActions.toList(),
save: (actions) => settings.videoQuickActions = actions,
);
}
}

View file

@ -0,0 +1,72 @@
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/viewer/entry_background.dart';
import 'package:aves/widgets/settings/viewer/viewer_actions_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ViewerSection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier;
const ViewerSection({
Key? key,
required this.expandedNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentShowOverlayMinimap = context.select<Settings, bool>((s) => s.showOverlayMinimap);
final currentShowOverlayInfo = context.select<Settings, bool>((s) => s.showOverlayInfo);
final currentShowOverlayShootingDetails = context.select<Settings, bool>((s) => s.showOverlayShootingDetails);
final currentRasterBackground = context.select<Settings, EntryBackground>((s) => s.rasterBackground);
final currentVectorBackground = context.select<Settings, EntryBackground>((s) => s.vectorBackground);
return AvesExpansionTile(
leading: SettingsTileLeading(
icon: AIcons.image,
color: stringToColor('Image'),
),
title: context.l10n.settingsSectionViewer,
expandedNotifier: expandedNotifier,
showHighlight: false,
children: [
ViewerActionsTile(),
SwitchListTile(
value: currentShowOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
),
SwitchListTile(
value: currentShowOverlayInfo,
onChanged: (v) => settings.showOverlayInfo = v,
title: Text(context.l10n.settingsViewerShowInformation),
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
),
SwitchListTile(
value: currentShowOverlayShootingDetails,
onChanged: currentShowOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
title: Text(context.l10n.settingsViewerShowShootingDetails),
),
ListTile(
title: Text(context.l10n.settingsRasterImageBackground),
trailing: EntryBackgroundSelector(
getter: () => currentRasterBackground,
setter: (value) => settings.rasterBackground = value,
),
),
ListTile(
title: Text(context.l10n.settingsVectorImageBackground),
trailing: EntryBackgroundSelector(
getter: () => currentVectorBackground,
setter: (value) => settings.vectorBackground = value,
),
),
],
);
}
}

View file

@ -1,10 +1,10 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/settings/quick_actions/editor_page.dart';
import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart';
import 'package:flutter/material.dart';
class QuickEntryActionsTile extends StatelessWidget {
class ViewerActionsTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
@ -13,8 +13,8 @@ class QuickEntryActionsTile extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: QuickEntryActionEditorPage.routeName),
builder: (context) => const QuickEntryActionEditorPage(),
settings: const RouteSettings(name: ViewerActionEditorPage.routeName),
builder: (context) => const ViewerActionEditorPage(),
),
);
},
@ -22,10 +22,10 @@ class QuickEntryActionsTile extends StatelessWidget {
}
}
class QuickEntryActionEditorPage extends StatelessWidget {
static const routeName = '/settings/quick_entry_actions';
class ViewerActionEditorPage extends StatelessWidget {
static const routeName = '/settings/viewer_actions';
const QuickEntryActionEditorPage({Key? key}) : super(key: key);
const ViewerActionEditorPage({Key? key}) : super(key: key);
static const allAvailableActions = [
EntryAction.info,
@ -44,6 +44,7 @@ class QuickEntryActionEditorPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QuickActionEditorPage<EntryAction>(
title: context.l10n.settingsViewerQuickActionEditorTitle,
bannerText: context.l10n.settingsViewerQuickActionEditorBanner,
allAvailableActions: allAvailableActions,
actionIcon: (action) => action.getIcon(),

View file

@ -1,5 +1,6 @@
import 'dart:convert';
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';
@ -105,14 +106,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
Future<void> _flip(BuildContext context, AvesEntry entry) async {
if (!await checkStoragePermission(context, {entry})) return;
final success = await entry.flip();
final success = await entry.flip(persist: isMainMode(context));
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
}
Future<void> _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async {
if (!await checkStoragePermission(context, {entry})) return;
final success = await entry.rotate(clockwise: clockwise);
final success = await entry.rotate(clockwise: clockwise, persist: isMainMode(context));
if (!success) showFeedback(context, context.l10n.genericFailureFeedback);
}
@ -187,6 +188,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selectionCount = selection.length;
showOpReport<ExportOpEvent>(
context: context,
// TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures)
opStream: imageFileService.export(
selection,
mimeType: MimeTypes.jpeg,
@ -196,16 +198,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
onDone: (processed) {
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length;
final showAction = collection != null && movedCount > 0
final _collection = collection;
final showAction = _collection != null && movedCount > 0
? SnackBarAction(
label: context.l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
final targetCollection = CollectionLens(
source: collection!.source,
source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
groupFactor: collection!.groupFactor,
sortFactor: collection!.sortFactor,
groupFactor: _collection.groupFactor,
sortFactor: _collection.sortFactor,
);
unawaited(Navigator.pushAndRemoveUntil(
context,
@ -255,7 +258,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkStoragePermission(context, {entry})) return;
final success = await context.read<CollectionSource>().renameEntry(entry, newName);
final success = await context.read<CollectionSource>().renameEntry(entry, newName, persist: isMainMode(context));
if (success) {
showFeedback(context, context.l10n.genericSuccessFeedback);
@ -264,6 +267,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
bool isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
void _goToSourceViewer(BuildContext context, AvesEntry entry) {
Navigator.push(
context,

View file

@ -26,6 +26,7 @@ import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
@ -60,7 +61,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
late Animation<double> _topOverlayScale, _bottomOverlayScale;
late Animation<Offset> _bottomOverlayOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late EntryActionDelegate _actionDelegate;
late EntryActionDelegate _entryActionDelegate;
late VideoActionDelegate _videoActionDelegate;
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true;
@ -108,10 +110,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
curve: Curves.easeOutQuad,
));
_overlayVisible.addListener(_onOverlayVisibleChange);
_actionDelegate = EntryActionDelegate(
_entryActionDelegate = EntryActionDelegate(
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_videoActionDelegate = VideoActionDelegate(
collection: collection,
);
_initEntryControllers();
_registerWidget(widget);
WidgetsBinding.instance!.addObserver(this);
@ -149,14 +154,14 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
_pauseVideoControllers();
break;
case AppLifecycleState.resumed:
availability.onResume();
break;
default:
break;
}
}
@ -243,7 +248,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
}
}
_actionDelegate.onActionSelected(context, targetEntry, action);
_entryActionDelegate.onActionSelected(context, targetEntry, action);
},
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
);
@ -290,6 +295,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
entry: pageEntry,
controller: videoController,
scale: _bottomOverlayScale,
onActionSelected: (action) {
if (videoController != null) {
_videoActionDelegate.onActionSelected(context, videoController, action);
}
},
),
);
} else if (pageEntry.is360) {
@ -414,7 +424,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page;
if (page == transitionPage) {
_actionDelegate.dismissFeedback(context);
_entryActionDelegate.dismissFeedback(context);
_popVisual();
} else if (page == infoPage) {
// prevent hero when viewer is offscreen

View file

@ -120,7 +120,7 @@ class MarkerPointerPainter extends CustomPainter {
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// generate bitmap from widget, for Google Maps

View file

@ -118,11 +118,15 @@ class ScaleBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedText(
text: distance,
style: const TextStyle(
color: fillColor,
fontSize: 11,
),
textSpans: [
TextSpan(
text: distance,
style: const TextStyle(
color: fillColor,
fontSize: 11,
),
)
],
outlineWidth: outlineWidth * 2,
outlineColor: outlineColor,
),

View file

@ -1,28 +1,36 @@
import 'dart:async';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class VideoControlOverlay extends StatefulWidget {
final AvesEntry entry;
final AvesVideoController? controller;
final Animation<double> scale;
final Function(VideoAction value) onActionSelected;
const VideoControlOverlay({
Key? key,
required this.entry,
required this.controller,
required this.scale,
required this.onActionSelected,
}) : super(key: key);
@override
@ -32,8 +40,6 @@ class VideoControlOverlay extends StatefulWidget {
class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTickerProviderStateMixin {
final GlobalKey _progressBarKey = GlobalKey(debugLabel: 'video-progress-bar');
bool _playingOnDragStart = false;
late AnimationController _playPauseAnimation;
final List<StreamSubscription> _subscriptions = [];
AvesEntry get entry => widget.entry;
@ -47,43 +53,8 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: Durations.iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant VideoControlOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(VideoControlOverlay widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(VideoControlOverlay widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
static const double outerPadding = 8;
static const double innerPadding = 8;
@override
Widget build(BuildContext context) {
@ -92,41 +63,51 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final status = controller?.status ?? VideoStatus.idle;
Widget child;
if (status == VideoStatus.error) {
child = Align(
alignment: AlignmentDirectional.centerEnd,
child: OverlayButton(
scale: scale,
child: IconButton(
icon: const Icon(AIcons.openOutside),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: context.l10n.viewerOpenTooltip,
),
),
);
} else {
child = Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width - mq.padding.horizontal,
builder: (c, mqWidth, child) {
final buttonWidth = OverlayButton.getSize(context);
final availableCount = ((mqWidth - outerPadding * 2) / (buttonWidth + innerPadding)).floor();
final quickActions = settings.videoQuickActions.take(availableCount - 1).toList();
final menuActions = VideoActions.all.where((action) => !quickActions.contains(action)).toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_ButtonRow(
quickActions: quickActions,
menuActions: menuActions,
scale: scale,
controller: controller,
onActionSelected: widget.onActionSelected,
),
const SizedBox(height: 8),
_buildProgressBar(),
],
);
},
);
}
return TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: status == VideoStatus.error
? [
OverlayButton(
scale: scale,
child: IconButton(
icon: const Icon(AIcons.openOutside),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: context.l10n.viewerOpenTooltip,
),
),
]
: [
Expanded(
child: _buildProgressBar(),
),
const SizedBox(width: 8),
OverlayButton(
scale: scale,
child: IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: _togglePlayPause,
tooltip: isPlaying ? context.l10n.viewerPauseTooltip : context.l10n.viewerPlayTooltip,
),
),
],
),
child: child,
);
});
}
@ -168,10 +149,16 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
builder: (context, snapshot) {
// do not use stream snapshot because it is obsolete when switching between videos
final position = controller?.currentPosition.floor() ?? 0;
return Text(formatFriendlyDuration(Duration(milliseconds: position)));
return Text(
formatFriendlyDuration(Duration(milliseconds: position)),
style: const TextStyle(shadows: Constants.embossShadows),
);
}),
const Spacer(),
Text(entry.durationText),
Text(
entry.durationText,
style: const TextStyle(shadows: Constants.embossShadows),
),
],
),
ClipRRect(
@ -196,27 +183,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
);
}
void _onStatusChange(VideoStatus status) {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
} else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) {
_playPauseAnimation.reverse();
}
}
Future<void> _togglePlayPause() async {
if (controller == null) return;
if (isPlaying) {
await controller!.pause();
} else {
await controller!.play();
// hide overlay
await Future.delayed(Durations.iconAnimation);
ToggleOverlayNotification().dispatch(context);
}
}
void _seekFromTap(Offset globalPosition) async {
if (controller == null) return;
final keyContext = _progressBarKey.currentContext!;
@ -225,3 +191,237 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
await controller!.seekToProgress(localPosition.dx / box.size.width);
}
}
class _ButtonRow extends StatelessWidget {
final List<VideoAction> quickActions, menuActions;
final Animation<double> scale;
final AvesVideoController? controller;
final Function(VideoAction value) onActionSelected;
const _ButtonRow({
Key? key,
required this.quickActions,
required this.menuActions,
required this.scale,
required this.controller,
required this.onActionSelected,
}) : super(key: key);
static const double padding = 8;
bool get isPlaying => controller?.isPlaying ?? false;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
...quickActions.map((action) => _buildOverlayButton(context, action)),
if (menuActions.isNotEmpty)
Padding(
padding: const EdgeInsetsDirectional.only(start: padding),
child: OverlayButton(
scale: scale,
child: PopupMenuButton<VideoAction>(
itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action));
},
),
),
),
],
);
}
Widget _buildOverlayButton(BuildContext context, VideoAction action) {
late Widget child;
void onPressed() => onActionSelected(action);
ValueListenableBuilder<bool> _buildFromListenable(ValueListenable<bool>? enabledNotifier) {
return ValueListenableBuilder<bool>(
valueListenable: enabledNotifier ?? ValueNotifier(false),
builder: (context, canDo, child) => IconButton(
icon: child!,
onPressed: canDo ? onPressed : null,
tooltip: action.getText(context),
),
child: Icon(action.getIcon()),
);
}
switch (action) {
case VideoAction.captureFrame:
child = _buildFromListenable(controller?.canCaptureFrameNotifier);
break;
case VideoAction.selectStreams:
child = _buildFromListenable(controller?.canSelectStreamNotifier);
break;
case VideoAction.setSpeed:
child = _buildFromListenable(controller?.canSetSpeedNotifier);
break;
case VideoAction.togglePlay:
child = _PlayToggler(
controller: controller,
onPressed: onPressed,
);
break;
case VideoAction.replay10:
case VideoAction.skip10:
case VideoAction.settings:
child = IconButton(
icon: Icon(action.getIcon()),
onPressed: onPressed,
tooltip: action.getText(context),
);
break;
}
return Padding(
padding: const EdgeInsetsDirectional.only(start: padding),
child: OverlayButton(
scale: scale,
child: child,
),
);
}
PopupMenuEntry<VideoAction> _buildPopupMenuItem(BuildContext context, VideoAction action) {
late final enabled;
switch (action) {
case VideoAction.captureFrame:
enabled = controller?.canCaptureFrameNotifier.value ?? false;
break;
case VideoAction.selectStreams:
enabled = controller?.canSelectStreamNotifier.value ?? false;
break;
case VideoAction.setSpeed:
enabled = controller?.canSetSpeedNotifier.value ?? false;
break;
case VideoAction.replay10:
case VideoAction.skip10:
case VideoAction.settings:
case VideoAction.togglePlay:
enabled = true;
break;
}
Widget? child;
switch (action) {
case VideoAction.togglePlay:
child = _PlayToggler(
controller: controller,
isMenuItem: true,
);
break;
case VideoAction.captureFrame:
case VideoAction.replay10:
case VideoAction.skip10:
case VideoAction.selectStreams:
case VideoAction.setSpeed:
case VideoAction.settings:
child = MenuRow(text: action.getText(context), icon: action.getIcon());
break;
}
return PopupMenuItem(
value: action,
enabled: enabled,
child: child,
);
}
}
class _PlayToggler extends StatefulWidget {
final AvesVideoController? controller;
final bool isMenuItem;
final VoidCallback? onPressed;
const _PlayToggler({
required this.controller,
this.isMenuItem = false,
this.onPressed,
});
@override
_PlayTogglerState createState() => _PlayTogglerState();
}
class _PlayTogglerState extends State<_PlayToggler> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
late AnimationController _playPauseAnimation;
AvesVideoController? get controller => widget.controller;
bool get isPlaying => controller?.isPlaying ?? false;
@override
void initState() {
super.initState();
_playPauseAnimation = AnimationController(
duration: Durations.iconAnimation,
vsync: this,
);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _PlayToggler oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_playPauseAnimation.dispose();
super.dispose();
}
void _registerWidget(_PlayToggler widget) {
final controller = widget.controller;
if (controller != null) {
_subscriptions.add(controller.statusStream.listen(_onStatusChange));
_onStatusChange(controller.status);
}
}
void _unregisterWidget(_PlayToggler widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
if (widget.isMenuItem) {
return isPlaying
? MenuRow(
text: context.l10n.videoActionPause,
icon: AIcons.pause,
)
: MenuRow(
text: context.l10n.videoActionPlay,
icon: AIcons.play,
);
}
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _playPauseAnimation,
),
onPressed: widget.onPressed,
tooltip: isPlaying ? context.l10n.videoActionPause : context.l10n.videoActionPlay,
);
}
void _onStatusChange(VideoStatus status) {
final status = _playPauseAnimation.status;
if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) {
_playPauseAnimation.forward();
} else if (!isPlaying && status != AnimationStatus.reverse && status != AnimationStatus.dismissed) {
_playPauseAnimation.reverse();
}
}
}

View file

@ -34,8 +34,8 @@ class OverlayButton extends StatelessWidget {
);
}
// icon (24) + icon padding (8) + button padding (16) + border (2)
static double getSize(BuildContext context) => 50.0;
// icon (24) + icon padding (8) + button padding (16) + border (1 or 2)
static double getSize(BuildContext context) => 48.0 + AvesBorder.borderWidth * 2;
}
class OverlayTextButton extends StatelessWidget {

View file

@ -117,5 +117,5 @@ class MinimapPainter extends CustomPainter {
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View file

@ -25,7 +25,8 @@ class ViewerTopOverlay extends StatelessWidget {
final bool canToggleFavourite;
final ValueNotifier<ViewState>? viewStateNotifier;
static const double padding = 8;
static const double outerPadding = 8;
static const double innerPadding = 8;
const ViewerTopOverlay({
Key? key,
@ -43,11 +44,12 @@ class ViewerTopOverlay extends StatelessWidget {
return SafeArea(
minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero),
child: Padding(
padding: const EdgeInsets.all(padding),
padding: const EdgeInsets.all(outerPadding),
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width - mq.padding.horizontal,
builder: (c, mqWidth, child) {
final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
final buttonWidth = OverlayButton.getSize(context);
final availableCount = ((mqWidth - outerPadding * 2 - buttonWidth) / (buttonWidth + innerPadding)).floor();
Widget? child;
if (mainEntry.isMultiPage) {
@ -108,7 +110,7 @@ class ViewerTopOverlay extends StatelessWidget {
}
}
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount - 1).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
final buttonRow = _TopOverlayRow(
@ -157,8 +159,6 @@ class _TopOverlayRow extends StatelessWidget {
required this.onActionSelected,
}) : super(key: key);
static const double padding = 8;
@override
Widget build(BuildContext context) {
return Row(
@ -228,7 +228,7 @@ class _TopOverlayRow extends StatelessWidget {
}
return child != null
? Padding(
padding: const EdgeInsetsDirectional.only(end: padding),
padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding),
child: OverlayButton(
scale: scale,
child: child,

View file

@ -2,18 +2,12 @@ import 'package:aves/model/entry.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video/fijkplayer.dart';
import 'package:collection/collection.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:fijkplayer/fijkplayer.dart';
class VideoConductor {
final List<AvesVideoController> _controllers = [];
static const maxControllerCount = 3;
VideoConductor() {
FijkLog.setLevel(FijkLogLevel.Warn);
}
Future<void> dispose() async {
await Future.forEach<AvesVideoController>(_controllers, (controller) => controller.dispose());
_controllers.clear();

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -37,8 +39,32 @@ abstract class AvesVideoController {
Stream<int> get positionStream;
Stream<String?> get timedTextStream;
ValueNotifier<bool> get canCaptureFrameNotifier;
ValueNotifier<bool> get canSetSpeedNotifier;
ValueNotifier<bool> get canSelectStreamNotifier;
ValueNotifier<double> get sarNotifier;
double get speed;
double get minSpeed;
double get maxSpeed;
set speed(double speed);
Future<void> selectStream(StreamType type, StreamSummary? selected);
Future<StreamSummary?> getSelectedStream(StreamType type);
List<StreamSummary> get streams;
Future<Uint8List> captureFrame();
Widget buildPlayerWidget(BuildContext context);
}
@ -50,3 +76,24 @@ enum VideoStatus {
completed,
error,
}
enum StreamType { video, audio, text }
class StreamSummary {
final StreamType type;
final int? index, width, height;
final String? codecName, language, title;
const StreamSummary({
required this.type,
required this.index,
required this.codecName,
required this.language,
required this.title,
required this.width,
required this.height,
});
@override
String toString() => '$runtimeType#${shortHash(this)}{type: type, index: $index, codecName: $codecName, language: $language, title: $title, width: $width, height: $height}';
}

View file

@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'package:aves/model/entry.dart';
@ -9,23 +11,38 @@ import 'package:aves/model/video/metadata.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:collection/collection.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class IjkPlayerAvesVideoController extends AvesVideoController {
static bool _staticInitialized = false;
late FijkPlayer _instance;
final List<StreamSubscription> _subscriptions = [];
final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
final AChangeNotifier _completedNotifier = AChangeNotifier();
Offset _macroBlockCrop = Offset.zero;
final List<StreamSummary> _streams = [];
final ValueNotifier<StreamSummary?> _selectedVideoStream = ValueNotifier(null);
final ValueNotifier<StreamSummary?> _selectedAudioStream = ValueNotifier(null);
final ValueNotifier<StreamSummary?> _selectedTextStream = ValueNotifier(null);
Timer? _initialPlayTimer;
double _speed = 1;
// audio/video get out of sync with speed < .5
// the video stream plays at .5 but the audio is slowed as requested
@override
final double minSpeed = .5;
// android.media.AudioTrack fails with speed > 2
@override
final double maxSpeed = 2;
@override
final ValueNotifier<bool> canCaptureFrameNotifier = ValueNotifier(false);
@override
final ValueNotifier<bool> canSetSpeedNotifier = ValueNotifier(false);
@override
final ValueNotifier<bool> canSelectStreamNotifier = ValueNotifier(false);
@override
final ValueNotifier<double> sarNotifier = ValueNotifier(1);
@ -36,7 +53,20 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
static const gifLikeVideoDurationThreshold = Duration(seconds: 10);
IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) {
if (!_staticInitialized) {
FijkLog.setLevel(FijkLogLevel.Warn);
_staticInitialized = true;
}
_instance = FijkPlayer();
_valueStream.firstWhere((value) => value.videoRenderStart).then(
(value) => canCaptureFrameNotifier.value = true,
onError: (error) {},
);
_valueStream.firstWhere((value) => value.audioRenderStart).then(
(value) => canSetSpeedNotifier.value = true,
onError: (error) {},
);
_startListening();
}
@ -67,6 +97,9 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// calling `setDataSource()` with `autoPlay` starts as soon as possible, but often yields initial artifacts
// so we introduce a small delay after the player is declared `prepared`, before playing
await _instance.setDataSourceUntilPrepared(entry.uri);
if (speed != 1) {
_applySpeed();
}
_initialPlayTimer = Timer(initialPlayDelay, play);
}
@ -74,6 +107,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// FFmpeg options
// cf https://github.com/Bilibili/ijkplayer/blob/master/ijkmedia/ijkplayer/ff_ffplay_options.h
// cf https://www.jianshu.com/p/843c86a9e9ad
// cf https://www.jianshu.com/p/3649c073b346
final options = FijkOption();
@ -83,13 +117,13 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
const accurateSeekEnabled = false;
// playing with HW acceleration seems to skip the last frames of some videos
// so HW acceleration is always disabled for gif-like videos where the last frames may be significant
// so HW acceleration is always disabled for GIF-like videos where the last frames may be significant
final hwAccelerationEnabled = settings.enableVideoHardwareAcceleration && entry.durationMillis! > gifLikeVideoDurationThreshold.inMilliseconds;
// TODO TLAD HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR)
// TODO TLAD [video] flaky: HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR)
if (hwAccelerationEnabled) {
// when HW acceleration is enabled, videos with dimensions that do not fit 16x macroblocks need cropping
// TODO TLAD not all formats/devices need this correction, e.g. 498x278 MP4 on S7, 408x244 WEBM on S10e do not
// TODO TLAD [video] flaky: not all formats/devices need this correction, e.g. 498x278 MP4 on S7, 408x244 WEBM on S10e do not
final s = entry.displaySize % 16 * -1 % 16;
_macroBlockCrop = Offset(s.width, s.height);
}
@ -100,38 +134,63 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
// in practice the flag seems ineffective, but harmless too
options.setFormatOption('fflags', 'fastseek');
// `enable-accurate-seek`: enable accurate seek, default: 0, in [0, 1]
options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0);
// `enable-snapshot`: enable snapshot interface
// default: 0, in [0, 1]
// cf https://fijkplayer.befovy.com/docs/zh/host-option.html
// there is a performance cost, and it should be set up before playing
options.setHostOption('enable-snapshot', 1);
// `accurate-seek-timeout`: accurate seek timeout, default: 5000 ms, in [0, 5000]
// `accurate-seek-timeout`: accurate seek timeout
// default: 5000 ms, in [0, 5000]
options.setPlayerOption('accurate-seek-timeout', 1000);
// `framedrop`: drop frames when cpu is too slow, default: 0, in [-1, 120]
options.setPlayerOption('framedrop', 5);
// `loop`: set number of times the playback shall be looped, default: 1, in [INT_MIN, INT_MAX]
options.setPlayerOption('loop', loopEnabled ? -1 : 1);
// `mediacodec-all-videos`: MediaCodec: enable all videos, default: 0, in [0, 1]
options.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0);
// `seek-at-start`: set offset of player should be seeked, default: 0, in [0, INT_MAX]
options.setPlayerOption('seek-at-start', startMillis);
// `cover-after-prepared`: show cover provided to `FijkView` when player is `prepared` without auto play, default: 0, in [0, 1]
// `cover-after-prepared`: show cover provided to `FijkView` when player is `prepared` without auto play
// default: 0, in [0, 1]
options.setPlayerOption('cover-after-prepared', 0);
// TODO TLAD try subs
// `subtitle`: decode subtitle stream, default: 0, in [0, 1]
// option.setPlayerOption('subtitle', 1);
// `enable-accurate-seek`: enable accurate seek
// default: 0, in [0, 1]
options.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0);
// `min-frames`: minimal frames to stop pre-reading
// default: 50000, in [2, 50000]
// a comment in `IjkMediaPlayer.java` recommends setting this to 25 when de/selecting streams
options.setPlayerOption('min-frames', 25);
// `framedrop`: drop frames when cpu is too slow
// default: 0, in [-1, 120]
options.setPlayerOption('framedrop', 5);
// `loop`: set number of times the playback shall be looped
// default: 1, in [INT_MIN, INT_MAX]
options.setPlayerOption('loop', loopEnabled ? -1 : 1);
// `mediacodec-all-videos`: MediaCodec: enable all videos
// default: 0, in [0, 1]
options.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0);
// `seek-at-start`: set offset of player should be seeked
// default: 0, in [0, INT_MAX]
options.setPlayerOption('seek-at-start', startMillis);
// `soundtouch`: enable SoundTouch
// default: 0, in [0, 1]
// slowed down videos with SoundTouch enabled have a weird wobbly audio
options.setPlayerOption('soundtouch', 0);
// `subtitle`: decode subtitle stream
// default: 0, in [0, 1]
options.setPlayerOption('subtitle', 1);
_instance.applyOptions(options);
}
void _fetchSelectedStreams() async {
void _fetchStreams() async {
final mediaInfo = await _instance.getInfo();
if (!mediaInfo.containsKey(Keys.streams)) return;
var videoStreamCount = 0, audioStreamCount = 0, textStreamCount = 0;
_streams.clear();
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
allStreams.forEach((stream) {
@ -140,29 +199,31 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
_streams.add(StreamSummary(
type: type,
index: stream[Keys.index],
codecName: stream[Keys.codecName],
language: stream[Keys.language],
title: stream[Keys.title],
width: stream[Keys.width] as int?,
height: stream[Keys.height] as int?,
));
switch (type) {
case StreamType.video:
videoStreamCount++;
break;
case StreamType.audio:
audioStreamCount++;
break;
case StreamType.text:
textStreamCount++;
break;
}
}
});
StreamSummary? _getSelectedStream(String selectedIndexKey) {
final indexString = mediaInfo[selectedIndexKey];
if (indexString != null) {
final index = int.tryParse(indexString);
if (index != null && index != -1) {
return _streams.firstWhereOrNull((stream) => stream.index == index);
}
}
return null;
}
canSelectStreamNotifier.value = videoStreamCount > 1 || audioStreamCount > 1 || textStreamCount > 0;
_selectedVideoStream.value = _getSelectedStream(Keys.selectedVideoStream);
_selectedAudioStream.value = _getSelectedStream(Keys.selectedAudioStream);
_selectedTextStream.value = _getSelectedStream(Keys.selectedTextStream);
if (_selectedVideoStream.value != null) {
final streamIndex = _selectedVideoStream.value!.index;
final selectedVideo = await getSelectedStream(StreamType.video);
if (selectedVideo != null) {
final streamIndex = selectedVideo.index;
final streamInfo = allStreams.firstWhereOrNull((stream) => stream[Keys.index] == streamIndex);
if (streamInfo != null) {
final num = streamInfo[Keys.sarNum] ?? 0;
@ -174,7 +235,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
void _onValueChanged() {
if (_instance.state == FijkState.prepared && _streams.isEmpty) {
_fetchSelectedStreams();
_fetchStreams();
}
_valueStreamController.add(_instance.value);
}
@ -198,6 +259,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override
Future<void> seekTo(int targetMillis) async {
targetMillis = max(0, targetMillis);
if (isReady) {
await _instance.seekTo(targetMillis);
} else {
@ -232,6 +294,60 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
@override
Stream<int> get positionStream => _instance.onCurrentPosUpdate.map((pos) => pos.inMilliseconds);
@override
Stream<String?> get timedTextStream => _instance.onTimedText;
@override
double get speed => _speed;
@override
set speed(double speed) {
if (speed <= 0 || _speed == speed) return;
_speed = speed;
_applySpeed();
}
// TODO TLAD [video] bug: setting speed fails when there is no audio stream or audio is disabled
void _applySpeed() => _instance.setSpeed(speed);
// When a stream is selected, the video accelerates to catch up with it.
// The duration of this acceleration phase depends on the player `min-frames` parameter.
// Calling `seekTo` after stream de/selection is a workaround to:
// 1) prevent video stream acceleration to catch up with audio
// 2) apply timed text stream
@override
Future<void> selectStream(StreamType type, StreamSummary? selected) async {
final current = await getSelectedStream(type);
if (current != selected) {
if (selected != null) {
final newIndex = selected.index;
if (newIndex != null) {
await _instance.selectTrack(newIndex);
}
} else if (current != null) {
await _instance.deselectTrack(current.index!);
}
await seekTo(currentPosition);
}
}
@override
Future<StreamSummary?> getSelectedStream(StreamType type) async {
final currentIndex = await _instance.getSelectedTrack(type.code);
return currentIndex != -1 ? _streams.firstWhereOrNull((v) => v.index == currentIndex) : null;
}
@override
List<StreamSummary> get streams => _streams;
@override
Future<Uint8List> captureFrame() {
if (!_instance.value.videoRenderStart) {
return Future.error('cannot capture frame when video is not rendered');
}
return _instance.takeSnapShot();
}
@override
Widget buildPlayerWidget(BuildContext context) {
return ValueListenableBuilder<double>(
@ -318,8 +434,6 @@ extension ExtraFijkPlayer on FijkPlayer {
}
}
enum StreamType { video, audio, text }
extension ExtraStreamType on StreamType {
static StreamType? fromTypeString(String? type) {
switch (type) {
@ -334,20 +448,20 @@ extension ExtraStreamType on StreamType {
return null;
}
}
}
class StreamSummary {
final StreamType type;
final int? index;
final String? language, title;
const StreamSummary({
required this.type,
required this.index,
required this.language,
required this.title,
});
@override
String toString() => '$runtimeType#${shortHash(this)}{type: type, index: $index, language: $language, title: $title}';
int get code {
// codes from ijkplayer ITrackInfo.java
switch (this) {
case StreamType.video:
return 1;
case StreamType.audio:
return 2;
case StreamType.text:
// TIMEDTEXT = 3, SUBTITLE = 4
return 3;
default:
// METADATA = 5, UNKNOWN = 0
return 0;
}
}
}

View file

@ -0,0 +1,179 @@
import 'dart:async';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/video_speed_dialog.dart';
import 'package:aves/widgets/dialogs/video_stream_selection_dialog.dart';
import 'package:aves/widgets/settings/video/video.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
import 'package:provider/provider.dart';
class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final CollectionLens? collection;
VideoActionDelegate({
required this.collection,
});
void onActionSelected(BuildContext context, AvesVideoController controller, VideoAction action) {
switch (action) {
case VideoAction.captureFrame:
_captureFrame(context, controller);
break;
case VideoAction.replay10:
if (controller.isReady) controller.seekTo(controller.currentPosition - 10000);
break;
case VideoAction.skip10:
if (controller.isReady) controller.seekTo(controller.currentPosition + 10000);
break;
case VideoAction.selectStreams:
_showStreamSelectionDialog(context, controller);
break;
case VideoAction.setSpeed:
_showSpeedDialog(context, controller);
break;
case VideoAction.settings:
_showSettings(context);
break;
case VideoAction.togglePlay:
_togglePlayPause(context, controller);
break;
}
}
Future<void> _captureFrame(BuildContext context, AvesVideoController controller) async {
final positionMillis = controller.currentPosition;
final bytes = await controller.captureFrame();
final destinationAlbum = androidFileUtils.videoCapturesPath;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpace(context, bytes.length, destinationAlbum)) return;
final entry = controller.entry;
final rotationDegrees = entry.rotationDegrees;
final dateTimeMillis = entry.catalogMetadata?.dateMillis;
final latLng = entry.latLng;
final exif = {
if (rotationDegrees != 0) 'rotationDegrees': rotationDegrees,
if (dateTimeMillis != null && dateTimeMillis != 0) 'dateTimeMillis': dateTimeMillis,
if (latLng != null) ...{
'latitude': latLng.latitude,
'longitude': latLng.longitude,
}
};
final newFields = await imageFileService.captureFrame(
entry,
desiredName: '${entry.bestTitle}_${'$positionMillis'.padLeft(8, '0')}',
exif: exif,
bytes: bytes,
destinationAlbum: destinationAlbum,
);
final success = newFields.isNotEmpty;
if (success) {
final _collection = collection;
final showAction = _collection != null
? SnackBarAction(
label: context.l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
final source = _collection.source;
final targetCollection = CollectionLens(
source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
groupFactor: _collection.groupFactor,
sortFactor: _collection.sortFactor,
);
unawaited(Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) {
return CollectionPage(
targetCollection,
);
},
),
(route) => false,
));
await Future.delayed(Durations.staggeredAnimationPageTarget + Durations.highlightScrollInitDelay);
final newUri = newFields['uri'] as String?;
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => entry.uri == newUri);
if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
}
},
)
: null;
showFeedback(context, context.l10n.genericSuccessFeedback, showAction);
} else {
showFeedback(context, context.l10n.genericFailureFeedback);
}
}
Future<void> _showStreamSelectionDialog(BuildContext context, AvesVideoController controller) async {
final streams = controller.streams;
final currentSelectedStreams = await Future.wait(StreamType.values.map(controller.getSelectedStream));
final currentSelectedIndices = currentSelectedStreams.where((v) => v != null).cast<StreamSummary>().map((v) => v.index).toSet();
final userSelectedStreams = await showDialog<Map<StreamType, StreamSummary>>(
context: context,
builder: (context) => VideoStreamSelectionDialog(
streams: Map.fromEntries(streams.map((stream) => MapEntry(stream, currentSelectedIndices.contains(stream.index)))),
),
);
if (userSelectedStreams == null || userSelectedStreams.isEmpty) return;
await Future.forEach<MapEntry<StreamType, StreamSummary>>(
userSelectedStreams.entries,
(kv) => controller.selectStream(kv.key, kv.value),
);
}
Future<void> _showSpeedDialog(BuildContext context, AvesVideoController controller) async {
final newSpeed = await showDialog<double>(
context: context,
builder: (context) => VideoSpeedDialog(
current: controller.speed,
min: controller.minSpeed,
max: controller.maxSpeed,
),
);
if (newSpeed == null) return;
controller.speed = newSpeed;
}
void _showSettings(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: VideoSettingsPage.routeName),
builder: (context) => const VideoSettingsPage(),
),
);
}
Future<void> _togglePlayPause(BuildContext context, AvesVideoController controller) async {
if (controller.isPlaying) {
await controller.pause();
} else {
await controller.play();
}
}
}

View file

@ -20,6 +20,7 @@ import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/visual/error.dart';
import 'package:aves/widgets/viewer/visual/raster.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart';
import 'package:aves/widgets/viewer/visual/vector.dart';
import 'package:aves/widgets/viewer/visual/video.dart';
import 'package:flutter/foundation.dart';
@ -71,8 +72,8 @@ class _EntryPageViewState extends State<EntryPageView> {
void didUpdateWidget(covariant EntryPageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) {
// do not reset the magnifier view state unless page dimensions change,
if (oldWidget.pageEntry.uri != widget.pageEntry.uri || oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) {
// do not reset the magnifier view state unless main entry or page entry dimensions change,
// in effect locking the zoom & position when browsing entry pages of the same size
_unregisterWidget();
_registerWidget();
@ -196,12 +197,26 @@ class _EntryPageViewState extends State<EntryPageView> {
ValueListenableBuilder<double>(
valueListenable: videoController.sarNotifier,
builder: (context, sar, child) {
return _buildMagnifier(
displaySize: entry.videoDisplaySize(sar),
child: VideoView(
entry: entry,
controller: videoController,
),
return Stack(
children: [
_buildMagnifier(
displaySize: entry.videoDisplaySize(sar),
child: VideoView(
entry: entry,
controller: videoController,
),
),
VideoSubtitles(
controller: videoController,
viewStateNotifier: _viewStateNotifier,
),
if (settings.videoShowRawTimedText)
VideoSubtitles(
controller: videoController,
viewStateNotifier: _viewStateNotifier,
debugMode: true,
),
],
);
}),
// fade out image to ease transition with the player

View file

@ -0,0 +1,596 @@
import 'package:aves/widgets/viewer/visual/subtitle/line.dart';
import 'package:aves/widgets/viewer/visual/subtitle/span.dart';
import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
import 'package:flutter/material.dart';
class AssParser {
// the optional `*` before the tags seems to be used for inner overrides
// but specs for its usage are yet to be found
static final overridePattern = RegExp(r'{\*?(.*?)}');
static final tagPattern = RegExp(r'('
r'1a|2a|3a|4a'
r'|1c|2c|3c|4c'
r'|alpha|an|a'
r'|be|blur|bord|b'
r'|clip|c'
r'|fade|fad|fax|fay|fe|fn'
r'|frx|fry|frz|fr|fscx|fscy|fsp|fs'
r'|iclip|i'
r'|kf|ko|k|K'
r'|move|org'
r'|pbo|pos|p'
r'|q|r'
r'|shad|s'
r'|t|u'
r'|xbord|xshad|ybord|yshad'
r')');
// &H<aa>
static final alphaPattern = RegExp('&H(..)');
// &H<bb><gg><rr>&
static final colorPattern = RegExp('&H(..)(..)(..)&');
// (<X>,<Y>)
static final multiParamPattern = RegExp('\\((.*)\\)');
// e.g. m 937.5 472.67 b 937.5 472.67 937.25 501.25 960 501.5 960 501.5 937.5 500.33 937.5 529.83
static final pathPattern = RegExp(r'([mnlbspc])([.\s\d]+)');
static const noBreakSpace = '\u00A0';
// Parse text with ASS style overrides
// cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/
// e.g. `And I'm like, "We can't {\i1}not{\i0} see it."`
// e.g. `{\fad(200,200)\blur3}lorem ipsum"`
// e.g. `{\fnCrapFLTSB\an9\bord5\fs70\c&H403A2D&\3c&HE5E5E8&\pos(1868.286,27.429)}lorem ipsum"`
static StyledSubtitleLine parse(String text, TextStyle baseStyle, double scale) {
final spans = <StyledSubtitleSpan>[];
var line = StyledSubtitleLine(spans: spans);
var extraStyle = const SubtitleStyle();
var textStyle = baseStyle;
var i = 0;
final overrideMatches = overridePattern.allMatches(text);
overrideMatches.forEach((overrideMatch) {
if (i != overrideMatch.start) {
final spanText = extraStyle.drawingPaths?.isNotEmpty == true ? null : _replaceChars(text.substring(i, overrideMatch.start));
spans.add(StyledSubtitleSpan(
textSpan: TextSpan(
text: spanText,
style: textStyle,
),
extraStyle: extraStyle,
));
}
i = overrideMatch.end;
final tags = overrideMatch.group(1);
tags?.split('\\').where((v) => v.isNotEmpty).forEach((tagWithParam) {
final tag = tagPattern.firstMatch(tagWithParam)?.group(1);
if (tag != null) {
final param = tagWithParam.substring(tag.length);
switch (tag) {
case 'alpha':
{
// \alpha: alpha of all components at once
final a = _parseAlpha(param);
if (a != null) {
textStyle = textStyle.copyWith(
color: textStyle.color?.withAlpha(a),
shadows: textStyle.shadows
?.map((v) => Shadow(
color: v.color.withAlpha(a),
offset: v.offset,
blurRadius: v.blurRadius,
))
.toList());
extraStyle = extraStyle.copyWith(
borderColor: extraStyle.borderColor?.withAlpha(a),
);
}
break;
}
case '1a':
{
// \1a: fill alpha
final a = _parseAlpha(param);
if (a != null) textStyle = textStyle.copyWith(color: textStyle.color?.withAlpha(a));
break;
}
case '3a':
{
// \3a: border alpha
final a = _parseAlpha(param);
if (a != null) extraStyle = extraStyle.copyWith(borderColor: extraStyle.borderColor?.withAlpha(a));
break;
}
case '4a':
{
// \4a: shadow alpha
final a = _parseAlpha(param);
if (a != null) {
textStyle = textStyle.copyWith(
shadows: textStyle.shadows
?.map((v) => Shadow(
color: v.color.withAlpha(a),
offset: v.offset,
blurRadius: v.blurRadius,
))
.toList());
}
break;
}
case 'a':
// \a: line alignment (legacy)
extraStyle = _copyWithAlignment(_parseLegacyAlignment(param), extraStyle);
break;
case 'an':
// \an: line alignment
extraStyle = _copyWithAlignment(_parseNewAlignment(param), extraStyle);
break;
case 'b':
{
// \b: bold
final weight = _parseFontWeight(param);
if (weight != null) textStyle = textStyle.copyWith(fontWeight: weight);
break;
}
case 'be':
{
// \be: blurs the edges of the text
final times = int.tryParse(param);
if (times != null) extraStyle = extraStyle.copyWith(edgeBlur: times == 0 ? 0 : 1);
break;
}
case 'blur':
{
// \blur: blurs the edges of the text (Gaussian kernel)
final strength = double.tryParse(param);
if (strength != null) extraStyle = extraStyle.copyWith(edgeBlur: strength / 2);
break;
}
case 'bord':
{
// \bord: border width
final size = double.tryParse(param);
if (size != null) extraStyle = extraStyle.copyWith(borderWidth: size);
break;
}
case 'c':
case '1c':
{
// \c or \1c: fill color
final color = _parseColor(param);
if (color != null) {
textStyle = textStyle.copyWith(color: color.withAlpha(textStyle.color?.alpha ?? 0xFF));
}
break;
}
case '3c':
{
// \3c: border color
final color = _parseColor(param);
if (color != null) {
extraStyle = extraStyle.copyWith(
borderColor: color.withAlpha(extraStyle.borderColor?.alpha ?? 0xFF),
);
}
break;
}
case '4c':
{
// \4c: shadow color
final color = _parseColor(param);
if (color != null) {
textStyle = textStyle.copyWith(
shadows: textStyle.shadows
?.map((v) => Shadow(
color: color.withAlpha(v.color.alpha),
offset: v.offset,
blurRadius: v.blurRadius,
))
.toList());
}
break;
}
case 'clip':
// \clip: clip (within rectangle or path)
line = line.copyWith(clip: _parseClip(param));
break;
case 'fax':
{
final factor = double.tryParse(param);
// ignore subsequent shearing when line is positioned
if (factor != null && (line.position == null || extraStyle.shearX == null)) {
extraStyle = extraStyle.copyWith(shearX: factor);
}
break;
}
case 'fay':
{
final factor = double.tryParse(param);
// ignore subsequent shearing when line is positioned
if (factor != null && (line.position == null || extraStyle.shearY == null)) {
extraStyle = extraStyle.copyWith(shearY: factor);
}
break;
}
case 'fn':
{
final name = param;
// TODO TLAD [subtitles] extract fonts from attachment streams, and load these fonts in Flutter
if (name.isNotEmpty) textStyle = textStyle.copyWith(fontFamily: name);
break;
}
case 'frx':
{
// \frx: text rotation (X axis)
final amount = double.tryParse(param);
if (amount != null) extraStyle = extraStyle.copyWith(rotationX: amount);
break;
}
case 'fry':
{
// \fry: text rotation (Y axis)
final amount = double.tryParse(param);
if (amount != null) extraStyle = extraStyle.copyWith(rotationY: amount);
break;
}
case 'fr':
case 'frz':
{
// \frz: text rotation (Z axis)
final amount = double.tryParse(param);
if (amount != null) extraStyle = extraStyle.copyWith(rotationZ: amount);
break;
}
case 'fs':
{
// \fs: font size
final size = int.tryParse(param);
if (size != null) textStyle = textStyle.copyWith(fontSize: size * scale);
break;
}
case 'fscx':
{
// \fscx: font scale (horizontal)
final scale = int.tryParse(param);
if (scale != null) extraStyle = extraStyle.copyWith(scaleX: scale.toDouble() / 100);
break;
}
case 'fscy':
{
// \fscx: font scale (vertical)
final scale = int.tryParse(param);
if (scale != null) extraStyle = extraStyle.copyWith(scaleY: scale.toDouble() / 100);
break;
}
case 'fsp':
{
// \fsp: letter spacing
final spacing = double.tryParse(param);
textStyle = textStyle.copyWith(letterSpacing: spacing);
break;
}
case 'i':
// \i: italics
textStyle = textStyle.copyWith(fontStyle: param == '1' ? FontStyle.italic : FontStyle.normal);
break;
case 'p':
{
// \p drawing paths
final scale = int.tryParse(param);
if (scale != null) {
if (scale > 0) {
final start = overrideMatch.end;
final end = text.indexOf('{', start);
final commands = text.substring(start, end == -1 ? null : end);
extraStyle = extraStyle.copyWith(drawingPaths: _parsePaths(commands, scale));
} else {
extraStyle = extraStyle.copyWith(drawingPaths: null);
}
}
break;
}
case 'pos':
{
// \pos: line position
final match = multiParamPattern.firstMatch(param);
if (match != null) {
final g = match.group(1);
if (g != null) {
final params = g.split(',');
if (params.length == 2) {
final x = double.tryParse(params[0]);
final y = double.tryParse(params[1]);
if (x != null && y != null) {
line = line.copyWith(position: Offset(x, y));
}
}
}
}
break;
}
case 'r':
// \r: reset
textStyle = baseStyle;
break;
case 's':
// \s: strikeout
textStyle = textStyle.copyWith(decoration: param == '1' ? TextDecoration.lineThrough : TextDecoration.none);
break;
case 'u':
// \u: underline
textStyle = textStyle.copyWith(decoration: param == '1' ? TextDecoration.underline : TextDecoration.none);
break;
// TODO TLAD [subtitles] SHOULD support the following
case 'shad':
case 't': // \t: animated transform
case 'xshad':
case 'yshad':
// line props: \pos, \move, \clip, \iclip, \org, \fade and \fad
case 'iclip': // \iclip: clip (inverse)
case 'fad': // \fad: fade
case 'fade': // \fade: fade (complex)
case 'move': // \move: movement
case 'org': // \org: rotation origin
// TODO TLAD [subtitles] MAY support the following
case 'fe': // \fe: font encoding
case 'pbo': // \pbo: baseline offset
case 'q': // \q: wrap style
// border size
case 'xbord':
case 'ybord':
// karaoke
case '2a':
case '2c':
case 'k':
case 'K':
case 'kf':
case 'ko':
default:
debugPrint('unhandled ASS tag=$tag');
}
}
});
});
if (i != text.length) {
final spanText = extraStyle.drawingPaths?.isNotEmpty == true ? null : _replaceChars(text.substring(i));
spans.add(StyledSubtitleSpan(
textSpan: TextSpan(
text: spanText,
style: textStyle,
),
extraStyle: extraStyle,
));
}
return line;
}
static SubtitleStyle _copyWithAlignment(Alignment? alignment, SubtitleStyle extraStyle) {
if (alignment == null) return extraStyle;
var hAlign = TextAlign.center;
var vAlign = TextAlignVertical.bottom;
if (alignment.x < 0) {
hAlign = TextAlign.left;
} else if (alignment.x > 0) {
hAlign = TextAlign.right;
}
if (alignment.y < 0) {
vAlign = TextAlignVertical.top;
} else if (alignment.y == 0) {
vAlign = TextAlignVertical.center;
}
return extraStyle.copyWith(
hAlign: hAlign,
vAlign: vAlign,
);
}
static String _replaceChars(String text) => text.replaceAll(r'\h', noBreakSpace).replaceAll(r'\N', '\n');
static int? _parseAlpha(String param) {
final match = alphaPattern.firstMatch(param);
if (match != null) {
final as = match.group(1);
final ai = int.tryParse('$as', radix: 16);
if (ai != null) {
return 0xFF - ai;
}
}
return null;
}
static Color? _parseColor(String param) {
final match = colorPattern.firstMatch(param);
if (match != null) {
final bs = match.group(1);
final gs = match.group(2);
final rs = match.group(3);
final rgb = int.tryParse('ff$rs$gs$bs', radix: 16);
if (rgb != null) {
return Color(rgb);
}
}
return null;
}
static FontWeight? _parseFontWeight(String param) {
switch (int.tryParse(param)) {
case 0:
return FontWeight.normal;
case 1:
return FontWeight.bold;
case 100:
return FontWeight.w100;
case 200:
return FontWeight.w200;
case 300:
return FontWeight.w300;
case 400:
return FontWeight.w400;
case 500:
return FontWeight.w500;
case 600:
return FontWeight.w600;
case 700:
return FontWeight.w700;
case 800:
return FontWeight.w800;
case 900:
return FontWeight.w900;
default:
return null;
}
}
static Alignment? _parseNewAlignment(String param) {
switch (int.tryParse(param)) {
case 1:
return Alignment.bottomLeft;
case 2:
return Alignment.bottomCenter;
case 3:
return Alignment.bottomRight;
case 4:
return Alignment.centerLeft;
case 5:
return Alignment.center;
case 6:
return Alignment.centerRight;
case 7:
return Alignment.topLeft;
case 8:
return Alignment.topCenter;
case 9:
return Alignment.topRight;
default:
return null;
}
}
static Alignment? _parseLegacyAlignment(String param) {
switch (int.tryParse(param)) {
case 1:
return Alignment.bottomLeft;
case 2:
return Alignment.bottomCenter;
case 3:
return Alignment.bottomRight;
case 5:
return Alignment.topLeft;
case 6:
return Alignment.topCenter;
case 7:
return Alignment.topRight;
case 9:
return Alignment.centerLeft;
case 10:
return Alignment.center;
case 11:
return Alignment.centerRight;
default:
return null;
}
}
static List<Path> _parsePaths(String commands, int scale) {
final paths = <Path>[];
Path? path;
pathPattern.allMatches(commands).forEach((match) {
if (match.groupCount == 2) {
final command = match.group(1)!;
final params = match.group(2)!.trim().split(' ').map(double.tryParse).where((v) => v != null).cast<double>().map((v) => v / scale).toList();
switch (command) {
case 'b':
if (path != null) {
const bParamCount = 6;
final steps = (params.length / bParamCount).floor();
for (var i = 0; i < steps; i++) {
final points = params.skip(i * bParamCount).take(bParamCount).toList();
path!.cubicTo(points[0], points[1], points[2], points[3], points[4], points[5]);
}
}
break;
case 'c':
if (path != null) {
path!.close();
}
path = null;
break;
case 'l':
if (path != null) {
const lParamCount = 2;
final steps = (params.length / lParamCount).floor();
for (var i = 0; i < steps; i++) {
final points = params.skip(i * lParamCount).take(lParamCount).toList();
path!.lineTo(points[0], points[1]);
}
}
break;
case 'm':
if (params.length == 2) {
if (path != null) {
path!.close();
}
path = Path();
paths.add(path!);
path!.moveTo(params[0], params[1]);
}
break;
case 'n':
if (params.length == 2 && path != null) {
path!.moveTo(params[0], params[1]);
}
break;
case 's':
case 'p':
debugPrint('unhandled ASS drawing command=$command');
break;
}
}
});
if (path != null) {
path!.close();
}
return paths;
}
static List<Path>? _parseClip(String param) {
List<Path>? paths;
final match = multiParamPattern.firstMatch(param);
if (match != null) {
final g = match.group(1);
if (g != null) {
final params = g.split(',');
if (params.length == 4) {
final points = params.map(double.tryParse).where((v) => v != null).cast<double>().toList();
if (points.length == 4) {
paths = [
Path()
..addRect(Rect.fromPoints(
Offset(points[0], points[1]),
Offset(points[2], points[3]),
))
];
}
} else {
int? scale;
String? commands;
if (params.length == 1) {
scale = 1;
commands = params[0];
} else if (params.length == 2) {
scale = int.tryParse(params[0]);
commands = params[1];
}
if (scale != null && commands != null) {
paths = _parsePaths(commands, scale);
}
}
}
}
return paths;
}
}

View file

@ -0,0 +1,49 @@
import 'package:aves/widgets/viewer/visual/subtitle/span.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class StyledSubtitleLine with Diagnosticable {
final List<StyledSubtitleSpan> spans;
final List<Path>? clip;
final Offset? position;
const StyledSubtitleLine({
required this.spans,
this.clip,
this.position,
});
StyledSubtitleLine copyWith({
List<StyledSubtitleSpan>? spans,
List<Path>? clip,
Offset? position,
}) {
return StyledSubtitleLine(
spans: spans ?? this.spans,
clip: clip ?? this.clip,
position: position ?? this.position,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<List<StyledSubtitleSpan>>('spans', spans));
properties.add(DiagnosticsProperty<List<Path>>('clip', clip));
properties.add(DiagnosticsProperty<Offset>('position', position));
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is StyledSubtitleLine && other.spans == spans && other.clip == clip && other.position == position;
}
@override
int get hashCode => hashValues(
spans,
clip,
position,
);
}

View file

@ -0,0 +1,43 @@
import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class StyledSubtitleSpan with Diagnosticable {
final TextSpan textSpan;
final SubtitleStyle extraStyle;
const StyledSubtitleSpan({
required this.textSpan,
required this.extraStyle,
});
StyledSubtitleSpan copyWith({
TextSpan? textSpan,
SubtitleStyle? extraStyle,
}) {
return StyledSubtitleSpan(
textSpan: textSpan ?? this.textSpan,
extraStyle: extraStyle ?? this.extraStyle,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextSpan>('textSpan', textSpan));
properties.add(DiagnosticsProperty<SubtitleStyle>('extraStyle', extraStyle));
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is StyledSubtitleSpan && other.textSpan == textSpan && other.extraStyle == extraStyle;
}
@override
int get hashCode => hashValues(
textSpan,
extraStyle,
);
}

View file

@ -0,0 +1,106 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable
class SubtitleStyle with Diagnosticable {
final TextAlign? hAlign;
final TextAlignVertical? vAlign;
final Color? borderColor;
final double? borderWidth, edgeBlur, rotationX, rotationY, rotationZ, scaleX, scaleY, shearX, shearY;
final List<Path>? drawingPaths;
bool get rotating => (rotationX ?? 0) != 0 || (rotationY ?? 0) != 0 || (rotationZ ?? 0) != 0;
bool get scaling => (scaleX ?? 1) != 1 || (scaleY ?? 1) != 1;
bool get shearing => (shearX ?? 0) != 0 || (shearY ?? 0) != 0;
const SubtitleStyle({
this.hAlign,
this.vAlign,
this.borderColor,
this.borderWidth,
this.edgeBlur,
this.rotationX,
this.rotationY,
this.rotationZ,
this.scaleX,
this.scaleY,
this.shearX,
this.shearY,
this.drawingPaths,
});
SubtitleStyle copyWith({
TextAlign? hAlign,
TextAlignVertical? vAlign,
Color? borderColor,
double? borderWidth,
double? edgeBlur,
double? rotationX,
double? rotationY,
double? rotationZ,
double? scaleX,
double? scaleY,
double? shearX,
double? shearY,
List<Path>? drawingPaths,
}) {
return SubtitleStyle(
hAlign: hAlign ?? this.hAlign,
vAlign: vAlign ?? this.vAlign,
borderColor: borderColor ?? this.borderColor,
borderWidth: borderWidth ?? this.borderWidth,
edgeBlur: edgeBlur ?? this.edgeBlur,
rotationX: rotationX ?? this.rotationX,
rotationY: rotationY ?? this.rotationY,
rotationZ: rotationZ ?? this.rotationZ,
scaleX: scaleX ?? this.scaleX,
scaleY: scaleY ?? this.scaleY,
shearX: shearX ?? this.shearX,
shearY: shearY ?? this.shearY,
drawingPaths: drawingPaths ?? this.drawingPaths,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<TextAlign>('hAlign', hAlign));
properties.add(DiagnosticsProperty<TextAlignVertical>('vAlign', vAlign));
properties.add(ColorProperty('borderColor', borderColor));
properties.add(DoubleProperty('borderWidth', borderWidth));
properties.add(DoubleProperty('edgeBlur', edgeBlur));
properties.add(DoubleProperty('rotationX', rotationX));
properties.add(DoubleProperty('rotationY', rotationY));
properties.add(DoubleProperty('rotationZ', rotationZ));
properties.add(DoubleProperty('scaleX', scaleX));
properties.add(DoubleProperty('scaleY', scaleY));
properties.add(DoubleProperty('shearX', shearX));
properties.add(DoubleProperty('shearY', shearY));
properties.add(DiagnosticsProperty<List<Path>>('drawingPaths', drawingPaths));
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is SubtitleStyle && other.hAlign == hAlign && other.vAlign == vAlign && other.borderColor == borderColor && other.borderWidth == borderWidth && other.edgeBlur == edgeBlur && other.rotationX == rotationX && other.rotationY == rotationY && other.rotationZ == rotationZ && other.scaleX == scaleX && other.scaleY == scaleY && other.shearX == shearX && other.shearY == shearY && other.drawingPaths == drawingPaths;
}
@override
int get hashCode => hashValues(
hAlign,
vAlign,
borderColor,
borderWidth,
edgeBlur,
rotationX,
rotationY,
rotationZ,
scaleX,
scaleY,
shearX,
shearY,
drawingPaths?.length,
);
}

View file

@ -0,0 +1,335 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:aves/widgets/viewer/visual/subtitle/ass_parser.dart';
import 'package:aves/widgets/viewer/visual/subtitle/span.dart';
import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class VideoSubtitles extends StatelessWidget {
final AvesVideoController controller;
final ValueNotifier<ViewState> viewStateNotifier;
final bool debugMode;
static const baseShadowOffset = Offset(1, 1);
const VideoSubtitles({
Key? key,
required this.controller,
required this.viewStateNotifier,
this.debugMode = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final videoDisplaySize = controller.entry.videoDisplaySize(controller.sarNotifier.value);
return IgnorePointer(
child: Consumer<Settings>(
builder: (context, settings, child) {
final baseTextAlign = settings.subtitleTextAlignment;
final baseOutlineWidth = settings.subtitleShowOutline ? 1 : 0;
final baseOutlineColor = Colors.black.withOpacity(settings.subtitleTextColor.opacity);
final baseShadows = [
Shadow(
color: baseOutlineColor,
offset: baseShadowOffset,
),
];
final baseStyle = TextStyle(
color: settings.subtitleTextColor,
backgroundColor: settings.subtitleBackgroundColor,
fontSize: settings.subtitleFontSize,
shadows: settings.subtitleShowOutline ? baseShadows : null,
);
return Selector<MediaQueryData, Orientation>(
selector: (c, mq) => mq.orientation,
builder: (c, orientation, child) {
final bottom = orientation == Orientation.portrait ? .5 : .8;
final viewportSize = context.read<MediaQueryData>().size;
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewPosition = viewState.position;
final viewScale = viewState.scale ?? 1;
final viewSize = videoDisplaySize * viewScale;
final viewOffset = Offset(
(viewportSize.width - viewSize.width) / 2,
(viewportSize.height - viewSize.height) / 2,
);
return StreamBuilder<String?>(
stream: controller.timedTextStream,
builder: (context, snapshot) {
final text = snapshot.data;
if (text == null) return const SizedBox();
if (debugMode) {
return Padding(
padding: const EdgeInsets.only(top: 100.0),
child: Align(
alignment: Alignment.topLeft,
child: OutlinedText(
textSpans: [
TextSpan(
text: text,
style: const TextStyle(fontSize: 14),
)
],
outlineWidth: 1,
outlineColor: Colors.black,
),
),
);
}
final styledLine = AssParser.parse(text, baseStyle, viewScale);
final position = styledLine.position;
final clip = styledLine.clip;
final styledSpans = styledLine.spans;
final byExtraStyle = groupBy<StyledSubtitleSpan, SubtitleStyle>(styledSpans, (v) => v.extraStyle);
return Stack(
children: byExtraStyle.entries.map((kv) {
final extraStyle = kv.key;
final spans = kv.value.map((v) {
final span = v.textSpan;
final style = span.style;
if (position == null || style == null) return span;
final letterSpacing = style.letterSpacing;
final shadows = style.shadows;
return TextSpan(
text: span.text,
style: style.copyWith(
letterSpacing: letterSpacing != null ? letterSpacing * viewScale : null,
shadows: shadows != null
? shadows
.map((v) => Shadow(
color: v.color,
offset: v.offset * viewScale,
blurRadius: v.blurRadius * viewScale,
))
.toList()
: null,
),
);
}).toList();
final drawingPaths = extraStyle.drawingPaths;
final textAlign = extraStyle.hAlign ?? (position != null ? TextAlign.center : baseTextAlign);
Widget child;
if (drawingPaths != null) {
child = CustomPaint(
painter: SubtitlePathPainter(
paths: drawingPaths,
scale: viewScale,
fillColor: spans.firstOrNull?.style?.color ?? Colors.white,
strokeColor: extraStyle.borderColor,
),
);
} else {
final outlineWidth = extraStyle.borderWidth ?? (extraStyle.edgeBlur != null ? 2 : 1);
child = OutlinedText(
textSpans: spans,
outlineWidth: outlineWidth * (position != null ? viewScale : baseOutlineWidth),
outlineColor: extraStyle.borderColor ?? baseOutlineColor,
outlineBlurSigma: extraStyle.edgeBlur ?? 0,
textAlign: textAlign,
);
}
var transform = Matrix4.identity();
if (position != null) {
final para = RenderParagraph(
TextSpan(children: spans),
textDirection: TextDirection.ltr,
textScaleFactor: context.read<MediaQueryData>().textScaleFactor,
)..layout(const BoxConstraints());
final textWidth = para.getMaxIntrinsicWidth(double.infinity);
final textHeight = para.getMaxIntrinsicHeight(double.infinity);
late double anchorOffsetX, anchorOffsetY;
switch (textAlign) {
case TextAlign.left:
anchorOffsetX = 0;
break;
case TextAlign.right:
anchorOffsetX = -textWidth;
break;
case TextAlign.center:
default:
anchorOffsetX = -textWidth / 2;
break;
}
switch (extraStyle.vAlign ?? TextAlignVertical.bottom) {
case TextAlignVertical.top:
anchorOffsetY = 0;
break;
case TextAlignVertical.center:
anchorOffsetY = -textHeight / 2;
break;
case TextAlignVertical.bottom:
anchorOffsetY = -textHeight;
break;
}
final alignOffset = Offset(anchorOffsetX, anchorOffsetY);
final lineOffset = position * viewScale + viewPosition;
final translateOffset = viewOffset + lineOffset + alignOffset;
transform.translate(translateOffset.dx, translateOffset.dy);
}
if (extraStyle.rotating) {
// for perspective
transform.setEntry(3, 2, 0.001);
final x = -toRadians(extraStyle.rotationX ?? 0);
final y = -toRadians(extraStyle.rotationY ?? 0);
final z = -toRadians(extraStyle.rotationZ ?? 0);
if (x != 0) transform.rotateX(x);
if (y != 0) transform.rotateY(y);
if (z != 0) transform.rotateZ(z);
}
if (extraStyle.scaling) {
final x = extraStyle.scaleX ?? 1;
final y = extraStyle.scaleY ?? 1;
transform.scale(x, y);
}
if (extraStyle.shearing) {
final x = extraStyle.shearX ?? 0;
final y = extraStyle.shearY ?? 0;
transform.multiply(Matrix4(1, y, 0, 0, x, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1));
}
if (!transform.isIdentity()) {
child = Transform(
transform: transform,
alignment: Alignment.center,
child: child,
);
}
if (position == null) {
late double alignX;
switch (textAlign) {
case TextAlign.left:
alignX = -1;
break;
case TextAlign.right:
alignX = 1;
break;
case TextAlign.center:
default:
alignX = 0;
break;
}
late double alignY;
switch (extraStyle.vAlign) {
case TextAlignVertical.top:
alignY = -bottom;
break;
case TextAlignVertical.center:
alignY = 0;
break;
case TextAlignVertical.bottom:
default:
alignY = bottom;
break;
}
child = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: Alignment(alignX, alignY),
child: child,
),
);
}
if (clip != null) {
final clipOffset = viewOffset + viewPosition;
final matrix = Matrix4.identity()
..translate(clipOffset.dx, clipOffset.dy)
..scale(viewScale, viewScale);
final transform = matrix.storage;
child = ClipPath(
clipper: SubtitlePathClipper(
paths: clip.map((v) => v.transform(transform)).toList(),
scale: viewScale,
),
child: child,
);
}
return child;
}).toList(),
);
},
);
},
);
},
);
},
),
);
}
}
class SubtitlePathPainter extends CustomPainter {
final List<Path> paths;
final double scale;
final Paint? fillPaint, strokePaint;
SubtitlePathPainter({
required this.paths,
required this.scale,
required Color? fillColor,
required Color? strokeColor,
}) : fillPaint = fillColor != null
? (Paint()
..style = PaintingStyle.fill
..color = fillColor)
: null,
strokePaint = strokeColor != null
? (Paint()
..style = PaintingStyle.stroke
..color = strokeColor)
: null;
@override
void paint(Canvas canvas, Size size) {
canvas.scale(scale, scale);
paths.forEach((path) {
if (fillPaint != null) {
canvas.drawPath(path, fillPaint!);
}
if (strokePaint != null) {
canvas.drawPath(path, strokePaint!);
}
});
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
class SubtitlePathClipper extends CustomClipper<Path> {
final List<Path> paths;
final double scale;
const SubtitlePathClipper({
required this.paths,
required this.scale,
});
@override
Path getClip(Size size) => paths.firstOrNull ?? Path();
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}

View file

@ -105,42 +105,42 @@ packages:
name: connectivity_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.3"
connectivity_plus_linux:
dependency: transitive
description:
name: connectivity_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.3"
connectivity_plus_macos:
dependency: transitive
description:
name: connectivity_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
connectivity_plus_web:
dependency: transitive
description:
name: connectivity_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
connectivity_plus_windows:
dependency: transitive
description:
name: connectivity_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
convert:
dependency: transitive
description:
@ -175,7 +175,7 @@ packages:
name: dbus
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.5"
version: "0.5.2"
decorated_icon:
dependency: "direct main"
description:
@ -218,7 +218,7 @@ packages:
description:
path: "."
ref: aves
resolved-ref: "33aaf201bf761a50755971ba714c80693717b9f9"
resolved-ref: "789162b567e2eaef4d6047cb85e77d9c915e1bed"
url: "git://github.com/deckerst/fijkplayer.git"
source: git
version: "0.9.0"
@ -242,7 +242,7 @@ packages:
name: firebase_analytics
url: "https://pub.dartlang.org"
source: hosted
version: "8.1.1"
version: "8.1.2"
firebase_analytics_platform_interface:
dependency: transitive
description:
@ -263,7 +263,7 @@ packages:
name: firebase_core
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
version: "1.3.0"
firebase_core_platform_interface:
dependency: transitive
description:
@ -284,14 +284,21 @@ packages:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.0.6"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.5"
version: "3.0.6"
flex_color_picker:
dependency: "direct main"
description:
name: flex_color_picker
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
flutter:
dependency: "direct main"
description: flutter
@ -327,7 +334,7 @@ packages:
name: flutter_map
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.0"
version: "0.13.1"
flutter_markdown:
dependency: "direct main"
description:
@ -412,7 +419,7 @@ packages:
name: google_maps_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
version: "2.1.0"
highlight:
dependency: transitive
description:
@ -559,7 +566,7 @@ packages:
name: node_preamble
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.0.1"
overlay_support:
dependency: "direct main"
description:
@ -580,7 +587,7 @@ packages:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.3"
package_info_plus_linux:
dependency: transitive
description:
@ -608,7 +615,7 @@ packages:
name: package_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
package_info_plus_windows:
dependency: transitive
description:
@ -699,14 +706,14 @@ packages:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.1"
version: "8.1.1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.5.1"
version: "3.6.0"
petitparser:
dependency: transitive
description:
@ -1105,7 +1112,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.4"
version: "2.1.5"
wkt_parser:
dependency: transitive
description:

View file

@ -1,7 +1,7 @@
name: aves
description: A visual media gallery and metadata explorer app.
repository: https://github.com/deckerst/aves
version: 1.4.3+47
version: 1.4.4+48
publish_to: none
environment:
@ -31,6 +31,7 @@ dependencies:
firebase_core:
firebase_analytics:
firebase_crashlytics:
flex_color_picker:
flutter_highlight:
flutter_map:
flutter_markdown:

File diff suppressed because one or more lines are too long

1
shaders_2.2.2.sksl.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -118,7 +118,7 @@ void main() {
await image1.toggleFavourite();
const albumFilter = AlbumFilter(testAlbum, 'whatever');
await covers.set(albumFilter, image1.contentId);
await source.renameEntry(image1, 'image1b.jpg');
await source.renameEntry(image1, 'image1b.jpg', persist: true);
expect(favourites.count, 1);
expect(image1.isFavourite, true);

View file

@ -2,7 +2,7 @@ import 'package:aves/widgets/aves_app.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('TODO TLAD', (tester) async {
testWidgets('widget test', (tester) async {
await tester.pumpWidget(AvesApp());
});
}

View file

@ -1,6 +1,6 @@
Thanks for using Aves!
v1.4.3:
- improved navigation usability
- changed thumbnail layout
- improved playing videos with non-square pixels
v1.4.4:
- video speed control, track selection, frame capture
- support embedded subtitles in videos
- custom video quick actions and subtitle theme
Full changelog available on Github