video: capture frame
This commit is contained in:
parent
d203e0fe2e
commit
0d879c41f4
21 changed files with 447 additions and 144 deletions
|
@ -14,6 +14,7 @@ import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
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) }
|
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
|
||||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
|
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
|
||||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
|
"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) }
|
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"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) {
|
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val entryMap = call.argument<FieldMap>("entry")
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
val newName = call.argument<String>("newName")
|
val newName = call.argument<String>("newName")
|
||||||
|
|
|
@ -18,7 +18,7 @@ import kotlin.math.roundToLong
|
||||||
|
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<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
|
private const val precisionErrorTolerance = 1e-10
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import java.util.*
|
||||||
|
|
||||||
object MetadataExtractorHelper {
|
object MetadataExtractorHelper {
|
||||||
const val PNG_TIME_DIR_NAME = "PNG-tIME"
|
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
|
// extensions
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import com.bumptech.glide.request.RequestOptions
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.MultiPage
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
@ -97,6 +98,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun exportSingleByTreeDocAndScan(
|
private suspend fun exportSingleByTreeDocAndScan(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceEntry: AvesEntry,
|
sourceEntry: AvesEntry,
|
||||||
|
@ -109,9 +111,7 @@ abstract class ImageProvider {
|
||||||
val pageId = sourceEntry.pageId
|
val pageId = sourceEntry.pageId
|
||||||
|
|
||||||
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
|
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
|
||||||
val sourcePath = sourceEntry.path
|
val sourceFileName = File(sourceEntry.path).name
|
||||||
val sourceFile = File(sourcePath)
|
|
||||||
val sourceFileName = sourceFile.name
|
|
||||||
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
||||||
} else {
|
} else {
|
||||||
sourceUri.lastPathSegment!!
|
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`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
||||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||||
|
|
||||||
if (isVideo(sourceMimeType)) {
|
if (isVideo(sourceMimeType)) {
|
||||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
|
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
sourceDocFile.copyTo(destinationDocFile)
|
sourceDocFile.copyTo(destinationDocFile)
|
||||||
} else {
|
} else {
|
||||||
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
||||||
|
@ -159,14 +157,12 @@ abstract class ImageProvider {
|
||||||
.load(model)
|
.load(model)
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
||||||
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||||
}
|
}
|
||||||
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
|
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
destinationDocFile.openOutputStream().use { output ->
|
destinationDocFile.openOutputStream().use { output ->
|
||||||
if (exportMimeType == MimeTypes.BMP) {
|
if (exportMimeType == MimeTypes.BMP) {
|
||||||
BmpWriter.writeRGB24(bitmap, output)
|
BmpWriter.writeRGB24(bitmap, output)
|
||||||
|
@ -201,6 +197,108 @@ abstract class ImageProvider {
|
||||||
return scanNewPath(context, destinationFullPath, exportMimeType)
|
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) {
|
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
||||||
val oldFile = File(oldPath)
|
val oldFile = File(oldPath)
|
||||||
val newFile = File(oldFile.parent, newFilename)
|
val newFile = File(oldFile.parent, newFilename)
|
||||||
|
|
|
@ -54,18 +54,18 @@ object StorageUtils {
|
||||||
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
||||||
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
||||||
|
|
||||||
var filename: String? = null
|
var fileName: String? = null
|
||||||
var relativePath: String? = null
|
var relativePath: String? = null
|
||||||
val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1
|
val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1
|
||||||
if (lastSeparatorIndex > rootLength) {
|
if (lastSeparatorIndex > rootLength) {
|
||||||
filename = anyPath.substring(lastSeparatorIndex)
|
fileName = anyPath.substring(lastSeparatorIndex)
|
||||||
relativePath = anyPath.substring(rootLength, lastSeparatorIndex)
|
relativePath = anyPath.substring(rootLength, lastSeparatorIndex)
|
||||||
}
|
}
|
||||||
relativePath ?: return null
|
relativePath ?: return null
|
||||||
|
|
||||||
val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList()
|
val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList()
|
||||||
if (filename?.isNotEmpty() == true) {
|
if (fileName?.isNotEmpty() == true) {
|
||||||
pathSteps.add(filename)
|
pathSteps.add(fileName)
|
||||||
}
|
}
|
||||||
return pathSteps.iterator()
|
return pathSteps.iterator()
|
||||||
}
|
}
|
||||||
|
@ -187,7 +187,7 @@ object StorageUtils {
|
||||||
return "primary"
|
return "primary"
|
||||||
}
|
}
|
||||||
volume.uuid?.let { uuid ->
|
volume.uuid?.let { uuid ->
|
||||||
return uuid.toUpperCase(Locale.ROOT)
|
return uuid.uppercase(Locale.ROOT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -199,7 +199,7 @@ object StorageUtils {
|
||||||
return "primary"
|
return "primary"
|
||||||
}
|
}
|
||||||
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
|
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
|
||||||
return uuid.toUpperCase(Locale.ROOT)
|
return uuid.uppercase(Locale.ROOT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -434,11 +434,11 @@ object StorageUtils {
|
||||||
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
|
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) {
|
class PathSegments(context: Context, fullPath: String) {
|
||||||
var volumePath: String? = null // `volumePath` with trailing "/"
|
var volumePath: String? = null // `volumePath` with trailing "/"
|
||||||
var relativeDir: String? = null // `relativeDir` 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 {
|
init {
|
||||||
volumePath = getVolumePath(context, fullPath)
|
volumePath = getVolumePath(context, fullPath)
|
||||||
|
@ -446,7 +446,7 @@ object StorageUtils {
|
||||||
val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1
|
val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1
|
||||||
val volumePathLength = volumePath!!.length
|
val volumePathLength = volumePath!!.length
|
||||||
if (lastSeparatorIndex > volumePathLength) {
|
if (lastSeparatorIndex > volumePathLength) {
|
||||||
filename = fullPath.substring(lastSeparatorIndex)
|
fileName = fullPath.substring(lastSeparatorIndex)
|
||||||
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex)
|
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -492,6 +492,8 @@
|
||||||
"@albumScreenshots": {},
|
"@albumScreenshots": {},
|
||||||
"albumScreenRecordings": "Screen recordings",
|
"albumScreenRecordings": "Screen recordings",
|
||||||
"@albumScreenRecordings": {},
|
"@albumScreenRecordings": {},
|
||||||
|
"albumVideoCaptures": "Video Captures",
|
||||||
|
"@albumVideoCaptures": {},
|
||||||
|
|
||||||
"albumPageTitle": "Albums",
|
"albumPageTitle": "Albums",
|
||||||
"@albumPageTitle": {},
|
"@albumPageTitle": {},
|
||||||
|
|
|
@ -224,6 +224,7 @@
|
||||||
"albumDownload": "다운로드",
|
"albumDownload": "다운로드",
|
||||||
"albumScreenshots": "스크린샷",
|
"albumScreenshots": "스크린샷",
|
||||||
"albumScreenRecordings": "화면 녹화 파일",
|
"albumScreenRecordings": "화면 녹화 파일",
|
||||||
|
"albumVideoCaptures": "동영상 캡처",
|
||||||
|
|
||||||
"albumPageTitle": "앨범",
|
"albumPageTitle": "앨범",
|
||||||
"albumEmpty": "앨범이 없습니다",
|
"albumEmpty": "앨범이 없습니다",
|
||||||
|
|
|
@ -14,7 +14,7 @@ class VideoActions {
|
||||||
static const all = [
|
static const all = [
|
||||||
VideoAction.replay10,
|
VideoAction.replay10,
|
||||||
VideoAction.togglePlay,
|
VideoAction.togglePlay,
|
||||||
// VideoAction.captureFrame,
|
VideoAction.captureFrame,
|
||||||
VideoAction.setSpeed,
|
VideoAction.setSpeed,
|
||||||
VideoAction.selectStreams,
|
VideoAction.selectStreams,
|
||||||
];
|
];
|
||||||
|
|
|
@ -34,6 +34,7 @@ mixin AlbumMixin on SourceBase {
|
||||||
if (type == AlbumType.download) return context.l10n.albumDownload;
|
if (type == AlbumType.download) return context.l10n.albumDownload;
|
||||||
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
|
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
|
||||||
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
|
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
|
||||||
|
if (type == AlbumType.videoCaptures) return context.l10n.albumVideoCaptures;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||||
|
|
|
@ -80,6 +80,14 @@ abstract class ImageFileService {
|
||||||
required String destinationAlbum,
|
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>> rename(AvesEntry entry, String newName);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||||
|
@ -334,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
|
@override
|
||||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
|
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -9,7 +9,7 @@ import 'package:flutter/widgets.dart';
|
||||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||||
|
|
||||||
class AndroidFileUtils {
|
class AndroidFileUtils {
|
||||||
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
|
||||||
Set<StorageVolume> storageVolumes = {};
|
Set<StorageVolume> storageVolumes = {};
|
||||||
Set<Package> _packages = {};
|
Set<Package> _packages = {};
|
||||||
List<String> _potentialAppDirs = [];
|
List<String> _potentialAppDirs = [];
|
||||||
|
@ -28,6 +28,8 @@ class AndroidFileUtils {
|
||||||
downloadPath = pContext.join(primaryStorage, 'Download');
|
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||||
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
||||||
|
// from Aves
|
||||||
|
videoCapturesPath = pContext.join(dcimPath, 'Video Captures');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initAppNames() async {
|
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 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;
|
bool isDownloadPath(String path) => path == downloadPath;
|
||||||
|
|
||||||
StorageVolume? getStorageVolume(String? path) {
|
StorageVolume? getStorageVolume(String? path) {
|
||||||
|
@ -59,6 +63,7 @@ class AndroidFileUtils {
|
||||||
if (isDownloadPath(albumPath)) return AlbumType.download;
|
if (isDownloadPath(albumPath)) return AlbumType.download;
|
||||||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||||
|
if (isVideoCapturesPath(albumPath)) return AlbumType.videoCaptures;
|
||||||
|
|
||||||
final dir = pContext.split(albumPath).last;
|
final dir = pContext.split(albumPath).last;
|
||||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
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 {
|
class Package {
|
||||||
final String packageName;
|
final String packageName;
|
||||||
|
|
|
@ -46,6 +46,31 @@ mixin SizeAwareMixin {
|
||||||
|
|
||||||
final hasEnoughSpace = needed < free;
|
final hasEnoughSpace = needed < free;
|
||||||
if (!hasEnoughSpace) {
|
if (!hasEnoughSpace) {
|
||||||
|
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(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
@ -66,6 +91,4 @@ mixin SizeAwareMixin {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return hasEnoughSpace;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -199,6 +199,7 @@ class IconUtils {
|
||||||
case AlbumType.camera:
|
case AlbumType.camera:
|
||||||
return buildIcon(AIcons.cameraAlbum);
|
return buildIcon(AIcons.cameraAlbum);
|
||||||
case AlbumType.screenshots:
|
case AlbumType.screenshots:
|
||||||
|
case AlbumType.videoCaptures:
|
||||||
return buildIcon(AIcons.screenshotAlbum);
|
return buildIcon(AIcons.screenshotAlbum);
|
||||||
case AlbumType.screenRecordings:
|
case AlbumType.screenRecordings:
|
||||||
return buildIcon(AIcons.recordingAlbum);
|
return buildIcon(AIcons.recordingAlbum);
|
||||||
|
|
|
@ -196,16 +196,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
onDone: (processed) {
|
onDone: (processed) {
|
||||||
final movedOps = processed.where((e) => e.success);
|
final movedOps = processed.where((e) => e.success);
|
||||||
final movedCount = movedOps.length;
|
final movedCount = movedOps.length;
|
||||||
final showAction = collection != null && movedCount > 0
|
final _collection = collection;
|
||||||
|
final showAction = _collection != null && movedCount > 0
|
||||||
? SnackBarAction(
|
? SnackBarAction(
|
||||||
label: context.l10n.showButtonLabel,
|
label: context.l10n.showButtonLabel,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final highlightInfo = context.read<HighlightInfo>();
|
final highlightInfo = context.read<HighlightInfo>();
|
||||||
final targetCollection = CollectionLens(
|
final targetCollection = CollectionLens(
|
||||||
source: collection!.source,
|
source: source,
|
||||||
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
|
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
|
||||||
groupFactor: collection!.groupFactor,
|
groupFactor: _collection.groupFactor,
|
||||||
sortFactor: collection!.sortFactor,
|
sortFactor: _collection.sortFactor,
|
||||||
);
|
);
|
||||||
unawaited(Navigator.pushAndRemoveUntil(
|
unawaited(Navigator.pushAndRemoveUntil(
|
||||||
context,
|
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/overlay/top.dart';
|
||||||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||||
import 'package:aves/widgets/viewer/video/controller.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:aves/widgets/viewer/visual/state.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -60,7 +61,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
late Animation<double> _topOverlayScale, _bottomOverlayScale;
|
late Animation<double> _topOverlayScale, _bottomOverlayScale;
|
||||||
late Animation<Offset> _bottomOverlayOffset;
|
late Animation<Offset> _bottomOverlayOffset;
|
||||||
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
|
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
|
||||||
late EntryActionDelegate _actionDelegate;
|
late EntryActionDelegate _entryActionDelegate;
|
||||||
|
late VideoActionDelegate _videoActionDelegate;
|
||||||
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
|
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
|
||||||
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
||||||
bool _isEntryTracked = true;
|
bool _isEntryTracked = true;
|
||||||
|
@ -108,10 +110,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
curve: Curves.easeOutQuad,
|
curve: Curves.easeOutQuad,
|
||||||
));
|
));
|
||||||
_overlayVisible.addListener(_onOverlayVisibleChange);
|
_overlayVisible.addListener(_onOverlayVisibleChange);
|
||||||
_actionDelegate = EntryActionDelegate(
|
_entryActionDelegate = EntryActionDelegate(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
showInfo: () => _goToVerticalPage(infoPage),
|
showInfo: () => _goToVerticalPage(infoPage),
|
||||||
);
|
);
|
||||||
|
_videoActionDelegate = VideoActionDelegate(
|
||||||
|
collection: collection,
|
||||||
|
);
|
||||||
_initEntryControllers();
|
_initEntryControllers();
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
WidgetsBinding.instance!.addObserver(this);
|
WidgetsBinding.instance!.addObserver(this);
|
||||||
|
@ -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,
|
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
|
||||||
);
|
);
|
||||||
|
@ -290,6 +295,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
entry: pageEntry,
|
entry: pageEntry,
|
||||||
controller: videoController,
|
controller: videoController,
|
||||||
scale: _bottomOverlayScale,
|
scale: _bottomOverlayScale,
|
||||||
|
onActionSelected: (action) {
|
||||||
|
if (videoController != null) {
|
||||||
|
_videoActionDelegate.onActionSelected(context, videoController, action);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (pageEntry.is360) {
|
} else if (pageEntry.is360) {
|
||||||
|
@ -414,7 +424,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
void _onVerticalPageChanged(int page) {
|
void _onVerticalPageChanged(int page) {
|
||||||
_currentVerticalPage.value = page;
|
_currentVerticalPage.value = page;
|
||||||
if (page == transitionPage) {
|
if (page == transitionPage) {
|
||||||
_actionDelegate.dismissFeedback(context);
|
_entryActionDelegate.dismissFeedback(context);
|
||||||
_popVisual();
|
_popVisual();
|
||||||
} else if (page == infoPage) {
|
} else if (page == infoPage) {
|
||||||
// prevent hero when viewer is offscreen
|
// prevent hero when viewer is offscreen
|
||||||
|
|
|
@ -12,10 +12,7 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/fx/blurred.dart';
|
import 'package:aves/widgets/common/fx/blurred.dart';
|
||||||
import 'package:aves/widgets/common/fx/borders.dart';
|
import 'package:aves/widgets/common/fx/borders.dart';
|
||||||
import 'package:aves/widgets/dialogs/video_speed_dialog.dart';
|
|
||||||
import 'package:aves/widgets/dialogs/video_stream_selection_dialog.dart';
|
|
||||||
import 'package:aves/widgets/viewer/overlay/common.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:aves/widgets/viewer/video/controller.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
@ -25,12 +22,14 @@ class VideoControlOverlay extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final AvesVideoController? controller;
|
final AvesVideoController? controller;
|
||||||
final Animation<double> scale;
|
final Animation<double> scale;
|
||||||
|
final Function(VideoAction value) onActionSelected;
|
||||||
|
|
||||||
const VideoControlOverlay({
|
const VideoControlOverlay({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.scale,
|
required this.scale,
|
||||||
|
required this.onActionSelected,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -93,6 +92,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
menuActions: menuActions,
|
menuActions: menuActions,
|
||||||
scale: scale,
|
scale: scale,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
onActionSelected: widget.onActionSelected,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildProgressBar(),
|
_buildProgressBar(),
|
||||||
|
@ -195,6 +195,7 @@ class _ButtonRow extends StatelessWidget {
|
||||||
final List<VideoAction> quickActions, menuActions;
|
final List<VideoAction> quickActions, menuActions;
|
||||||
final Animation<double> scale;
|
final Animation<double> scale;
|
||||||
final AvesVideoController? controller;
|
final AvesVideoController? controller;
|
||||||
|
final Function(VideoAction value) onActionSelected;
|
||||||
|
|
||||||
const _ButtonRow({
|
const _ButtonRow({
|
||||||
Key? key,
|
Key? key,
|
||||||
|
@ -202,6 +203,7 @@ class _ButtonRow extends StatelessWidget {
|
||||||
required this.menuActions,
|
required this.menuActions,
|
||||||
required this.scale,
|
required this.scale,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
required this.onActionSelected,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
static const double padding = 8;
|
static const double padding = 8;
|
||||||
|
@ -223,7 +225,7 @@ class _ButtonRow extends StatelessWidget {
|
||||||
itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
|
itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
|
||||||
onSelected: (action) {
|
onSelected: (action) {
|
||||||
// wait for the popup menu to hide before proceeding with the action
|
// wait for the popup menu to hide before proceeding with the action
|
||||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action));
|
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -234,7 +236,7 @@ class _ButtonRow extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildOverlayButton(BuildContext context, VideoAction action) {
|
Widget _buildOverlayButton(BuildContext context, VideoAction action) {
|
||||||
late Widget child;
|
late Widget child;
|
||||||
void onPressed() => _onActionSelected(context, action);
|
void onPressed() => onActionSelected(action);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case VideoAction.togglePlay:
|
case VideoAction.togglePlay:
|
||||||
child = _PlayToggler(
|
child = _PlayToggler(
|
||||||
|
@ -283,81 +285,6 @@ class _ButtonRow extends StatelessWidget {
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onActionSelected(BuildContext context, VideoAction action) {
|
|
||||||
switch (action) {
|
|
||||||
case VideoAction.togglePlay:
|
|
||||||
_togglePlayPause(context);
|
|
||||||
break;
|
|
||||||
case VideoAction.setSpeed:
|
|
||||||
_showSpeedDialog(context);
|
|
||||||
break;
|
|
||||||
case VideoAction.selectStreams:
|
|
||||||
_showStreamSelectionDialog(context);
|
|
||||||
break;
|
|
||||||
case VideoAction.captureFrame:
|
|
||||||
controller?.captureFrame();
|
|
||||||
break;
|
|
||||||
case VideoAction.replay10:
|
|
||||||
{
|
|
||||||
final _controller = controller;
|
|
||||||
if (_controller != null && _controller.isReady) {
|
|
||||||
_controller.seekTo(_controller.currentPosition - 10000);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showSpeedDialog(BuildContext context) async {
|
|
||||||
final _controller = controller;
|
|
||||||
if (_controller == null) return;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showStreamSelectionDialog(BuildContext context) async {
|
|
||||||
final _controller = controller;
|
|
||||||
if (_controller == null) return;
|
|
||||||
|
|
||||||
final selectedStreams = await showDialog<Map<StreamType, StreamSummary>>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => VideoStreamSelectionDialog(
|
|
||||||
streams: _controller.streams,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (selectedStreams == null || selectedStreams.isEmpty) return;
|
|
||||||
|
|
||||||
// TODO TLAD [video] get stream list & guess default selected streams, when the controller is not initialized yet
|
|
||||||
await Future.forEach<MapEntry<StreamType, StreamSummary>>(
|
|
||||||
selectedStreams.entries,
|
|
||||||
(kv) => _controller.selectStream(kv.key, kv.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _togglePlayPause(BuildContext context) async {
|
|
||||||
final _controller = controller;
|
|
||||||
if (_controller == null) return;
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
await _controller.pause();
|
|
||||||
} else {
|
|
||||||
await _controller.play();
|
|
||||||
// hide overlay
|
|
||||||
await Future.delayed(Durations.iconAnimation);
|
|
||||||
ToggleOverlayNotification().dispatch(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayToggler extends StatefulWidget {
|
class _PlayToggler extends StatefulWidget {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -55,7 +57,7 @@ abstract class AvesVideoController {
|
||||||
|
|
||||||
Map<StreamSummary, bool> get streams;
|
Map<StreamSummary, bool> get streams;
|
||||||
|
|
||||||
Future<void> captureFrame();
|
Future<Uint8List> captureFrame();
|
||||||
|
|
||||||
Widget buildPlayerWidget(BuildContext context);
|
Widget buildPlayerWidget(BuildContext context);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
@ -339,11 +340,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> captureFrame() async {
|
Future<Uint8List> captureFrame() => _instance.takeSnapShot();
|
||||||
final bytes = await _instance.takeSnapShot();
|
|
||||||
// TODO TLAD [video] export to DCIM/Videocaptures
|
|
||||||
debugPrint('captureFrame bytes=${bytes.length}');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildPlayerWidget(BuildContext context) {
|
Widget buildPlayerWidget(BuildContext context) {
|
||||||
|
|
163
lib/widgets/viewer/video_action_delegate.dart
Normal file
163
lib/widgets/viewer/video_action_delegate.dart
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
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/viewer/overlay/notifications.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.selectStreams:
|
||||||
|
_showStreamSelectionDialog(context, controller);
|
||||||
|
break;
|
||||||
|
case VideoAction.setSpeed:
|
||||||
|
_showSpeedDialog(context, controller);
|
||||||
|
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 selectedStreams = await showDialog<Map<StreamType, StreamSummary>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => VideoStreamSelectionDialog(
|
||||||
|
streams: controller.streams,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selectedStreams == null || selectedStreams.isEmpty) return;
|
||||||
|
|
||||||
|
// TODO TLAD [video] get stream list & guess default selected streams, when the controller is not initialized yet
|
||||||
|
await Future.forEach<MapEntry<StreamType, StreamSummary>>(
|
||||||
|
selectedStreams.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _togglePlayPause(BuildContext context, AvesVideoController controller) async {
|
||||||
|
if (controller.isPlaying) {
|
||||||
|
await controller.pause();
|
||||||
|
} else {
|
||||||
|
await controller.play();
|
||||||
|
// hide overlay
|
||||||
|
await Future.delayed(Durations.iconAnimation);
|
||||||
|
ToggleOverlayNotification().dispatch(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -209,6 +209,11 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
VideoSubtitles(
|
VideoSubtitles(
|
||||||
controller: videoController,
|
controller: videoController,
|
||||||
),
|
),
|
||||||
|
if (settings.videoShowRawTimedText)
|
||||||
|
VideoSubtitles(
|
||||||
|
controller: videoController,
|
||||||
|
debugMode: true,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import 'package:aves/model/settings/settings.dart';
|
|
||||||
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
||||||
import 'package:aves/widgets/viewer/video/controller.dart';
|
import 'package:aves/widgets/viewer/video/controller.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -6,10 +5,12 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class VideoSubtitles extends StatelessWidget {
|
class VideoSubtitles extends StatelessWidget {
|
||||||
final AvesVideoController controller;
|
final AvesVideoController controller;
|
||||||
|
final bool debugMode;
|
||||||
|
|
||||||
const VideoSubtitles({
|
const VideoSubtitles({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
this.debugMode = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -17,34 +18,43 @@ class VideoSubtitles extends StatelessWidget {
|
||||||
return Selector<MediaQueryData, Orientation>(
|
return Selector<MediaQueryData, Orientation>(
|
||||||
selector: (c, mq) => mq.orientation,
|
selector: (c, mq) => mq.orientation,
|
||||||
builder: (c, orientation, child) {
|
builder: (c, orientation, child) {
|
||||||
|
final y = orientation == Orientation.portrait ? .5 : .8;
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment(0, orientation == Orientation.portrait ? .5 : .8),
|
alignment: Alignment(0, debugMode ? -y : y),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
child: StreamBuilder<String?>(
|
child: StreamBuilder<String?>(
|
||||||
stream: controller.timedTextStream,
|
stream: controller.timedTextStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final text = snapshot.data;
|
final text = snapshot.data;
|
||||||
return text != null ? SubtitleText(text: text) : const SizedBox();
|
return text != null
|
||||||
|
? SubtitleText(
|
||||||
|
text: text,
|
||||||
|
debugMode: debugMode,
|
||||||
|
)
|
||||||
|
: const SizedBox();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SubtitleText extends StatelessWidget {
|
class SubtitleText extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
|
final bool debugMode;
|
||||||
|
|
||||||
const SubtitleText({
|
const SubtitleText({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.text,
|
required this.text,
|
||||||
|
this.debugMode = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
late final String displayText;
|
late final String displayText;
|
||||||
|
|
||||||
if (!settings.videoShowRawTimedText) {
|
if (debugMode) {
|
||||||
displayText = text;
|
displayText = text;
|
||||||
} else {
|
} else {
|
||||||
// TODO TLAD [video] process ASS tags, cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/
|
// TODO TLAD [video] process ASS tags, cf https://aegi.vmoe.info/docs/3.0/ASS_Tags/
|
||||||
|
|
Loading…
Reference in a new issue