Merge branch 'develop'
This commit is contained in:
commit
1788fd73e7
100 changed files with 3928 additions and 888 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -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
|
||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -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
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -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
|
||||
|
|
Binary file not shown.
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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("")
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": "파일이 존재하지 않습니다.",
|
||||
|
||||
|
|
68
lib/model/actions/video_actions.dart
Normal file
68
lib/model/actions/video_actions.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
100
lib/widgets/common/basic/color_list_tile.dart
Normal file
100
lib/widgets/common/basic/color_list_tile.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
59
lib/widgets/common/basic/slider_list_tile.dart
Normal file
59
lib/widgets/common/basic/slider_list_tile.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -36,5 +36,5 @@ class CheckeredPainter extends CustomPainter {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
|
|
@ -153,5 +153,5 @@ class _SweepClipPath extends CustomClipper<Path> {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) => true;
|
||||
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
|
||||
}
|
||||
|
|
|
@ -188,5 +188,5 @@ class _TransitionImagePainter extends CustomPainter {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -289,5 +289,5 @@ class GridPainter extends CustomPainter {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
70
lib/widgets/dialogs/video_speed_dialog.dart
Normal file
70
lib/widgets/dialogs/video_speed_dialog.dart
Normal 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);
|
||||
}
|
184
lib/widgets/dialogs/video_stream_selection_dialog.dart
Normal file
184
lib/widgets/dialogs/video_stream_selection_dialog.dart
Normal 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,
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>(),
|
||||
|
|
|
@ -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>(),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
|
@ -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: () {
|
34
lib/widgets/settings/common/tile_leading.dart
Normal file
34
lib/widgets/settings/common/tile_leading.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
61
lib/widgets/settings/language/language.dart
Normal file
61
lib/widgets/settings/language/language.dart
Normal 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;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
79
lib/widgets/settings/navigation.dart
Normal file
79
lib/widgets/settings/navigation.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
54
lib/widgets/settings/privacy/privacy.dart
Normal file
54
lib/widgets/settings/privacy/privacy.dart
Normal 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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
79
lib/widgets/settings/thumbnails.dart
Normal file
79
lib/widgets/settings/thumbnails.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
82
lib/widgets/settings/video/subtitle_sample.dart
Normal file
82
lib/widgets/settings/video/subtitle_sample.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
126
lib/widgets/settings/video/subtitle_theme.dart
Normal file
126
lib/widgets/settings/video/subtitle_theme.dart
Normal 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 '';
|
||||
}
|
||||
}
|
||||
}
|
119
lib/widgets/settings/video/video.dart
Normal file
119
lib/widgets/settings/video/video.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
42
lib/widgets/settings/video/video_actions_editor.dart
Normal file
42
lib/widgets/settings/video/video_actions_editor.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
72
lib/widgets/settings/viewer/viewer.dart
Normal file
72
lib/widgets/settings/viewer/viewer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -117,5 +117,5 @@ class MinimapPainter extends CustomPainter {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
179
lib/widgets/viewer/video_action_delegate.dart
Normal file
179
lib/widgets/viewer/video_action_delegate.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
596
lib/widgets/viewer/visual/subtitle/ass_parser.dart
Normal file
596
lib/widgets/viewer/visual/subtitle/ass_parser.dart
Normal 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;
|
||||
}
|
||||
}
|
49
lib/widgets/viewer/visual/subtitle/line.dart
Normal file
49
lib/widgets/viewer/visual/subtitle/line.dart
Normal 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,
|
||||
);
|
||||
}
|
43
lib/widgets/viewer/visual/subtitle/span.dart
Normal file
43
lib/widgets/viewer/visual/subtitle/span.dart
Normal 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,
|
||||
);
|
||||
}
|
106
lib/widgets/viewer/visual/subtitle/style.dart
Normal file
106
lib/widgets/viewer/visual/subtitle/style.dart
Normal 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,
|
||||
);
|
||||
}
|
335
lib/widgets/viewer/visual/subtitle/subtitle.dart
Normal file
335
lib/widgets/viewer/visual/subtitle/subtitle.dart
Normal 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;
|
||||
}
|
47
pubspec.lock
47
pubspec.lock
|
@ -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:
|
||||
|
|
|
@ -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
1
shaders_2.2.2.sksl.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue