Merge branch 'develop'
This commit is contained in:
commit
331cd287d2
70 changed files with 2326 additions and 815 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v1.4.0] - 2021-04-16
|
||||||
|
### Added
|
||||||
|
- Viewer: support for videos with EAC3/FLAC/OPUS audio
|
||||||
|
- Info: more consistent and comprehensive info for videos and streams
|
||||||
|
- Settings: more video options (auto play, loop, hardware acceleration)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Info: present video cover like XMP embedded images
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- locale name package (-3 MB)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Albums: auto naming for folders on SD card
|
||||||
|
- Viewer: display of videos with unusual SAR
|
||||||
|
|
||||||
## [v1.3.7] - 2021-04-02
|
## [v1.3.7] - 2021-04-02
|
||||||
### Added
|
### Added
|
||||||
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
|
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
|
||||||
|
|
|
@ -32,15 +32,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
||||||
- SVG: unsupported `<style>` (cf [flutter_svg issue #105](https://github.com/dnfield/flutter_svg/issues/105))
|
- SVG: unsupported `<style>` (cf [flutter_svg issue #105](https://github.com/dnfield/flutter_svg/issues/105))
|
||||||
- SVG: limited support for `%`, `mm` or `pt` unit (cf [flutter_svg issue #110](https://github.com/dnfield/flutter_svg/issues/110))
|
- SVG: limited support for `%`, `mm` or `pt` unit (cf [flutter_svg issue #110](https://github.com/dnfield/flutter_svg/issues/110))
|
||||||
|
|
||||||
## Test Devices
|
|
||||||
|
|
||||||
| Model | Name | Android Version | API |
|
|
||||||
| ----------- | -------------------------- | --------------- | ---:|
|
|
||||||
| SM-G981N | Samsung Galaxy S20 5G | 11 (R) | 30 |
|
|
||||||
| SM-G970N | Samsung Galaxy S10e | 11 (R) | 30 |
|
|
||||||
| SM-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
|
|
||||||
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |
|
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys.
|
Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys.
|
||||||
|
|
|
@ -58,6 +58,7 @@ android {
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
|
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
|
||||||
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
@ -104,14 +105,15 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||||
implementation 'androidx.core:core-ktx:1.5.0-beta03' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
implementation 'androidx.core:core-ktx:1.5.0-rc01' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||||
|
implementation "androidx.multidex:multidex:2.0.1"
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
|
||||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.1.0'
|
kapt 'androidx.annotation:annotation:1.2.0'
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
||||||
|
|
||||||
compileOnly rootProject.findProject(':streams_channel')
|
compileOnly rootProject.findProject(':streams_channel')
|
||||||
|
|
BIN
android/app/libs/fijkplayer-full-release.aar
Normal file
BIN
android/app/libs/fijkplayer-full-release.aar
Normal file
Binary file not shown.
|
@ -39,9 +39,9 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:requestLegacyExternalStorage="true">
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
|
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
@ -56,8 +56,7 @@
|
||||||
to determine the Window background behind the Flutter UI. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme" />
|
||||||
/>
|
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
@ -71,6 +70,15 @@
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
<data android:mimeType="vnd.android.cursor.dir/image" />
|
<data android:mimeType="vnd.android.cursor.dir/image" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.android.camera.action.REVIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.dir/image" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.dir/video" />
|
||||||
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.GET_CONTENT" />
|
<action android:name="android.intent.action.GET_CONTENT" />
|
||||||
|
|
||||||
|
@ -100,6 +108,7 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.geo.API_KEY"
|
android:name="com.google.android.geo.API_KEY"
|
||||||
android:value="${googleApiKey}" />
|
android:value="${googleApiKey}" />
|
||||||
|
|
|
@ -111,7 +111,7 @@ class MainActivity : FlutterActivity() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Intent.ACTION_VIEW -> {
|
Intent.ACTION_VIEW, "com.android.camera.action.REVIEW" -> {
|
||||||
intent.data?.let { uri ->
|
intent.data?.let { uri ->
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"action" to "view",
|
"action" to "view",
|
||||||
|
|
|
@ -15,6 +15,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -53,7 +54,9 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
||||||
.putExtra("page", "/collection")
|
.putExtra("page", "/collection")
|
||||||
.putExtra("filters", filters.toTypedArray())
|
.putExtra("filters", filters.toTypedArray())
|
||||||
|
|
||||||
val shortcut = ShortcutInfoCompat.Builder(context, "collection-${filters.joinToString("-")}")
|
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
|
||||||
|
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
|
||||||
|
val shortcut = ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString())
|
||||||
.setShortLabel(label)
|
.setShortLabel(label)
|
||||||
.setIcon(icon)
|
.setIcon(icon)
|
||||||
.setIntent(intent)
|
.setIntent(intent)
|
||||||
|
|
|
@ -18,6 +18,7 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Tag
|
import com.drew.metadata.Tag
|
||||||
|
import com.drew.metadata.avi.AviDirectory
|
||||||
import com.drew.metadata.exif.*
|
import com.drew.metadata.exif.*
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory
|
import com.drew.metadata.gif.GifAnimationDirectory
|
||||||
|
@ -58,9 +59,8 @@ import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
@ -88,8 +88,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
|
|
||||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
|
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
|
||||||
|
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -115,9 +115,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||||
|
|
||||||
for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) {
|
for (dir in metadata.directories.filter {
|
||||||
|
it.tagCount > 0
|
||||||
|
&& it !is FileTypeDirectory
|
||||||
|
&& it !is AviDirectory
|
||||||
|
}) {
|
||||||
// directory name
|
// directory name
|
||||||
var dirName = dir.name
|
var dirName = dir.name
|
||||||
|
|
||||||
|
// exclude directories known to be redundant with info derived on the Dart side
|
||||||
|
// they are excluded by name instead of runtime type because excluding `Mp4Directory`
|
||||||
|
// would also exclude derived directories, such as `Mp4UuidBoxDirectory`
|
||||||
|
if (allMetadataRedundantDirNames.contains(dirName)) continue
|
||||||
|
|
||||||
// optional parent to distinguish child directories of the same type
|
// optional parent to distinguish child directories of the same type
|
||||||
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
||||||
|
|
||||||
|
@ -205,11 +215,23 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMultimedia(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
|
// this is used as fallback when the video metadata cannot be found on the Dart side
|
||||||
|
// and to identify whether there is an accessible cover image
|
||||||
|
// do not include HEIC here
|
||||||
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
|
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
|
||||||
if (mediaDir.isNotEmpty()) {
|
if (mediaDir.isNotEmpty()) {
|
||||||
metadataMap[Metadata.DIR_MEDIA] = mediaDir
|
metadataMap[Metadata.DIR_MEDIA] = mediaDir
|
||||||
|
if (mediaDir.containsKey(KEY_HAS_EMBEDDED_PICTURE)) {
|
||||||
|
metadataMap[Metadata.DIR_COVER_ART] = hashMapOf(
|
||||||
|
// dummy entry value
|
||||||
|
"Image" to "data",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
||||||
|
// about embedded images as they do not list them as separate tracks
|
||||||
|
// and only identify at most one
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadataMap.isNotEmpty()) {
|
if (metadataMap.isNotEmpty()) {
|
||||||
|
@ -224,7 +246,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return dirMap
|
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return dirMap
|
||||||
try {
|
try {
|
||||||
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
|
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
|
||||||
retriever.getSafeDescription(code, context) { dirMap[name] = it }
|
retriever.getSafeDescription(code) { dirMap[name] = it }
|
||||||
|
}
|
||||||
|
if (retriever.embeddedPicture != null) {
|
||||||
|
// additional key for the Dart side to know whether to add a `Cover` section
|
||||||
|
dirMap[KEY_HAS_EMBEDDED_PICTURE] = "yes"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
|
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
|
||||||
|
@ -263,7 +289,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val metadataMap = HashMap<String, Any>()
|
val metadataMap = HashMap<String, Any>()
|
||||||
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
|
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
|
||||||
if (isMultimedia(mimeType)) {
|
if (isVideo(mimeType) || isHeic(mimeType)) {
|
||||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
|
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -479,7 +505,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHeifLike(mimeType)) {
|
if (isHeic(mimeType)) {
|
||||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
||||||
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
||||||
}
|
}
|
||||||
|
@ -587,7 +613,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
|
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isHeifLike(mimeType)) {
|
} else if (isHeic(mimeType)) {
|
||||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
if (this.containsKey(key)) save(this.getInteger(key))
|
||||||
}
|
}
|
||||||
|
@ -719,28 +745,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(value?.toString())
|
result.success(value?.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
|
||||||
if (uri == null) {
|
|
||||||
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val pictures = ArrayList<ByteArray>()
|
|
||||||
val retriever = StorageUtils.openMetadataRetriever(context, uri)
|
|
||||||
if (retriever != null) {
|
|
||||||
try {
|
|
||||||
retriever.embeddedPicture?.let { pictures.add(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
|
|
||||||
retriever.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(pictures)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
@ -770,6 +774,39 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(thumbnails)
|
result.success(thumbnails)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
if (uri == null) {
|
||||||
|
result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val retriever = StorageUtils.openMetadataRetriever(context, uri)
|
||||||
|
if (retriever != null) {
|
||||||
|
try {
|
||||||
|
retriever.embeddedPicture?.let { bytes ->
|
||||||
|
var embedMimeType: String? = null
|
||||||
|
bytes.inputStream().use { input ->
|
||||||
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir ->
|
||||||
|
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
embedMimeType?.let { mime ->
|
||||||
|
copyEmbeddedBytes(bytes, mime, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message)
|
||||||
|
} finally {
|
||||||
|
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
|
||||||
|
retriever.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
@ -805,36 +842,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
copyEmbeddedBytes(embedBytes, embedMimeType, result)
|
||||||
deleteOnExit()
|
|
||||||
outputStream().use { outputStream ->
|
|
||||||
embedBytes.inputStream().use { inputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val embedUri = Uri.fromFile(embedFile)
|
|
||||||
val embedFields: FieldMap = hashMapOf(
|
|
||||||
"uri" to embedUri.toString(),
|
|
||||||
"mimeType" to embedMimeType,
|
|
||||||
)
|
|
||||||
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
|
|
||||||
override fun onSuccess(fields: FieldMap) {
|
|
||||||
embedFields.putAll(fields)
|
|
||||||
result.success(embedFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(throwable: Throwable) = result.error("extractXmpDataProp-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.success(embedFields)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
} catch (e: XMPException) {
|
} catch (e: XMPException) {
|
||||||
result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
|
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -847,6 +858,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
|
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyEmbeddedBytes(embedBytes: ByteArray, embedMimeType: String, result: MethodChannel.Result) {
|
||||||
|
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||||
|
deleteOnExit()
|
||||||
|
outputStream().use { outputStream ->
|
||||||
|
embedBytes.inputStream().use { inputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val embedUri = Uri.fromFile(embedFile)
|
||||||
|
val embedFields: FieldMap = hashMapOf(
|
||||||
|
"uri" to embedUri.toString(),
|
||||||
|
"mimeType" to embedMimeType,
|
||||||
|
)
|
||||||
|
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) {
|
||||||
|
embedFields.putAll(fields)
|
||||||
|
result.success(embedFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.success(embedFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
|
private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
|
||||||
|
|
||||||
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
|
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
|
||||||
|
@ -872,6 +913,15 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||||
|
|
||||||
|
private val allMetadataRedundantDirNames = setOf(
|
||||||
|
"MP4",
|
||||||
|
"MP4 Sound",
|
||||||
|
"MP4 Video",
|
||||||
|
"QuickTime",
|
||||||
|
"QuickTime Sound",
|
||||||
|
"QuickTime Video",
|
||||||
|
)
|
||||||
|
|
||||||
// catalog metadata & page info
|
// catalog metadata & page info
|
||||||
private const val KEY_MIME_TYPE = "mimeType"
|
private const val KEY_MIME_TYPE = "mimeType"
|
||||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||||
|
@ -899,5 +949,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
private const val KEY_EXPOSURE_TIME = "exposureTime"
|
private const val KEY_EXPOSURE_TIME = "exposureTime"
|
||||||
private const val KEY_FOCAL_LENGTH = "focalLength"
|
private const val KEY_FOCAL_LENGTH = "focalLength"
|
||||||
private const val KEY_ISO = "iso"
|
private const val KEY_ISO = "iso"
|
||||||
|
|
||||||
|
// additional media key
|
||||||
|
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -40,7 +40,7 @@ class RegionFetcher internal constructor(
|
||||||
imageHeight: Int,
|
imageHeight: Int,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
if (MimeTypes.isHeic(mimeType) && pageId != null) {
|
||||||
val id = Pair(uri, pageId)
|
val id = Pair(uri, pageId)
|
||||||
fetch(
|
fetch(
|
||||||
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
||||||
|
|
|
@ -18,7 +18,7 @@ import deckers.thibault.aves.decoder.VideoThumbnail
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
|
@ -42,7 +42,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||||
private val tiffFetch = mimeType == MimeTypes.TIFF
|
private val tiffFetch = mimeType == MimeTypes.TIFF
|
||||||
private val multiTrackFetch = isHeifLike(mimeType) && pageId != null
|
private val multiTrackFetch = isHeic(mimeType) && pageId != null
|
||||||
private val customFetch = tiffFetch || multiTrackFetch
|
private val customFetch = tiffFetch || multiTrackFetch
|
||||||
|
|
||||||
fun fetch() {
|
fun fetch() {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
|
@ -115,7 +115,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||||
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
|
val model: Any = if (isHeic(mimeType) && pageId != null) {
|
||||||
MultiTrackImage(activity, uri, pageId)
|
MultiTrackImage(activity, uri, pageId)
|
||||||
} else if (mimeType == MimeTypes.TIFF) {
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
TiffImage(activity, uri, pageId)
|
TiffImage(activity, uri, pageId)
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.format.Formatter
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@ -95,7 +93,16 @@ object MediaMetadataRetrieverHelper {
|
||||||
if (dateMillis > 0) save(dateMillis)
|
if (dateMillis > 0) save(dateMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MediaMetadataRetriever.getSafeDescription(tag: Int, context: Context, save: (value: String) -> Unit) {
|
private fun formatBitrate(size: Long): String {
|
||||||
|
val divider = 1000
|
||||||
|
val symbol = "bit/s"
|
||||||
|
|
||||||
|
if (size < divider) return "$size $symbol"
|
||||||
|
if (size < divider * divider) return "${String.format("%.2f", size.toDouble() / divider)} K$symbol"
|
||||||
|
return "${String.format("%.2f", size.toDouble() / divider / divider)} M$symbol"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaMetadataRetriever.getSafeDescription(tag: Int, save: (value: String) -> Unit) {
|
||||||
val value = this.extractMetadata(tag)
|
val value = this.extractMetadata(tag)
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
when (tag) {
|
when (tag) {
|
||||||
|
@ -106,7 +113,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
||||||
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
||||||
val bitrate = value.toLongOrNull() ?: 0
|
val bitrate = value.toLongOrNull() ?: 0
|
||||||
if (bitrate > 0) "${Formatter.formatFileSize(context, bitrate)}/sec" else null
|
if (bitrate > 0) formatBitrate(bitrate) else null
|
||||||
}
|
}
|
||||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
||||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||||
|
|
|
@ -19,10 +19,14 @@ object Metadata {
|
||||||
// "+51.3328-000.7053+113.474/" (Apple)
|
// "+51.3328-000.7053+113.474/" (Apple)
|
||||||
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
|
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
|
||||||
|
|
||||||
|
private val VIDEO_DATE_SUBSECOND_PATTERN = Pattern.compile("(\\d{6})(\\.\\d+)")
|
||||||
|
private val VIDEO_TIMEZONE_PATTERN = Pattern.compile("(Z|[+-]\\d{4})$")
|
||||||
|
|
||||||
// directory names, as shown when listing all metadata
|
// directory names, as shown when listing all metadata
|
||||||
const val DIR_GPS = "GPS" // from metadata-extractor
|
const val DIR_GPS = "GPS" // from metadata-extractor
|
||||||
const val DIR_XMP = "XMP" // from metadata-extractor
|
const val DIR_XMP = "XMP" // from metadata-extractor
|
||||||
const val DIR_MEDIA = "Media"
|
const val DIR_MEDIA = "Media" // custom
|
||||||
|
const val DIR_COVER_ART = "Cover" // custom
|
||||||
|
|
||||||
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||||
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
||||||
|
@ -55,7 +59,7 @@ object Metadata {
|
||||||
|
|
||||||
// optional sub-second
|
// optional sub-second
|
||||||
var subSecond: String? = null
|
var subSecond: String? = null
|
||||||
val subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString)
|
val subSecondMatcher = VIDEO_DATE_SUBSECOND_PATTERN.matcher(dateString)
|
||||||
if (subSecondMatcher.find()) {
|
if (subSecondMatcher.find()) {
|
||||||
subSecond = subSecondMatcher.group(2)?.substring(1)
|
subSecond = subSecondMatcher.group(2)?.substring(1)
|
||||||
dateString = subSecondMatcher.replaceAll("$1")
|
dateString = subSecondMatcher.replaceAll("$1")
|
||||||
|
@ -63,7 +67,7 @@ object Metadata {
|
||||||
|
|
||||||
// optional time zone
|
// optional time zone
|
||||||
var timeZone: TimeZone? = null
|
var timeZone: TimeZone? = null
|
||||||
val timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString)
|
val timeZoneMatcher = VIDEO_TIMEZONE_PATTERN.matcher(dateString)
|
||||||
if (timeZoneMatcher.find()) {
|
if (timeZoneMatcher.find()) {
|
||||||
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z".toRegex(), "")}")
|
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z".toRegex(), "")}")
|
||||||
dateString = timeZoneMatcher.replaceAll("")
|
dateString = timeZoneMatcher.replaceAll("")
|
||||||
|
|
|
@ -130,7 +130,7 @@ abstract class ImageProvider {
|
||||||
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)
|
||||||
|
|
||||||
val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) {
|
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
||||||
MultiTrackImage(context, sourceUri, pageId)
|
MultiTrackImage(context, sourceUri, pageId)
|
||||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||||
TiffImage(context, sourceUri, pageId)
|
TiffImage(context, sourceUri, pageId)
|
||||||
|
|
|
@ -43,9 +43,7 @@ object MimeTypes {
|
||||||
|
|
||||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||||
|
|
||||||
fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
fun isHeic(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
||||||
|
|
||||||
fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
|
|
||||||
|
|
||||||
fun isRaw(mimeType: String): Boolean {
|
fun isRaw(mimeType: String): Boolean {
|
||||||
return when (mimeType) {
|
return when (mimeType) {
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
package="deckers.thibault.aves">
|
|
||||||
<!-- Flutter needs it to communicate with the running application
|
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
|
||||||
-->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
</manifest>
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.4.31'
|
ext.kotlin_version = '1.4.32'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
@ -15,7 +15,7 @@ buildscript {
|
||||||
classpath 'com.android.tools.build:gradle:4.1.3'
|
classpath 'com.android.tools.build:gradle:4.1.3'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.5'
|
classpath 'com.google.gms:google-services:4.3.5'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.1'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -115,6 +115,13 @@
|
||||||
"coordinateFormatDecimal": "Decimal degrees",
|
"coordinateFormatDecimal": "Decimal degrees",
|
||||||
"@coordinateFormatDecimal": {},
|
"@coordinateFormatDecimal": {},
|
||||||
|
|
||||||
|
"videoLoopModeNever": "Never",
|
||||||
|
"@videoLoopModeNever": {},
|
||||||
|
"videoLoopModeShortOnly": "Short videos only",
|
||||||
|
"@videoLoopModeShortOnly": {},
|
||||||
|
"videoLoopModeAlways": "Always",
|
||||||
|
"@videoLoopModeAlways": {},
|
||||||
|
|
||||||
"mapStyleGoogleNormal": "Google Maps",
|
"mapStyleGoogleNormal": "Google Maps",
|
||||||
"@mapStyleGoogleNormal": {},
|
"@mapStyleGoogleNormal": {},
|
||||||
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
|
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
|
||||||
|
@ -542,6 +549,14 @@
|
||||||
"@settingsSectionVideo": {},
|
"@settingsSectionVideo": {},
|
||||||
"settingsVideoShowVideos": "Show videos",
|
"settingsVideoShowVideos": "Show videos",
|
||||||
"@settingsVideoShowVideos": {},
|
"@settingsVideoShowVideos": {},
|
||||||
|
"settingsVideoEnableHardwareAcceleration": "Hardware acceleration",
|
||||||
|
"@settingsVideoEnableHardwareAcceleration": {},
|
||||||
|
"settingsVideoEnableAutoPlay": "Auto play",
|
||||||
|
"@settingsVideoEnableAutoPlay": {},
|
||||||
|
"settingsVideoLoopModeTile": "Loop mode",
|
||||||
|
"@settingsVideoLoopModeTile": {},
|
||||||
|
"settingsVideoLoopModeTitle": "Loop Mode",
|
||||||
|
"@settingsVideoLoopModeTitle": {},
|
||||||
|
|
||||||
"settingsSectionPrivacy": "Privacy",
|
"settingsSectionPrivacy": "Privacy",
|
||||||
"@settingsSectionPrivacy": {},
|
"@settingsSectionPrivacy": {},
|
||||||
|
|
|
@ -59,6 +59,10 @@
|
||||||
"coordinateFormatDms": "도분초",
|
"coordinateFormatDms": "도분초",
|
||||||
"coordinateFormatDecimal": "소수점",
|
"coordinateFormatDecimal": "소수점",
|
||||||
|
|
||||||
|
"videoLoopModeNever": "반복 안 함",
|
||||||
|
"videoLoopModeShortOnly": "짧은 동영상만 반복",
|
||||||
|
"videoLoopModeAlways": "항상 반복",
|
||||||
|
|
||||||
"mapStyleGoogleNormal": "구글 지도",
|
"mapStyleGoogleNormal": "구글 지도",
|
||||||
"mapStyleGoogleHybrid": "구글 지도 (위성)",
|
"mapStyleGoogleHybrid": "구글 지도 (위성)",
|
||||||
"mapStyleGoogleTerrain": "구글 지도 (지형)",
|
"mapStyleGoogleTerrain": "구글 지도 (지형)",
|
||||||
|
@ -252,6 +256,10 @@
|
||||||
|
|
||||||
"settingsSectionVideo": "동영상",
|
"settingsSectionVideo": "동영상",
|
||||||
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
||||||
|
"settingsVideoEnableHardwareAcceleration": "하드웨어 가속",
|
||||||
|
"settingsVideoEnableAutoPlay": "자동 재생",
|
||||||
|
"settingsVideoLoopModeTile": "반복 모드",
|
||||||
|
"settingsVideoLoopModeTitle": "반복 모드",
|
||||||
|
|
||||||
"settingsSectionPrivacy": "개인정보 보호",
|
"settingsSectionPrivacy": "개인정보 보호",
|
||||||
"settingsEnableAnalytics": "진단 데이터 보내기",
|
"settingsEnableAnalytics": "진단 데이터 보내기",
|
||||||
|
|
|
@ -24,7 +24,6 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
|
|
||||||
import 'package:overlay_support/overlay_support.dart';
|
import 'package:overlay_support/overlay_support.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -107,7 +106,6 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
locale: settingsLocale,
|
locale: settingsLocale,
|
||||||
localizationsDelegates: [
|
localizationsDelegates: [
|
||||||
...AppLocalizations.localizationsDelegates,
|
...AppLocalizations.localizationsDelegates,
|
||||||
LocaleNamesLocalizationsDelegate(),
|
|
||||||
],
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
);
|
);
|
||||||
|
|
|
@ -366,7 +366,7 @@ class AvesEntry {
|
||||||
String _durationText;
|
String _durationText;
|
||||||
|
|
||||||
String get durationText {
|
String get durationText {
|
||||||
_durationText ??= formatDuration(Duration(milliseconds: durationMillis ?? 0));
|
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
|
||||||
return _durationText;
|
return _durationText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,3 +8,5 @@ enum HomePageSetting { collection, albums }
|
||||||
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
|
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
|
||||||
|
|
||||||
enum KeepScreenOn { never, viewerOnly, always }
|
enum KeepScreenOn { never, viewerOnly, always }
|
||||||
|
|
||||||
|
enum VideoLoopMode { never, shortOnly, always }
|
||||||
|
|
|
@ -49,6 +49,11 @@ class Settings extends ChangeNotifier {
|
||||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||||
static const viewerQuickActionsKey = 'viewer_quick_actions';
|
static const viewerQuickActionsKey = 'viewer_quick_actions';
|
||||||
|
|
||||||
|
// video
|
||||||
|
static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec';
|
||||||
|
static const enableVideoAutoPlayKey = 'video_auto_play';
|
||||||
|
static const videoLoopModeKey = 'video_loop';
|
||||||
|
|
||||||
// info
|
// info
|
||||||
static const infoMapStyleKey = 'info_map_style';
|
static const infoMapStyleKey = 'info_map_style';
|
||||||
static const infoMapZoomKey = 'info_map_zoom';
|
static const infoMapZoomKey = 'info_map_zoom';
|
||||||
|
@ -223,6 +228,20 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||||
|
|
||||||
|
// video
|
||||||
|
|
||||||
|
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);
|
||||||
|
|
||||||
|
bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true);
|
||||||
|
|
||||||
|
set enableVideoAutoPlay(bool newValue) => setAndNotify(enableVideoAutoPlayKey, newValue);
|
||||||
|
|
||||||
|
bool get enableVideoAutoPlay => getBoolOrDefault(enableVideoAutoPlayKey, false);
|
||||||
|
|
||||||
|
VideoLoopMode get videoLoopMode => getEnumOrDefault(videoLoopModeKey, VideoLoopMode.shortOnly, VideoLoopMode.values);
|
||||||
|
|
||||||
|
set videoLoopMode(VideoLoopMode newValue) => setAndNotify(videoLoopModeKey, newValue.toString());
|
||||||
|
|
||||||
// info
|
// info
|
||||||
|
|
||||||
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
|
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
|
||||||
|
|
35
lib/model/settings/video_loop_mode.dart
Normal file
35
lib/model/settings/video_loop_mode.dart
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'enums.dart';
|
||||||
|
|
||||||
|
extension ExtraVideoLoopMode on VideoLoopMode {
|
||||||
|
String getName(BuildContext context) {
|
||||||
|
switch (this) {
|
||||||
|
case VideoLoopMode.never:
|
||||||
|
return context.l10n.videoLoopModeNever;
|
||||||
|
case VideoLoopMode.shortOnly:
|
||||||
|
return context.l10n.videoLoopModeShortOnly;
|
||||||
|
case VideoLoopMode.always:
|
||||||
|
return context.l10n.videoLoopModeAlways;
|
||||||
|
default:
|
||||||
|
return toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const shortVideoThreshold = Duration(seconds: 30);
|
||||||
|
|
||||||
|
bool shouldLoop(AvesEntry entry) {
|
||||||
|
switch (this) {
|
||||||
|
case VideoLoopMode.never:
|
||||||
|
return false;
|
||||||
|
case VideoLoopMode.shortOnly:
|
||||||
|
if (entry.durationMillis == null) return false;
|
||||||
|
return entry.durationMillis < shortVideoThreshold.inMilliseconds;
|
||||||
|
case VideoLoopMode.always:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -56,7 +56,7 @@ mixin AlbumMixin on SourceBase {
|
||||||
|
|
||||||
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
|
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
|
||||||
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
|
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
|
||||||
if (uniqueNameInDevice.length < relativeDir.length) {
|
if (uniqueNameInDevice.length <= relativeDir.length) {
|
||||||
return uniqueNameInDevice;
|
return uniqueNameInDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
94
lib/model/video/channel_layouts.dart
Normal file
94
lib/model/video/channel_layouts.dart
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
// channel layout constants from FFmpeg libavutil/channel_layout.h
|
||||||
|
class ChannelLayouts {
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
|
||||||
|
static const FRONT_LEFT = 0x00000001;
|
||||||
|
static const FRONT_RIGHT = 0x00000002;
|
||||||
|
static const FRONT_CENTER = 0x00000004;
|
||||||
|
static const LOW_FREQUENCY = 0x00000008;
|
||||||
|
static const BACK_LEFT = 0x00000010;
|
||||||
|
static const BACK_RIGHT = 0x00000020;
|
||||||
|
static const FRONT_LEFT_OF_CENTER = 0x00000040;
|
||||||
|
static const FRONT_RIGHT_OF_CENTER = 0x00000080;
|
||||||
|
static const BACK_CENTER = 0x00000100;
|
||||||
|
static const SIDE_LEFT = 0x00000200;
|
||||||
|
static const SIDE_RIGHT = 0x00000400;
|
||||||
|
static const TOP_CENTER = 0x00000800;
|
||||||
|
static const TOP_FRONT_LEFT = 0x00001000;
|
||||||
|
static const TOP_FRONT_CENTER = 0x00002000;
|
||||||
|
static const TOP_FRONT_RIGHT = 0x00004000;
|
||||||
|
static const TOP_BACK_LEFT = 0x00008000;
|
||||||
|
static const TOP_BACK_CENTER = 0x00010000;
|
||||||
|
static const TOP_BACK_RIGHT = 0x00020000;
|
||||||
|
static const STEREO_LEFT = 0x20000000;
|
||||||
|
static const STEREO_RIGHT = 0x40000000;
|
||||||
|
|
||||||
|
static const WIDE_LEFT = 0x0000000080000000;
|
||||||
|
static const WIDE_RIGHT = 0x0000000100000000;
|
||||||
|
static const SURROUND_DIRECT_LEFT = 0x0000000200000000;
|
||||||
|
static const SURROUND_DIRECT_RIGHT = 0x0000000400000000;
|
||||||
|
static const LOW_FREQUENCY_2 = 0x0000000800000000;
|
||||||
|
|
||||||
|
static const LAYOUT_NATIVE = 0x8000000000000000;
|
||||||
|
|
||||||
|
static const LAYOUT_MONO = (FRONT_CENTER);
|
||||||
|
static const LAYOUT_STEREO = (FRONT_LEFT | FRONT_RIGHT);
|
||||||
|
static const LAYOUT_2POINT1 = (LAYOUT_STEREO | LOW_FREQUENCY);
|
||||||
|
static const LAYOUT_2_1 = (LAYOUT_STEREO | BACK_CENTER);
|
||||||
|
static const LAYOUT_SURROUND = (LAYOUT_STEREO | FRONT_CENTER);
|
||||||
|
static const LAYOUT_3POINT1 = (LAYOUT_SURROUND | LOW_FREQUENCY);
|
||||||
|
static const LAYOUT_4POINT0 = (LAYOUT_SURROUND | BACK_CENTER);
|
||||||
|
static const LAYOUT_4POINT1 = (LAYOUT_4POINT0 | LOW_FREQUENCY);
|
||||||
|
static const LAYOUT_2_2 = (LAYOUT_STEREO | SIDE_LEFT | SIDE_RIGHT);
|
||||||
|
static const LAYOUT_QUAD = (LAYOUT_STEREO | BACK_LEFT | BACK_RIGHT);
|
||||||
|
static const LAYOUT_5POINT0 = (LAYOUT_SURROUND | SIDE_LEFT | SIDE_RIGHT);
|
||||||
|
static const LAYOUT_5POINT1 = (LAYOUT_5POINT0 | LOW_FREQUENCY);
|
||||||
|
static const LAYOUT_5POINT0_BACK = (LAYOUT_SURROUND | BACK_LEFT | BACK_RIGHT);
|
||||||
|
static const LAYOUT_5POINT1_BACK = (LAYOUT_5POINT0_BACK | LOW_FREQUENCY);
|
||||||
|
static const LAYOUT_6POINT0 = (LAYOUT_5POINT0 | BACK_CENTER);
|
||||||
|
static const LAYOUT_6POINT0_FRONT = (LAYOUT_2_2 | FRONT_LEFT_OF_CENTER | FRONT_RIGHT_OF_CENTER);
|
||||||
|
static const LAYOUT_HEXAGONAL = (LAYOUT_5POINT0_BACK | BACK_CENTER);
|
||||||
|
static const LAYOUT_6POINT1 = (LAYOUT_5POINT1 | BACK_CENTER);
|
||||||
|
static const LAYOUT_6POINT1_BACK = (LAYOUT_5POINT1_BACK | BACK_CENTER);
|
||||||
|
static const LAYOUT_6POINT1_FRONT = (LAYOUT_6POINT0_FRONT | LOW_FREQUENCY);
|
||||||
|
static const LAYOUT_7POINT0 = (LAYOUT_5POINT0 | BACK_LEFT | BACK_RIGHT);
|
||||||
|
static const LAYOUT_7POINT0_FRONT = (LAYOUT_5POINT0 | FRONT_LEFT_OF_CENTER | FRONT_RIGHT_OF_CENTER);
|
||||||
|
static const LAYOUT_7POINT1 = (LAYOUT_5POINT1 | BACK_LEFT | BACK_RIGHT);
|
||||||
|
static const LAYOUT_7POINT1_WIDE = (LAYOUT_5POINT1 | FRONT_LEFT_OF_CENTER | FRONT_RIGHT_OF_CENTER);
|
||||||
|
static const LAYOUT_7POINT1_WIDE_BACK = (LAYOUT_5POINT1_BACK | FRONT_LEFT_OF_CENTER | FRONT_RIGHT_OF_CENTER);
|
||||||
|
static const LAYOUT_OCTAGONAL = (LAYOUT_5POINT0 | BACK_LEFT | BACK_CENTER | BACK_RIGHT);
|
||||||
|
static const LAYOUT_HEXADECAGONAL = (LAYOUT_OCTAGONAL | WIDE_LEFT | WIDE_RIGHT | TOP_BACK_LEFT | TOP_BACK_RIGHT | TOP_BACK_CENTER | TOP_FRONT_CENTER | TOP_FRONT_LEFT | TOP_FRONT_RIGHT);
|
||||||
|
static const LAYOUT_STEREO_DOWNMIX = (STEREO_LEFT | STEREO_RIGHT);
|
||||||
|
|
||||||
|
static const names = {
|
||||||
|
LAYOUT_NATIVE: 'native',
|
||||||
|
LAYOUT_MONO: 'mono',
|
||||||
|
LAYOUT_STEREO: 'stereo 2.0 • FL FR',
|
||||||
|
LAYOUT_2POINT1: 'stereo 2.1 • FL FR LFE',
|
||||||
|
LAYOUT_2_1: 'surround 3.0 • FL FR BC',
|
||||||
|
LAYOUT_SURROUND: 'stereo 3.0 • FL FR FC',
|
||||||
|
LAYOUT_3POINT1: 'stereo 3.1 • FL FR FC LFE',
|
||||||
|
LAYOUT_4POINT0: 'surround 4.0 • FL FR FC BC',
|
||||||
|
LAYOUT_4POINT1: 'surround 4.1 • FL FR FC BC LFE',
|
||||||
|
LAYOUT_2_2: 'quad (side) • FL FR SL SR',
|
||||||
|
LAYOUT_QUAD: 'quad (back) • FL FR BL BR',
|
||||||
|
LAYOUT_5POINT0: '5.0 (side) • FL FR FC SL SR',
|
||||||
|
LAYOUT_5POINT1: '5.1 (side) • FL FR FC SL SR LFE',
|
||||||
|
LAYOUT_5POINT0_BACK: '5.0 (back) • FL FR FC BL BR',
|
||||||
|
LAYOUT_5POINT1_BACK: '5.1 (back) • FL FR FC BL BR LFE',
|
||||||
|
LAYOUT_6POINT0: '6.0 (side) • FL FR FC SL SR BC',
|
||||||
|
LAYOUT_6POINT0_FRONT: '6.0 (front) • FL FR FLC FRC SL SR',
|
||||||
|
LAYOUT_HEXAGONAL: 'hexagonal • FL FR FC BL BR BC',
|
||||||
|
LAYOUT_6POINT1: '6.1 (side) • FL FR FC SL SR BC LFE',
|
||||||
|
LAYOUT_6POINT1_BACK: '6.1 (back) • FL FR FC BL BR BC LFE',
|
||||||
|
LAYOUT_6POINT1_FRONT: '6.1 (front) • FL FR FLC FRC SL SR LFE',
|
||||||
|
LAYOUT_7POINT0: 'surround 7.0 • FL FR FC SL SR BL BR',
|
||||||
|
LAYOUT_7POINT0_FRONT: 'wide 7.0 • FL FR FC FLC FRC SL SR',
|
||||||
|
LAYOUT_7POINT1: 'surround 7.1 • FL FR FC SL SR BL BR LFE',
|
||||||
|
LAYOUT_7POINT1_WIDE: 'wide 7.1 • FL FR FC FLC FRC SL SR LFE',
|
||||||
|
LAYOUT_7POINT1_WIDE_BACK: 'wide 7.1 (back) • FL FR FC FLC FRC BL BR LFE',
|
||||||
|
LAYOUT_OCTAGONAL: 'octagonal • FL FR FC SL SR BL BR BC',
|
||||||
|
LAYOUT_HEXADECAGONAL: 'hexadecagonal • FL FR FC WL WR TFL TFR TFC SL SR BL BR BC TBL TBR TBC',
|
||||||
|
LAYOUT_STEREO_DOWNMIX: 'stereo downmix',
|
||||||
|
};
|
||||||
|
}
|
66
lib/model/video/h264.dart
Normal file
66
lib/model/video/h264.dart
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
class H264 {
|
||||||
|
static const profileConstrained = 1 << 9;
|
||||||
|
static const profileIntra = 1 << 11;
|
||||||
|
static const profileBaseline = 66;
|
||||||
|
static const profileConstrainedBaseline = 66 | profileConstrained;
|
||||||
|
static const profileMain = 77;
|
||||||
|
static const profileExtended = 88;
|
||||||
|
static const profileHigh = 100;
|
||||||
|
static const profileHigh10 = 110;
|
||||||
|
static const profileHigh10Intra = 110 | profileIntra;
|
||||||
|
static const profileHigh422 = 122;
|
||||||
|
static const profileHigh422Intra = 122 | profileIntra;
|
||||||
|
static const profileHigh444 = 144;
|
||||||
|
static const profileHigh444Predictive = 244;
|
||||||
|
static const profileHigh444Intra = 244 | profileIntra;
|
||||||
|
static const profileCAVLC444 = 44;
|
||||||
|
|
||||||
|
static String formatProfile(int profileIndex, int level) {
|
||||||
|
String profile;
|
||||||
|
switch (profileIndex) {
|
||||||
|
case profileBaseline:
|
||||||
|
profile = 'Baseline';
|
||||||
|
break;
|
||||||
|
case profileConstrainedBaseline:
|
||||||
|
profile = 'Constrained Baseline';
|
||||||
|
break;
|
||||||
|
case profileMain:
|
||||||
|
profile = 'Main';
|
||||||
|
break;
|
||||||
|
case profileExtended:
|
||||||
|
profile = 'Extended';
|
||||||
|
break;
|
||||||
|
case profileHigh:
|
||||||
|
profile = 'High';
|
||||||
|
break;
|
||||||
|
case profileHigh10:
|
||||||
|
profile = 'High 10';
|
||||||
|
break;
|
||||||
|
case profileHigh10Intra:
|
||||||
|
profile = 'High 10 Intra';
|
||||||
|
break;
|
||||||
|
case profileHigh422:
|
||||||
|
profile = 'High 4:2:2';
|
||||||
|
break;
|
||||||
|
case profileHigh422Intra:
|
||||||
|
profile = 'High 4:2:2 Intra';
|
||||||
|
break;
|
||||||
|
case profileHigh444:
|
||||||
|
profile = 'High 4:4:4';
|
||||||
|
break;
|
||||||
|
case profileHigh444Predictive:
|
||||||
|
profile = 'High 4:4:4 Predictive';
|
||||||
|
break;
|
||||||
|
case profileHigh444Intra:
|
||||||
|
profile = 'High 4:4:4 Intra';
|
||||||
|
break;
|
||||||
|
case profileCAVLC444:
|
||||||
|
profile = 'CAVLC 4:4:4';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return '$profileIndex';
|
||||||
|
}
|
||||||
|
if (level < 10) return profile;
|
||||||
|
return '$profile Profile, Level ${level % 10 == 0 ? level ~/ 10 : level / 10}';
|
||||||
|
}
|
||||||
|
}
|
52
lib/model/video/keys.dart
Normal file
52
lib/model/video/keys.dart
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
// keys returned by fijkplayer when getting media and streams info
|
||||||
|
// they originate from FFmpeg, fijkplayer, and other software
|
||||||
|
// that write additional metadata to media files
|
||||||
|
class Keys {
|
||||||
|
static const androidCaptureFramerate = 'com.android.capture.fps';
|
||||||
|
static const androidVersion = 'com.android.version';
|
||||||
|
static const bps = 'bps';
|
||||||
|
static const bitrate = 'bitrate';
|
||||||
|
static const byteCount = 'number_of_bytes';
|
||||||
|
static const channelLayout = 'channel_layout';
|
||||||
|
static const codecLevel = 'codec_level';
|
||||||
|
static const codecName = 'codec_name';
|
||||||
|
static const codecPixelFormat = 'codec_pixel_format';
|
||||||
|
static const codecProfileId = 'codec_profile_id';
|
||||||
|
static const compatibleBrands = 'compatible_brands';
|
||||||
|
static const creationTime = 'creation_time';
|
||||||
|
static const date = 'date';
|
||||||
|
static const duration = 'duration';
|
||||||
|
static const durationMicros = 'duration_us';
|
||||||
|
static const encoder = 'encoder';
|
||||||
|
static const filename = 'filename';
|
||||||
|
static const fpsDen = 'fps_den';
|
||||||
|
static const fpsNum = 'fps_num';
|
||||||
|
static const frameCount = 'number_of_frames';
|
||||||
|
static const handlerName = 'handler_name';
|
||||||
|
static const height = 'height';
|
||||||
|
static const index = 'index';
|
||||||
|
static const language = 'language';
|
||||||
|
static const location = 'location';
|
||||||
|
static const majorBrand = 'major_brand';
|
||||||
|
static const mediaFormat = 'format';
|
||||||
|
static const mediaType = 'media_type';
|
||||||
|
static const minorVersion = 'minor_version';
|
||||||
|
static const rotate = 'rotate';
|
||||||
|
static const sampleRate = 'sample_rate';
|
||||||
|
static const sarDen = 'sar_den';
|
||||||
|
static const sarNum = 'sar_num';
|
||||||
|
static const selectedAudioStream = 'audio';
|
||||||
|
static const selectedTextStream = 'timedtext';
|
||||||
|
static const selectedVideoStream = 'video';
|
||||||
|
static const startMicros = 'start_us';
|
||||||
|
static const statisticsTags = '_statistics_tags';
|
||||||
|
static const statisticsWritingApp = '_statistics_writing_app';
|
||||||
|
static const statisticsWritingDateUtc = '_statistics_writing_date_utc';
|
||||||
|
static const streams = 'streams';
|
||||||
|
static const tbrDen = 'tbr_den';
|
||||||
|
static const tbrNum = 'tbr_num';
|
||||||
|
static const streamType = 'type';
|
||||||
|
static const title = 'title';
|
||||||
|
static const track = 'track';
|
||||||
|
static const width = 'width';
|
||||||
|
}
|
301
lib/model/video/metadata.dart
Normal file
301
lib/model/video/metadata.dart
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/video/channel_layouts.dart';
|
||||||
|
import 'package:aves/model/video/h264.dart';
|
||||||
|
import 'package:aves/model/video/keys.dart';
|
||||||
|
import 'package:aves/ref/languages.dart';
|
||||||
|
import 'package:aves/ref/mp4.dart';
|
||||||
|
import 'package:aves/utils/file_utils.dart';
|
||||||
|
import 'package:aves/utils/math_utils.dart';
|
||||||
|
import 'package:aves/utils/string_utils.dart';
|
||||||
|
import 'package:aves/utils/time_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/video/fijkplayer.dart';
|
||||||
|
import 'package:fijkplayer/fijkplayer.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class VideoMetadataFormatter {
|
||||||
|
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||||
|
static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
|
||||||
|
static final _locationPattern = RegExp(r'([+-][.0-9]+)');
|
||||||
|
static final Map<String, String> _codecNames = {
|
||||||
|
'ac3': 'AC-3',
|
||||||
|
'eac3': 'E-AC-3',
|
||||||
|
'h264': 'AVC (H.264)',
|
||||||
|
'hdmv_pgs_subtitle': 'PGS',
|
||||||
|
'hevc': 'HEVC (H.265)',
|
||||||
|
'matroska': 'Matroska',
|
||||||
|
'mpeg4': 'MPEG-4 Visual',
|
||||||
|
'mpegts': 'MPEG-TS',
|
||||||
|
'subrip': 'SubRip',
|
||||||
|
'webm': 'WebM',
|
||||||
|
};
|
||||||
|
|
||||||
|
static Future<Map> getVideoMetadata(AvesEntry entry) async {
|
||||||
|
final player = FijkPlayer();
|
||||||
|
await player.setDataSourceUntilPrepared(entry.uri);
|
||||||
|
final info = await player.getInfo();
|
||||||
|
await player.release();
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pattern to extract optional language code suffix, e.g. 'location-eng'
|
||||||
|
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$');
|
||||||
|
|
||||||
|
static Map<String, String> formatInfo(Map info) {
|
||||||
|
final dir = <String, String>{};
|
||||||
|
final streamType = info[Keys.streamType];
|
||||||
|
final codec = info[Keys.codecName];
|
||||||
|
for (final kv in info.entries) {
|
||||||
|
final value = kv.value;
|
||||||
|
if (value != null) {
|
||||||
|
try {
|
||||||
|
String key;
|
||||||
|
String keyLanguage;
|
||||||
|
// some keys have a language suffix, but they may be duplicates
|
||||||
|
// we only keep the root key when they have the same value as the same key with no language
|
||||||
|
final languageMatch = keyWithLanguagePattern.firstMatch(kv.key);
|
||||||
|
if (languageMatch != null) {
|
||||||
|
final code = languageMatch.group(2);
|
||||||
|
final native = _formatLanguage(code);
|
||||||
|
if (native != code) {
|
||||||
|
final root = languageMatch.group(1);
|
||||||
|
final rootValue = info[root];
|
||||||
|
// skip if it is a duplicate of the same entry with no language
|
||||||
|
if (rootValue == value) continue;
|
||||||
|
key = root;
|
||||||
|
if (info.keys.cast<String>().where((k) => k.startsWith('$root-')).length > 1) {
|
||||||
|
// only keep language when multiple languages are present
|
||||||
|
keyLanguage = native;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key = (key ?? (kv.key as String)).toLowerCase();
|
||||||
|
|
||||||
|
void save(String key, String value) {
|
||||||
|
if (value != null) {
|
||||||
|
dir[keyLanguage != null ? '$key ($keyLanguage)' : key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case Keys.codecLevel:
|
||||||
|
case Keys.fpsNum:
|
||||||
|
case Keys.handlerName:
|
||||||
|
case Keys.index:
|
||||||
|
case Keys.sarNum:
|
||||||
|
case Keys.selectedAudioStream:
|
||||||
|
case Keys.selectedTextStream:
|
||||||
|
case Keys.selectedVideoStream:
|
||||||
|
case Keys.statisticsTags:
|
||||||
|
case Keys.streams:
|
||||||
|
case Keys.streamType:
|
||||||
|
case Keys.tbrNum:
|
||||||
|
case Keys.tbrDen:
|
||||||
|
break;
|
||||||
|
case Keys.androidCaptureFramerate:
|
||||||
|
final captureFps = double.parse(value);
|
||||||
|
save('Capture Frame Rate', '${roundToPrecision(captureFps, decimals: 3).toString()} FPS');
|
||||||
|
break;
|
||||||
|
case Keys.androidVersion:
|
||||||
|
save('Android Version', value);
|
||||||
|
break;
|
||||||
|
case Keys.bitrate:
|
||||||
|
case Keys.bps:
|
||||||
|
save('Bit Rate', _formatMetric(value, 'b/s'));
|
||||||
|
break;
|
||||||
|
case Keys.byteCount:
|
||||||
|
save('Size', _formatFilesize(value));
|
||||||
|
break;
|
||||||
|
case Keys.channelLayout:
|
||||||
|
save('Channel Layout', _formatChannelLayout(value));
|
||||||
|
break;
|
||||||
|
case Keys.codecName:
|
||||||
|
save('Format', _formatCodecName(value));
|
||||||
|
break;
|
||||||
|
case Keys.codecPixelFormat:
|
||||||
|
if (streamType == StreamTypes.video) {
|
||||||
|
// this is just a short name used by FFmpeg
|
||||||
|
// user-friendly descriptions for related enums are defined in libavutil/pixfmt.h
|
||||||
|
save('Pixel Format', (value as String).toUpperCase());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Keys.codecProfileId:
|
||||||
|
if (codec == 'h264') {
|
||||||
|
final profile = int.tryParse(value);
|
||||||
|
if (profile != null && profile != 0) {
|
||||||
|
final level = int.tryParse(info[Keys.codecLevel]);
|
||||||
|
save('Codec Profile', H264.formatProfile(profile, level));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Keys.compatibleBrands:
|
||||||
|
save('Compatible Brands', RegExp(r'.{4}').allMatches(value).map((m) => _formatBrand(m.group(0))).join(', '));
|
||||||
|
break;
|
||||||
|
case Keys.creationTime:
|
||||||
|
save('Creation Time', _formatDate(value));
|
||||||
|
break;
|
||||||
|
case Keys.date:
|
||||||
|
if (value != '0') {
|
||||||
|
final charCount = (value as String)?.length ?? 0;
|
||||||
|
save(charCount == 4 ? 'Year' : 'Date', value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Keys.duration:
|
||||||
|
save('Duration', _formatDuration(value));
|
||||||
|
break;
|
||||||
|
case Keys.durationMicros:
|
||||||
|
if (value != 0) save('Duration', formatPreciseDuration(Duration(microseconds: value)));
|
||||||
|
break;
|
||||||
|
case Keys.fpsDen:
|
||||||
|
save('Frame Rate', '${roundToPrecision(info[Keys.fpsNum] / info[Keys.fpsDen], decimals: 3).toString()} FPS');
|
||||||
|
break;
|
||||||
|
case Keys.frameCount:
|
||||||
|
save('Frame Count', value);
|
||||||
|
break;
|
||||||
|
case Keys.height:
|
||||||
|
save('Height', '$value pixels');
|
||||||
|
break;
|
||||||
|
case Keys.language:
|
||||||
|
if (value != 'und') save('Language', _formatLanguage(value));
|
||||||
|
break;
|
||||||
|
case Keys.location:
|
||||||
|
save('Location', _formatLocation(value));
|
||||||
|
break;
|
||||||
|
case Keys.majorBrand:
|
||||||
|
save('Major Brand', _formatBrand(value));
|
||||||
|
break;
|
||||||
|
case Keys.mediaFormat:
|
||||||
|
save('Format', (value as String).splitMapJoin(',', onMatch: (s) => ', ', onNonMatch: _formatCodecName));
|
||||||
|
break;
|
||||||
|
case Keys.mediaType:
|
||||||
|
save('Media Type', value);
|
||||||
|
break;
|
||||||
|
case Keys.minorVersion:
|
||||||
|
if (value != '0') save('Minor Version', value);
|
||||||
|
break;
|
||||||
|
case Keys.rotate:
|
||||||
|
save('Rotation', '$value°');
|
||||||
|
break;
|
||||||
|
case Keys.sampleRate:
|
||||||
|
save('Sample Rate', _formatMetric(value, 'Hz'));
|
||||||
|
break;
|
||||||
|
case Keys.sarDen:
|
||||||
|
final sarNum = info[Keys.sarNum];
|
||||||
|
final sarDen = info[Keys.sarDen];
|
||||||
|
// skip common square pixels (1:1)
|
||||||
|
if (sarNum != sarDen) save('SAR', '$sarNum:$sarDen');
|
||||||
|
break;
|
||||||
|
case Keys.startMicros:
|
||||||
|
if (value != 0) save('Start', formatPreciseDuration(Duration(microseconds: value)));
|
||||||
|
break;
|
||||||
|
case Keys.statisticsWritingApp:
|
||||||
|
save('Stats Writing App', value);
|
||||||
|
break;
|
||||||
|
case Keys.statisticsWritingDateUtc:
|
||||||
|
save('Stats Writing Date', _formatDate(value));
|
||||||
|
break;
|
||||||
|
case Keys.track:
|
||||||
|
if (value != '0') save('Track', value);
|
||||||
|
break;
|
||||||
|
case Keys.width:
|
||||||
|
save('Width', '$value pixels');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
save(key.toSentenceCase(), value.toString());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('failed to process video info key=${kv.key} value=${kv.value}, error=$error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatBrand(String value) => Mp4.brands[value] ?? value;
|
||||||
|
|
||||||
|
static String _formatChannelLayout(value) => ChannelLayouts.names[value] ?? 'unknown ($value)';
|
||||||
|
|
||||||
|
static String _formatCodecName(String value) => _codecNames[value] ?? value?.toUpperCase()?.replaceAll('_', ' ');
|
||||||
|
|
||||||
|
// input example: '2021-04-12T09:14:37.000000Z'
|
||||||
|
static String _formatDate(String value) {
|
||||||
|
final date = DateTime.tryParse(value);
|
||||||
|
if (date == null) return value;
|
||||||
|
if (date == _epoch) return null;
|
||||||
|
return date.toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
// input example: '00:00:05.408000000'
|
||||||
|
static String _formatDuration(String value) {
|
||||||
|
final match = _durationPattern.firstMatch(value);
|
||||||
|
if (match != null) {
|
||||||
|
final h = int.tryParse(match.group(1));
|
||||||
|
final m = int.tryParse(match.group(2));
|
||||||
|
final s = int.tryParse(match.group(3));
|
||||||
|
final millis = double.tryParse(match.group(4));
|
||||||
|
if (h != null && m != null && s != null && millis != null) {
|
||||||
|
return formatPreciseDuration(Duration(
|
||||||
|
hours: h,
|
||||||
|
minutes: m,
|
||||||
|
seconds: s,
|
||||||
|
milliseconds: (millis * 1000).toInt(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatFilesize(String value) {
|
||||||
|
final size = int.tryParse(value);
|
||||||
|
return size != null ? formatFilesize(size) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatLanguage(String value) {
|
||||||
|
final language = Language.living639_2.firstWhere((language) => language.iso639_2 == value, orElse: () => null);
|
||||||
|
return language?.native ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// format ISO 6709 input, e.g. '+37.5090+127.0243/' (Samsung), '+51.3328-000.7053+113.474/' (Apple)
|
||||||
|
static String _formatLocation(String value) {
|
||||||
|
final matches = _locationPattern.allMatches(value);
|
||||||
|
if (matches.isNotEmpty) {
|
||||||
|
final coordinates = matches.map((m) => double.tryParse(m.group(0))).toList();
|
||||||
|
if (coordinates.every((c) => c == 0)) return null;
|
||||||
|
return coordinates.join(', ');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatMetric(dynamic size, String unit, {int round = 2}) {
|
||||||
|
if (size is String) {
|
||||||
|
final parsed = int.tryParse(size);
|
||||||
|
if (parsed == null) return size;
|
||||||
|
size = parsed;
|
||||||
|
}
|
||||||
|
const divider = 1000;
|
||||||
|
|
||||||
|
if (size < divider) return '$size $unit';
|
||||||
|
|
||||||
|
if (size < divider * divider && size % divider == 0) {
|
||||||
|
return '${(size / divider).toStringAsFixed(0)} K$unit';
|
||||||
|
}
|
||||||
|
if (size < divider * divider) {
|
||||||
|
return '${(size / divider).toStringAsFixed(round)} K$unit';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < divider * divider * divider && size % divider == 0) {
|
||||||
|
return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit';
|
||||||
|
}
|
||||||
|
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamTypes {
|
||||||
|
static const audio = 'audio';
|
||||||
|
static const metadata = 'metadata';
|
||||||
|
static const subtitle = 'subtitle';
|
||||||
|
static const timedText = 'timedtext';
|
||||||
|
static const unknown = 'unknown';
|
||||||
|
static const video = 'video';
|
||||||
|
}
|
402
lib/ref/languages.dart
Normal file
402
lib/ref/languages.dart
Normal file
|
@ -0,0 +1,402 @@
|
||||||
|
class Language {
|
||||||
|
final String iso639_2, name, native;
|
||||||
|
|
||||||
|
const Language({
|
||||||
|
this.iso639_2,
|
||||||
|
this.name,
|
||||||
|
this.native,
|
||||||
|
});
|
||||||
|
|
||||||
|
// subset of ISO 639-2 codes for living languages (including macrolanguages, excluding constructed and collective ones)
|
||||||
|
// synonyms for terminology and bibliographic applications (ISO 639-2/T and ISO 639-2/B) are separate entries
|
||||||
|
// some entries have been simplified to keep only one name for one code
|
||||||
|
static const living639_2 = [
|
||||||
|
Language(iso639_2: 'aar', name: 'Afar', native: 'Qafaraf; ’Afar Af; Afaraf; Qafar af'),
|
||||||
|
Language(iso639_2: 'abk', name: 'Abkhazian', native: 'Аҧсуа бызшәа Aƥsua bızšwa; Аҧсшәа Aƥsua'),
|
||||||
|
Language(iso639_2: 'ace', name: 'Achinese', native: 'بهسا اچيه'),
|
||||||
|
Language(iso639_2: 'ach', name: 'Acoli', native: 'Lwo'),
|
||||||
|
Language(iso639_2: 'ada', name: 'Adangme', native: 'Dangme'),
|
||||||
|
Language(iso639_2: 'ady', name: 'Adyghe; Adygei', native: 'Адыгабзэ; Кӏахыбзэ'),
|
||||||
|
Language(iso639_2: 'afr', name: 'Afrikaans', native: 'Afrikaans'),
|
||||||
|
Language(iso639_2: 'ain', name: 'Ainu', native: 'アイヌ・イタㇰ Ainu-itak'),
|
||||||
|
Language(iso639_2: 'aka', name: 'Akan', native: 'Akan'),
|
||||||
|
Language(iso639_2: 'ale', name: 'Aleut', native: 'Уна́ӈам тунуу́; Унаӈан умсуу'),
|
||||||
|
Language(iso639_2: 'alt', name: 'Southern Altai', native: 'Алтай тили'),
|
||||||
|
Language(iso639_2: 'amh', name: 'Amharic', native: 'አማርኛ Amârıñâ'),
|
||||||
|
Language(iso639_2: 'anp', name: 'Angika'),
|
||||||
|
Language(iso639_2: 'ara', name: 'Arabic', native: 'العربية'),
|
||||||
|
Language(iso639_2: 'arg', name: 'Aragonese', native: 'aragonés'),
|
||||||
|
Language(iso639_2: 'arn', name: 'Mapudungun; Mapuche'),
|
||||||
|
Language(iso639_2: 'arp', name: 'Arapaho', native: 'Hinónoʼeitíít'),
|
||||||
|
Language(iso639_2: 'arw', name: 'Arawak', native: 'Lokono'),
|
||||||
|
Language(iso639_2: 'asm', name: 'Assamese', native: 'অসমীয়া'),
|
||||||
|
Language(iso639_2: 'ast', name: 'Asturian', native: 'asturianu'),
|
||||||
|
Language(iso639_2: 'ava', name: 'Avaric', native: 'Магӏарул мацӏ; Авар мацӏ'),
|
||||||
|
Language(iso639_2: 'awa', name: 'Awadhi', native: 'अवधी'),
|
||||||
|
Language(iso639_2: 'aym', name: 'Aymara', native: 'Aymar aru'),
|
||||||
|
Language(iso639_2: 'aze', name: 'Azerbaijani', native: 'Azərbaycan dili; آذربایجان دیلی; Азәрбајҹан дили'),
|
||||||
|
Language(iso639_2: 'bak', name: 'Bashkir', native: 'Башҡорт теле; Başqort tele'),
|
||||||
|
Language(iso639_2: 'bal', name: 'Baluchi', native: 'بلوچی'),
|
||||||
|
Language(iso639_2: 'bam', name: 'Bambara', native: 'ߓߊߡߊߣߊߣߞߊߣ'),
|
||||||
|
Language(iso639_2: 'ban', name: 'Balinese', native: 'ᬪᬵᬱᬩᬮᬶ; ᬩᬲᬩᬮᬶ; Basa Bali'),
|
||||||
|
Language(iso639_2: 'bas', name: 'Basa', native: 'Mbene; Ɓasaá'),
|
||||||
|
Language(iso639_2: 'bej', name: 'Beja; Bedawiyet', native: 'Bidhaawyeet'),
|
||||||
|
Language(iso639_2: 'bel', name: 'Belarusian', native: 'Беларуская мова Belaruskaâ mova'),
|
||||||
|
Language(iso639_2: 'bem', name: 'Bemba', native: 'Chibemba'),
|
||||||
|
Language(iso639_2: 'ben', name: 'Bengali', native: 'বাংলা Bāŋlā'),
|
||||||
|
Language(iso639_2: 'bho', name: 'Bhojpuri', native: 'भोजपुरी'),
|
||||||
|
Language(iso639_2: 'bik', name: 'Bikol'),
|
||||||
|
Language(iso639_2: 'bin', name: 'Bini; Edo', native: 'Ẹ̀dó'),
|
||||||
|
Language(iso639_2: 'bis', name: 'Bislama'),
|
||||||
|
Language(iso639_2: 'bla', name: 'Siksika', native: 'ᓱᖽᐧᖿ'),
|
||||||
|
Language(iso639_2: 'bod', name: 'Tibetan', native: 'བོད་སྐད་; ལྷ་སའི་སྐད་'),
|
||||||
|
Language(iso639_2: 'tib', name: 'Tibetan', native: 'བོད་སྐད་; ལྷ་སའི་སྐད་'),
|
||||||
|
Language(iso639_2: 'bos', name: 'Bosnian', native: 'bosanski'),
|
||||||
|
Language(iso639_2: 'bra', name: 'Braj'),
|
||||||
|
Language(iso639_2: 'bre', name: 'Breton', native: 'Brezhoneg'),
|
||||||
|
Language(iso639_2: 'bua', name: 'Buriat', native: 'буряад хэлэн'),
|
||||||
|
Language(iso639_2: 'bug', name: 'Buginese', native: 'ᨅᨔ ᨕᨘᨁᨗ'),
|
||||||
|
Language(iso639_2: 'bul', name: 'Bulgarian', native: 'български'),
|
||||||
|
Language(iso639_2: 'byn', name: 'Blin; Bilin', native: 'ብሊና; ብሊን'),
|
||||||
|
Language(iso639_2: 'cad', name: 'Caddo', native: 'Hasí:nay'),
|
||||||
|
Language(iso639_2: 'car', name: 'Galibi Carib', native: 'Kari\'nja'),
|
||||||
|
Language(iso639_2: 'cat', name: 'Catalan', native: 'català'),
|
||||||
|
Language(iso639_2: 'ceb', name: 'Cebuano', native: 'Sinugbuanong Binisayâ'),
|
||||||
|
Language(iso639_2: 'ces', name: 'Czech', native: 'čeština'),
|
||||||
|
Language(iso639_2: 'cze', name: 'Czech', native: 'čeština'),
|
||||||
|
Language(iso639_2: 'cha', name: 'Chamorro', native: 'Finu\' Chamoru'),
|
||||||
|
Language(iso639_2: 'che', name: 'Chechen', native: 'Нохчийн мотт; نَاخچیین موٓتت; ნახჩიე მუოთთ'),
|
||||||
|
Language(iso639_2: 'chk', name: 'Chuukese'),
|
||||||
|
Language(iso639_2: 'chm', name: 'Mari', native: 'марий йылме'),
|
||||||
|
Language(iso639_2: 'chn', name: 'Chinook jargon', native: 'chinuk wawa; wawa; chinook lelang; lelang'),
|
||||||
|
Language(iso639_2: 'cho', name: 'Choctaw', native: 'Chahta\''),
|
||||||
|
Language(iso639_2: 'chp', name: 'Chipewyan; Dene Suline', native: 'ᑌᓀᓱᒼᕄᓀ (Dënesųłiné)'),
|
||||||
|
Language(iso639_2: 'chr', name: 'Cherokee', native: 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ Tsalagi gawonihisdi'),
|
||||||
|
Language(iso639_2: 'chv', name: 'Chuvash', native: 'Чӑвашла'),
|
||||||
|
Language(iso639_2: 'chy', name: 'Cheyenne', native: 'Tsėhésenėstsestȯtse'),
|
||||||
|
Language(iso639_2: 'cnr', name: 'Montenegrin', native: 'crnogorski / црногорски'),
|
||||||
|
Language(iso639_2: 'cor', name: 'Cornish', native: 'Kernowek'),
|
||||||
|
Language(iso639_2: 'cos', name: 'Corsican', native: 'Corsu; Lingua corsa'),
|
||||||
|
Language(iso639_2: 'cre', name: 'Cree'),
|
||||||
|
Language(iso639_2: 'crh', name: 'Crimean Tatar; Crimean Turkish', native: 'Къырымтатарджа; Къырымтатар тили; Ҡырымтатарҗа; Ҡырымтатар тили'),
|
||||||
|
Language(iso639_2: 'csb', name: 'Kashubian', native: 'Kaszëbsczi jãzëk'),
|
||||||
|
Language(iso639_2: 'cym', name: 'Welsh', native: 'Cymraeg; y Gymraeg'),
|
||||||
|
Language(iso639_2: 'wel', name: 'Welsh', native: 'Cymraeg; y Gymraeg'),
|
||||||
|
Language(iso639_2: 'dak', name: 'Dakota', native: 'Dakhótiyapi; Dakȟótiyapi'),
|
||||||
|
Language(iso639_2: 'dan', name: 'Danish', native: 'dansk'),
|
||||||
|
Language(iso639_2: 'dar', name: 'Dargwa', native: 'дарган мез'),
|
||||||
|
Language(iso639_2: 'del', name: 'Delaware'),
|
||||||
|
Language(iso639_2: 'den', name: 'Slave (Athapascan)', native: 'Dene K\'e'),
|
||||||
|
Language(iso639_2: 'deu', name: 'German', native: 'Deutsch'),
|
||||||
|
Language(iso639_2: 'ger', name: 'German', native: 'Deutsch'),
|
||||||
|
Language(iso639_2: 'dgr', name: 'Dogrib'),
|
||||||
|
Language(iso639_2: 'din', name: 'Dinka', native: 'Thuɔŋjäŋ'),
|
||||||
|
Language(iso639_2: 'div', name: 'Divehi; Dhivehi; Maldivian', native: 'ދިވެހި; ދިވެހިބަސް Divehi'),
|
||||||
|
Language(iso639_2: 'doi', name: 'Dogri', native: '𑠖𑠵𑠌𑠤𑠮; डोगरी; ڈوگرى'),
|
||||||
|
Language(iso639_2: 'dsb', name: 'Lower Sorbian', native: 'Dolnoserbski; Dolnoserbšćina'),
|
||||||
|
Language(iso639_2: 'dua', name: 'Duala'),
|
||||||
|
Language(iso639_2: 'dyu', name: 'Dyula', native: 'Julakan'),
|
||||||
|
Language(iso639_2: 'dzo', name: 'Dzongkha', native: 'རྫོང་ཁ་ Ĵoŋkha'),
|
||||||
|
Language(iso639_2: 'efi', name: 'Efik'),
|
||||||
|
Language(iso639_2: 'eka', name: 'Ekajuk'),
|
||||||
|
Language(iso639_2: 'ell', name: 'Greek', native: 'Ελληνικά'),
|
||||||
|
Language(iso639_2: 'gre', name: 'Greek', native: 'Ελληνικά'),
|
||||||
|
Language(iso639_2: 'eng', name: 'English', native: 'English'),
|
||||||
|
Language(iso639_2: 'est', name: 'Estonian', native: 'eesti'),
|
||||||
|
Language(iso639_2: 'eus', name: 'Basque', native: 'euskara'),
|
||||||
|
Language(iso639_2: 'baq', name: 'Basque', native: 'euskara'),
|
||||||
|
Language(iso639_2: 'ewe', name: 'Ewe', native: 'Èʋegbe'),
|
||||||
|
Language(iso639_2: 'ewo', name: 'Ewondo'),
|
||||||
|
Language(iso639_2: 'fan', name: 'Fang'),
|
||||||
|
Language(iso639_2: 'fao', name: 'Faroese', native: 'føroyskt'),
|
||||||
|
Language(iso639_2: 'fas', name: 'Persian', native: 'فارسی'),
|
||||||
|
Language(iso639_2: 'per', name: 'Persian', native: 'فارسی'),
|
||||||
|
Language(iso639_2: 'fat', name: 'Fanti', native: 'Mfantse; Fante; Fanti'),
|
||||||
|
Language(iso639_2: 'fij', name: 'Fijian', native: 'Na Vosa Vakaviti'),
|
||||||
|
Language(iso639_2: 'fil', name: 'Filipino; Pilipino', native: 'Wikang Filipino'),
|
||||||
|
Language(iso639_2: 'fin', name: 'Finnish', native: 'suomi'),
|
||||||
|
Language(iso639_2: 'fon', name: 'Fon', native: 'Fon gbè'),
|
||||||
|
Language(iso639_2: 'fra', name: 'French', native: 'français'),
|
||||||
|
Language(iso639_2: 'fre', name: 'French', native: 'français'),
|
||||||
|
Language(iso639_2: 'frr', name: 'Northern Frisian', native: 'Frasch; Fresk; Freesk; Friisk'),
|
||||||
|
Language(iso639_2: 'frs', name: 'East Frisian Low Saxon', native: 'Oostfreesk; Plattdüütsk'),
|
||||||
|
Language(iso639_2: 'fry', name: 'Western Frisian', native: 'Frysk'),
|
||||||
|
Language(iso639_2: 'ful', name: 'Fulah', native: 'Fulfulde; Pulaar; Pular'),
|
||||||
|
Language(iso639_2: 'fur', name: 'Friulian', native: 'Furlan'),
|
||||||
|
Language(iso639_2: 'gaa', name: 'Ga', native: 'Gã'),
|
||||||
|
Language(iso639_2: 'gay', name: 'Gayo', native: 'Basa Gayo'),
|
||||||
|
Language(iso639_2: 'gba', name: 'Gbaya'),
|
||||||
|
Language(iso639_2: 'gil', name: 'Gilbertese', native: 'Taetae ni Kiribati'),
|
||||||
|
Language(iso639_2: 'gla', name: 'Gaelic; Scottish Gaelic', native: 'Gàidhlig'),
|
||||||
|
Language(iso639_2: 'gle', name: 'Irish', native: 'Gaeilge'),
|
||||||
|
Language(iso639_2: 'glg', name: 'Galician', native: 'galego'),
|
||||||
|
Language(iso639_2: 'glv', name: 'Manx', native: 'Gaelg; Gailck'),
|
||||||
|
Language(iso639_2: 'gon', name: 'Gondi'),
|
||||||
|
Language(iso639_2: 'gor', name: 'Gorontalo', native: 'Bahasa Hulontalo'),
|
||||||
|
Language(iso639_2: 'grb', name: 'Grebo'),
|
||||||
|
Language(iso639_2: 'grn', name: 'Guarani', native: 'Avañe\'ẽ'),
|
||||||
|
Language(iso639_2: 'gsw', name: 'Swiss German; Alemannic; Alsatian', native: 'Schwiizerdütsch'),
|
||||||
|
Language(iso639_2: 'guj', name: 'Gujarati', native: 'ગુજરાતી Gujarātī'),
|
||||||
|
Language(iso639_2: 'gwi', name: 'Gwich\'in', native: 'Dinjii Zhu’ Ginjik'),
|
||||||
|
Language(iso639_2: 'hai', name: 'Haida', native: 'X̱aat Kíl; X̱aadas Kíl; X̱aayda Kil; Xaad kil'),
|
||||||
|
Language(iso639_2: 'hat', name: 'Haitian; Haitian Creole', native: 'kreyòl ayisyen'),
|
||||||
|
Language(iso639_2: 'hau', name: 'Hausa', native: 'Harshen Hausa; هَرْشَن'),
|
||||||
|
Language(iso639_2: 'haw', name: 'Hawaiian', native: 'ʻŌlelo Hawaiʻi'),
|
||||||
|
Language(iso639_2: 'heb', name: 'Hebrew', native: 'עברית'),
|
||||||
|
Language(iso639_2: 'her', name: 'Herero', native: 'Otjiherero'),
|
||||||
|
Language(iso639_2: 'hil', name: 'Hiligaynon', native: 'Ilonggo'),
|
||||||
|
Language(iso639_2: 'hin', name: 'Hindi', native: 'हिन्दी Hindī'),
|
||||||
|
Language(iso639_2: 'hmn', name: 'Hmong; Mong', native: 'lus Hmoob; lug Moob; lol Hmongb; 𖬇𖬰𖬞 𖬌𖬣𖬵'),
|
||||||
|
Language(iso639_2: 'hmo', name: 'Hiri Motu'),
|
||||||
|
Language(iso639_2: 'hrv', name: 'Croatian', native: 'hrvatski'),
|
||||||
|
Language(iso639_2: 'hsb', name: 'Upper Sorbian', native: 'hornjoserbšćina'),
|
||||||
|
Language(iso639_2: 'hun', name: 'Hungarian', native: 'magyar'),
|
||||||
|
Language(iso639_2: 'hup', name: 'Hupa', native: 'Na:tinixwe Mixine:whe\''),
|
||||||
|
Language(iso639_2: 'hye', name: 'Armenian', native: 'Հայերէն; Հայերեն'),
|
||||||
|
Language(iso639_2: 'arm', name: 'Armenian', native: 'Հայերէն; Հայերեն'),
|
||||||
|
Language(iso639_2: 'iba', name: 'Iban', native: 'Jaku Iban'),
|
||||||
|
Language(iso639_2: 'ibo', name: 'Igbo', native: 'Asụsụ Igbo'),
|
||||||
|
Language(iso639_2: 'iii', name: 'Sichuan Yi; Nuosu', native: 'ꆈꌠꉙ'),
|
||||||
|
Language(iso639_2: 'iku', name: 'Inuktitut', native: 'ᐃᓄᒃᑎᑐᑦ'),
|
||||||
|
Language(iso639_2: 'ilo', name: 'Iloko', native: 'Pagsasao nga Ilokano; Ilokano'),
|
||||||
|
Language(iso639_2: 'ind', name: 'Indonesian', native: 'Bahasa Indonesia'),
|
||||||
|
Language(iso639_2: 'inh', name: 'Ingush', native: 'ГӀалгӀай мотт'),
|
||||||
|
Language(iso639_2: 'ipk', name: 'Inupiaq', native: 'Iñupiaq'),
|
||||||
|
Language(iso639_2: 'isl', name: 'Icelandic', native: 'íslenska'),
|
||||||
|
Language(iso639_2: 'ice', name: 'Icelandic', native: 'íslenska'),
|
||||||
|
Language(iso639_2: 'ita', name: 'Italian', native: 'italiano'),
|
||||||
|
Language(iso639_2: 'jav', name: 'Javanese', native: 'ꦧꦱꦗꦮ / Basa Jawa'),
|
||||||
|
Language(iso639_2: 'jpn', name: 'Japanese', native: '日本語'),
|
||||||
|
Language(iso639_2: 'jpr', name: 'Judeo-Persian', native: 'Dzhidi'),
|
||||||
|
Language(iso639_2: 'jrb', name: 'Judeo-Arabic', native: 'عربية يهودية / ערבית יהודית'),
|
||||||
|
Language(iso639_2: 'kaa', name: 'Kara-Kalpak', native: 'Qaraqalpaq tili; Қарақалпақ тили'),
|
||||||
|
Language(iso639_2: 'kab', name: 'Kabyle', native: 'Tamaziɣt Taqbaylit; Tazwawt'),
|
||||||
|
Language(iso639_2: 'kac', name: 'Kachin; Jingpho', native: 'Jingpho'),
|
||||||
|
Language(iso639_2: 'kal', name: 'Kalaallisut; Greenlandic'),
|
||||||
|
Language(iso639_2: 'kam', name: 'Kamba'),
|
||||||
|
Language(iso639_2: 'kan', name: 'Kannada', native: 'ಕನ್ನಡ Kannađa'),
|
||||||
|
Language(iso639_2: 'kas', name: 'Kashmiri', native: 'कॉशुर / كأشُر'),
|
||||||
|
Language(iso639_2: 'kat', name: 'Georgian', native: 'ქართული'),
|
||||||
|
Language(iso639_2: 'geo', name: 'Georgian', native: 'ქართული'),
|
||||||
|
Language(iso639_2: 'kau', name: 'Kanuri'),
|
||||||
|
Language(iso639_2: 'kaz', name: 'Kazakh', native: 'қазақ тілі qazaq tili; қазақша qazaqşa'),
|
||||||
|
Language(iso639_2: 'kbd', name: 'Kabardian', native: 'Адыгэбзэ (Къэбэрдейбзэ) Adıgăbză (Qăbărdeĭbză)'),
|
||||||
|
Language(iso639_2: 'kha', name: 'Khasi', native: 'কা কতিয়েন খাশি'),
|
||||||
|
Language(iso639_2: 'khm', name: 'Central Khmer', native: 'ភាសាខ្មែរ Phiəsaakhmær'),
|
||||||
|
Language(iso639_2: 'kik', name: 'Kikuyu; Gikuyu', native: 'Gĩkũyũ'),
|
||||||
|
Language(iso639_2: 'kin', name: 'Kinyarwanda', native: 'Ikinyarwanda'),
|
||||||
|
Language(iso639_2: 'kir', name: 'Kirghiz; Kyrgyz', native: 'кыргызча kırgızça; кыргыз тили kırgız tili'),
|
||||||
|
Language(iso639_2: 'kmb', name: 'Kimbundu'),
|
||||||
|
Language(iso639_2: 'kok', name: 'Konkani', native: 'कोंकणी'),
|
||||||
|
Language(iso639_2: 'kom', name: 'Komi', native: 'Коми кыв'),
|
||||||
|
Language(iso639_2: 'kon', name: 'Kongo'),
|
||||||
|
Language(iso639_2: 'kor', name: 'Korean', native: '한국어'),
|
||||||
|
Language(iso639_2: 'kos', name: 'Kosraean'),
|
||||||
|
Language(iso639_2: 'kpe', name: 'Kpelle', native: 'Kpɛlɛwoo'),
|
||||||
|
Language(iso639_2: 'krc', name: 'Karachay-Balkar', native: 'Къарачай-Малкъар тил; Таулу тил'),
|
||||||
|
Language(iso639_2: 'krl', name: 'Karelian', native: 'karjal; kariela; karjala'),
|
||||||
|
Language(iso639_2: 'kru', name: 'Kurukh', native: 'कुड़ुख़'),
|
||||||
|
Language(iso639_2: 'kua', name: 'Kuanyama; Kwanyama'),
|
||||||
|
Language(iso639_2: 'kum', name: 'Kumyk', native: 'къумукъ тил/qumuq til'),
|
||||||
|
Language(iso639_2: 'kur', name: 'Kurdish', native: 'kurdî / کوردی'),
|
||||||
|
Language(iso639_2: 'kut', name: 'Kutenai'),
|
||||||
|
Language(iso639_2: 'lad', name: 'Ladino', native: 'Judeo-español'),
|
||||||
|
Language(iso639_2: 'lah', name: 'Lahnda', native: 'بھارت کا'),
|
||||||
|
Language(iso639_2: 'lam', name: 'Lamba'),
|
||||||
|
Language(iso639_2: 'lao', name: 'Lao', native: 'ພາສາລາວ Phasalaw'),
|
||||||
|
Language(iso639_2: 'lav', name: 'Latvian', native: 'latviešu'),
|
||||||
|
Language(iso639_2: 'lez', name: 'Lezghian', native: 'Лезги чӏал'),
|
||||||
|
Language(iso639_2: 'lim', name: 'Limburgan; Limburger; Limburgish', native: 'Lèmburgs'),
|
||||||
|
Language(iso639_2: 'lin', name: 'Lingala'),
|
||||||
|
Language(iso639_2: 'lit', name: 'Lithuanian', native: 'lietuvių'),
|
||||||
|
Language(iso639_2: 'lol', name: 'Mongo', native: 'Lomongo'),
|
||||||
|
Language(iso639_2: 'loz', name: 'Lozi'),
|
||||||
|
Language(iso639_2: 'ltz', name: 'Luxembourgish; Letzeburgesch', native: 'Lëtzebuergesch'),
|
||||||
|
Language(iso639_2: 'lua', name: 'Luba-Lulua', native: 'Tshiluba'),
|
||||||
|
Language(iso639_2: 'lub', name: 'Luba-Katanga', native: 'Kiluba'),
|
||||||
|
Language(iso639_2: 'lug', name: 'Ganda', native: 'Luganda'),
|
||||||
|
Language(iso639_2: 'lun', name: 'Lunda', native: 'Chilunda'),
|
||||||
|
Language(iso639_2: 'luo', name: 'Luo (Kenya and Tanzania)', native: 'Dholuo'),
|
||||||
|
Language(iso639_2: 'lus', name: 'Lushai', native: 'Mizo ṭawng'),
|
||||||
|
Language(iso639_2: 'mad', name: 'Madurese', native: 'Madhura'),
|
||||||
|
Language(iso639_2: 'mag', name: 'Magahi', native: 'मगही'),
|
||||||
|
Language(iso639_2: 'mah', name: 'Marshallese', native: 'Kajin M̧ajeļ'),
|
||||||
|
Language(iso639_2: 'mai', name: 'Maithili', native: 'मैथिली; মৈথিলী'),
|
||||||
|
Language(iso639_2: 'mak', name: 'Makasar', native: 'Basa Mangkasara\' / ᨅᨔ ᨆᨀᨔᨑ'),
|
||||||
|
Language(iso639_2: 'mal', name: 'Malayalam', native: 'മലയാളം'),
|
||||||
|
Language(iso639_2: 'man', name: 'Mandingo', native: 'Mandi\'nka kango'),
|
||||||
|
Language(iso639_2: 'mar', name: 'Marathi', native: 'मराठी Marāţhī'),
|
||||||
|
Language(iso639_2: 'mas', name: 'Masai', native: 'ɔl'),
|
||||||
|
Language(iso639_2: 'mdf', name: 'Moksha', native: 'мокшень кяль'),
|
||||||
|
Language(iso639_2: 'mdr', name: 'Mandar'),
|
||||||
|
Language(iso639_2: 'men', name: 'Mende', native: 'Mɛnde yia'),
|
||||||
|
Language(iso639_2: 'mic', name: 'Mi\'kmaq; Micmac', native: 'Míkmawísimk'),
|
||||||
|
Language(iso639_2: 'min', name: 'Minangkabau', native: 'Baso Minang'),
|
||||||
|
Language(iso639_2: 'mkd', name: 'Macedonian', native: 'македонски'),
|
||||||
|
Language(iso639_2: 'mac', name: 'Macedonian', native: 'македонски'),
|
||||||
|
Language(iso639_2: 'mlg', name: 'Malagasy'),
|
||||||
|
Language(iso639_2: 'mlt', name: 'Maltese', native: 'Malti'),
|
||||||
|
Language(iso639_2: 'mnc', name: 'Manchu', native: 'ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ Manju gisun'),
|
||||||
|
Language(iso639_2: 'mni', name: 'Manipuri'),
|
||||||
|
Language(iso639_2: 'moh', name: 'Mohawk', native: 'Kanien’kéha'),
|
||||||
|
Language(iso639_2: 'mon', name: 'Mongolian', native: 'монгол хэл mongol xel; ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'),
|
||||||
|
Language(iso639_2: 'mos', name: 'Mossi', native: 'Mooré'),
|
||||||
|
Language(iso639_2: 'mri', name: 'Māori', native: 'Te Reo Māori'),
|
||||||
|
Language(iso639_2: 'mao', name: 'Māori', native: 'Te Reo Māori'),
|
||||||
|
Language(iso639_2: 'msa', name: 'Malay', native: 'Bahasa Melayu'),
|
||||||
|
Language(iso639_2: 'may', name: 'Malay', native: 'Bahasa Melayu'),
|
||||||
|
Language(iso639_2: 'mus', name: 'Creek', native: 'Mvskoke'),
|
||||||
|
Language(iso639_2: 'mwl', name: 'Mirandese', native: 'mirandés; lhéngua mirandesa'),
|
||||||
|
Language(iso639_2: 'mwr', name: 'Marwari', native: 'मारवाड़ी'),
|
||||||
|
Language(iso639_2: 'mya', name: 'Burmese', native: 'မြန်မာစာ; မြန်မာစကား'),
|
||||||
|
Language(iso639_2: 'bur', name: 'Burmese', native: 'မြန်မာစာ; မြန်မာစကား'),
|
||||||
|
Language(iso639_2: 'myv', name: 'Erzya', native: 'эрзянь кель'),
|
||||||
|
Language(iso639_2: 'nap', name: 'Neapolitan', native: 'napulitano'),
|
||||||
|
Language(iso639_2: 'nau', name: 'Nauru', native: 'dorerin Naoero'),
|
||||||
|
Language(iso639_2: 'nav', name: 'Navajo; Navaho', native: 'Diné bizaad; Naabeehó bizaad'),
|
||||||
|
Language(iso639_2: 'nbl', name: 'Ndebele, South; South Ndebele', native: 'isiNdebele seSewula'),
|
||||||
|
Language(iso639_2: 'nde', name: 'Ndebele, North; North Ndebele', native: 'siNdebele saseNyakatho'),
|
||||||
|
Language(iso639_2: 'ndo', name: 'Ndonga', native: 'ndonga'),
|
||||||
|
Language(iso639_2: 'nds', name: 'Low German; Low Saxon; German, Low; Saxon, Low', native: 'Plattdütsch; Plattdüütsch'),
|
||||||
|
Language(iso639_2: 'nep', name: 'Nepali', native: 'नेपाली भाषा Nepālī bhāśā'),
|
||||||
|
Language(iso639_2: 'new', name: 'Nepal Bhasa; Newari', native: 'नेपाल भाषा; नेवाः भाय्'),
|
||||||
|
Language(iso639_2: 'nia', name: 'Nias', native: 'Li Niha'),
|
||||||
|
Language(iso639_2: 'niu', name: 'Niuean', native: 'ko e vagahau Niuē'),
|
||||||
|
Language(iso639_2: 'nld', name: 'Dutch', native: 'Nederlands'),
|
||||||
|
Language(iso639_2: 'dut', name: 'Dutch', native: 'Nederlands'),
|
||||||
|
Language(iso639_2: 'nno', name: 'Nynorsk', native: 'nynorsk'),
|
||||||
|
Language(iso639_2: 'nob', name: 'Bokmål', native: 'bokmål'),
|
||||||
|
Language(iso639_2: 'nog', name: 'Nogai', native: 'Ногай тили'),
|
||||||
|
Language(iso639_2: 'nor', name: 'Norwegian', native: 'Norsk'),
|
||||||
|
Language(iso639_2: 'nqo', name: 'N\'Ko'),
|
||||||
|
Language(iso639_2: 'nso', name: 'Pedi; Sepedi; Northern Sotho', native: 'Sesotho sa Leboa'),
|
||||||
|
Language(iso639_2: 'nya', name: 'Chichewa; Chewa; Nyanja', native: 'Chichewa; Chinyanja'),
|
||||||
|
Language(iso639_2: 'nym', name: 'Nyamwezi'),
|
||||||
|
Language(iso639_2: 'nyn', name: 'Nyankole'),
|
||||||
|
Language(iso639_2: 'nyo', name: 'Nyoro', native: 'Runyoro'),
|
||||||
|
Language(iso639_2: 'nzi', name: 'Nzima'),
|
||||||
|
Language(iso639_2: 'oci', name: 'Occitan (post 1500)', native: 'occitan; lenga d\'òc'),
|
||||||
|
Language(iso639_2: 'oji', name: 'Ojibwa'),
|
||||||
|
Language(iso639_2: 'ori', name: 'Oriya', native: 'ଓଡ଼ିଆ'),
|
||||||
|
Language(iso639_2: 'orm', name: 'Oromo', native: 'Afaan Oromoo'),
|
||||||
|
Language(iso639_2: 'osa', name: 'Osage', native: 'Wazhazhe ie / 𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟'),
|
||||||
|
Language(iso639_2: 'oss', name: 'Ossetian; Ossetic', native: 'Ирон æвзаг Iron ævzag'),
|
||||||
|
Language(iso639_2: 'pag', name: 'Pangasinan', native: 'Salitan Pangasinan'),
|
||||||
|
Language(iso639_2: 'pam', name: 'Pampanga; Kapampangan', native: 'Amánung Kapampangan; Amánung Sísuan'),
|
||||||
|
Language(iso639_2: 'pan', name: 'Panjabi; Punjabi', native: 'ਪੰਜਾਬੀ / پنجابی Pãjābī'),
|
||||||
|
Language(iso639_2: 'pap', name: 'Papiamento', native: 'Papiamentu'),
|
||||||
|
Language(iso639_2: 'pau', name: 'Palauan', native: 'a tekoi er a Belau'),
|
||||||
|
Language(iso639_2: 'pol', name: 'Polish', native: 'polski'),
|
||||||
|
Language(iso639_2: 'pon', name: 'Pohnpeian'),
|
||||||
|
Language(iso639_2: 'por', name: 'Portuguese', native: 'português'),
|
||||||
|
Language(iso639_2: 'pus', name: 'Pushto; Pashto', native: 'پښتو Pax̌tow'),
|
||||||
|
Language(iso639_2: 'que', name: 'Quechua', native: 'Runa simi; kichwa simi; Nuna shimi'),
|
||||||
|
Language(iso639_2: 'raj', name: 'Rajasthani', native: 'राजस्थानी'),
|
||||||
|
Language(iso639_2: 'rap', name: 'Rapanui', native: 'Vananga rapa nui'),
|
||||||
|
Language(iso639_2: 'rar', name: 'Rarotongan; Cook Islands Māori', native: 'Māori Kūki \'Āirani'),
|
||||||
|
Language(iso639_2: 'roh', name: 'Romansh', native: 'Rumantsch; Rumàntsch; Romauntsch; Romontsch'),
|
||||||
|
Language(iso639_2: 'rom', name: 'Romany', native: 'romani čhib'),
|
||||||
|
Language(iso639_2: 'ron', name: 'Romanian', native: 'română'),
|
||||||
|
Language(iso639_2: 'rum', name: 'Romanian', native: 'română'),
|
||||||
|
Language(iso639_2: 'run', name: 'Rundi', native: 'Ikirundi'),
|
||||||
|
Language(iso639_2: 'rup', name: 'Aromanian; Arumanian; Macedo-Romanian', native: 'armãneashce; armãneashti; rrãmãneshti'),
|
||||||
|
Language(iso639_2: 'rus', name: 'Russian', native: 'русский'),
|
||||||
|
Language(iso639_2: 'sad', name: 'Sandawe', native: 'Sandaweeki'),
|
||||||
|
Language(iso639_2: 'sag', name: 'Sango', native: 'yângâ tî sängö'),
|
||||||
|
Language(iso639_2: 'sah', name: 'Yakut', native: 'Сахалыы'),
|
||||||
|
Language(iso639_2: 'sas', name: 'Sasak'),
|
||||||
|
Language(iso639_2: 'sat', name: 'Santali', native: 'ᱥᱟᱱᱛᱟᱲᱤ'),
|
||||||
|
Language(iso639_2: 'scn', name: 'Sicilian', native: 'Sicilianu'),
|
||||||
|
Language(iso639_2: 'sco', name: 'Scots', native: 'Braid Scots; Lallans'),
|
||||||
|
Language(iso639_2: 'sel', name: 'Selkup'),
|
||||||
|
Language(iso639_2: 'shn', name: 'Shan', native: 'ၵႂၢမ်းတႆးယႂ်'),
|
||||||
|
Language(iso639_2: 'sid', name: 'Sidamo', native: 'Sidaamu Afoo'),
|
||||||
|
Language(iso639_2: 'sin', name: 'Sinhala; Sinhalese', native: 'සිංහල Sĩhala'),
|
||||||
|
Language(iso639_2: 'slk', name: 'Slovak', native: 'slovenčina'),
|
||||||
|
Language(iso639_2: 'slo', name: 'Slovak', native: 'slovenčina'),
|
||||||
|
Language(iso639_2: 'slv', name: 'Slovenian', native: 'slovenščina'),
|
||||||
|
Language(iso639_2: 'sma', name: 'Southern Sami', native: 'Åarjelsaemien gïele'),
|
||||||
|
Language(iso639_2: 'sme', name: 'Northern Sami', native: 'davvisámegiella'),
|
||||||
|
Language(iso639_2: 'smj', name: 'Lule Sami', native: 'julevsámegiella'),
|
||||||
|
Language(iso639_2: 'smn', name: 'Inari Sami', native: 'anarâškielâ'),
|
||||||
|
Language(iso639_2: 'smo', name: 'Samoan', native: 'Gagana faʻa Sāmoa'),
|
||||||
|
Language(iso639_2: 'sms', name: 'Skolt Sami', native: 'sääʹmǩiõll'),
|
||||||
|
Language(iso639_2: 'sna', name: 'Shona', native: 'chiShona'),
|
||||||
|
Language(iso639_2: 'snd', name: 'Sindhi', native: 'سنڌي / सिन्धी / ਸਿੰਧੀ'),
|
||||||
|
Language(iso639_2: 'snk', name: 'Soninke', native: 'Sooninkanxanne'),
|
||||||
|
Language(iso639_2: 'som', name: 'Somali', native: 'af Soomaali'),
|
||||||
|
Language(iso639_2: 'sot', name: 'Sotho, Southern', native: 'Sesotho [southern]'),
|
||||||
|
Language(iso639_2: 'spa', name: 'Spanish', native: 'español'),
|
||||||
|
Language(iso639_2: 'sqi', name: 'Albanian', native: 'Shqip'),
|
||||||
|
Language(iso639_2: 'alb', name: 'Albanian', native: 'Shqip'),
|
||||||
|
Language(iso639_2: 'srd', name: 'Sardinian', native: 'sardu; limba sarda; lingua sarda'),
|
||||||
|
Language(iso639_2: 'srn', name: 'Sranan Tongo'),
|
||||||
|
Language(iso639_2: 'srp', name: 'Serbian', native: 'српски / srpski'),
|
||||||
|
Language(iso639_2: 'srr', name: 'Serer', native: 'Seereer'),
|
||||||
|
Language(iso639_2: 'ssw', name: 'Swati', native: 'siSwati'),
|
||||||
|
Language(iso639_2: 'suk', name: 'Sukuma', native: 'Kɪsukuma'),
|
||||||
|
Language(iso639_2: 'sun', name: 'Sundanese', native: 'ᮘᮞ ᮞᮥᮔ᮪ᮓ / Basa Sunda'),
|
||||||
|
Language(iso639_2: 'sus', name: 'Susu', native: 'Sosoxui'),
|
||||||
|
Language(iso639_2: 'swa', name: 'Swahili', native: 'Kiswahili'),
|
||||||
|
Language(iso639_2: 'swe', name: 'Swedish', native: 'svenska'),
|
||||||
|
Language(iso639_2: 'syr', name: 'Syriac', native: 'ܠܫܢܐ ܣܘܪܝܝܐ Lešānā Suryāyā'),
|
||||||
|
Language(iso639_2: 'tah', name: 'Tahitian', native: 'Reo Tahiti; Reo Mā\'ohi'),
|
||||||
|
Language(iso639_2: 'tam', name: 'Tamil', native: 'தமிழ் Tamił'),
|
||||||
|
Language(iso639_2: 'tat', name: 'Tatar', native: 'татар теле / tatar tele / تاتار'),
|
||||||
|
Language(iso639_2: 'tel', name: 'Telugu', native: 'తెలుగు Telugu'),
|
||||||
|
Language(iso639_2: 'tem', name: 'Timne', native: 'KʌThemnɛ'),
|
||||||
|
Language(iso639_2: 'ter', name: 'Tereno', native: 'Terêna'),
|
||||||
|
Language(iso639_2: 'tet', name: 'Tetum', native: 'Lia-Tetun'),
|
||||||
|
Language(iso639_2: 'tgk', name: 'Tajik', native: 'тоҷикӣ toçikī'),
|
||||||
|
Language(iso639_2: 'tgl', name: 'Tagalog', native: 'Wikang Tagalog'),
|
||||||
|
Language(iso639_2: 'tha', name: 'Thai', native: 'ไทย'),
|
||||||
|
Language(iso639_2: 'tig', name: 'Tigre', native: 'ትግረ; ትግሬ; ኻሳ; ትግራይት'),
|
||||||
|
Language(iso639_2: 'tir', name: 'Tigrinya', native: 'ትግርኛ'),
|
||||||
|
Language(iso639_2: 'tiv', name: 'Tiv'),
|
||||||
|
Language(iso639_2: 'tkl', name: 'Tokelau'),
|
||||||
|
Language(iso639_2: 'tli', name: 'Tlingit', native: 'Lingít'),
|
||||||
|
Language(iso639_2: 'tmh', name: 'Tamashek'),
|
||||||
|
Language(iso639_2: 'tog', name: 'Tonga (Nyasa)', native: 'chiTonga'),
|
||||||
|
Language(iso639_2: 'ton', name: 'Tonga (Tonga Islands)', native: 'lea faka-Tonga'),
|
||||||
|
Language(iso639_2: 'tpi', name: 'Tok Pisin'),
|
||||||
|
Language(iso639_2: 'tsi', name: 'Tsimshian'),
|
||||||
|
Language(iso639_2: 'tsn', name: 'Tswana', native: 'Setswana'),
|
||||||
|
Language(iso639_2: 'tso', name: 'Tsonga', native: 'Xitsonga'),
|
||||||
|
Language(iso639_2: 'tuk', name: 'Turkmen', native: 'Türkmençe / Түркменче / تورکمن تیلی تورکمنچ; türkmen dili / түркмен дили'),
|
||||||
|
Language(iso639_2: 'tum', name: 'Tumbuka', native: 'chiTumbuka'),
|
||||||
|
Language(iso639_2: 'tur', name: 'Turkish', native: 'Türkçe'),
|
||||||
|
Language(iso639_2: 'tvl', name: 'Tuvalu', native: 'Te Ggana Tuuvalu; Te Gagana Tuuvalu'),
|
||||||
|
Language(iso639_2: 'twi', name: 'Twi'),
|
||||||
|
Language(iso639_2: 'tyv', name: 'Tuvinian', native: 'тыва дыл'),
|
||||||
|
Language(iso639_2: 'udm', name: 'Udmurt', native: 'удмурт кыл'),
|
||||||
|
Language(iso639_2: 'uig', name: 'Uighur; Uyghur', native: 'ئۇيغۇرچە ; ئۇيغۇر تىلى'),
|
||||||
|
Language(iso639_2: 'ukr', name: 'Ukrainian', native: 'українська'),
|
||||||
|
Language(iso639_2: 'umb', name: 'Umbundu', native: 'Úmbúndú'),
|
||||||
|
Language(iso639_2: 'urd', name: 'Urdu', native: 'اُردُو Urduw'),
|
||||||
|
Language(iso639_2: 'uzb', name: 'Uzbek', native: 'Oʻzbekcha / Ózbekça / ўзбекча / ئوزبېچه; oʻzbek tili / ўзбек тили / ئوبېک تیلی'),
|
||||||
|
Language(iso639_2: 'vai', name: 'Vai', native: 'ꕙꔤ'),
|
||||||
|
Language(iso639_2: 'ven', name: 'Venda', native: 'Tshivenḓa'),
|
||||||
|
Language(iso639_2: 'vie', name: 'Vietnamese', native: 'Tiếng Việt'),
|
||||||
|
Language(iso639_2: 'vot', name: 'Votic', native: 'vađđa ceeli'),
|
||||||
|
Language(iso639_2: 'wal', name: 'Wolaitta; Wolaytta'),
|
||||||
|
Language(iso639_2: 'war', name: 'Waray', native: 'Winaray; Samareño; Lineyte-Samarnon; Binisayâ nga Winaray; Binisayâ nga Samar-Leyte; “Binisayâ nga Waray”'),
|
||||||
|
Language(iso639_2: 'was', name: 'Washo', native: 'wá:šiw ʔítlu'),
|
||||||
|
Language(iso639_2: 'wln', name: 'Walloon', native: 'Walon'),
|
||||||
|
Language(iso639_2: 'wol', name: 'Wolof'),
|
||||||
|
Language(iso639_2: 'xal', name: 'Kalmyk; Oirat', native: 'Хальмг келн / Xaľmg keln'),
|
||||||
|
Language(iso639_2: 'xho', name: 'Xhosa', native: 'isiXhosa'),
|
||||||
|
Language(iso639_2: 'yao', name: 'Yao'),
|
||||||
|
Language(iso639_2: 'yap', name: 'Yapese'),
|
||||||
|
Language(iso639_2: 'yid', name: 'Yiddish', native: 'ייִדיש; יידיש; אידיש Yidiš'),
|
||||||
|
Language(iso639_2: 'yor', name: 'Yoruba', native: 'èdè Yorùbá'),
|
||||||
|
Language(iso639_2: 'zap', name: 'Zapotec', native: 'Diidxazá'),
|
||||||
|
Language(iso639_2: 'zen', name: 'Zenaga', native: 'Tuḍḍungiyya'),
|
||||||
|
Language(iso639_2: 'zgh', name: 'Standard Moroccan Tamazight', native: 'ⵜⴰⵎⴰⵣⵉⵖⵜ ⵜⴰⵏⴰⵡⴰⵢⵜ'),
|
||||||
|
Language(iso639_2: 'zha', name: 'Zhuang; Chuang', native: 'Vahcuengh / 話僮'),
|
||||||
|
Language(iso639_2: 'zho', name: 'Chinese', native: '中文'),
|
||||||
|
Language(iso639_2: 'chi', name: 'Chinese', native: '中文'),
|
||||||
|
Language(iso639_2: 'zul', name: 'Zulu', native: 'isiZulu'),
|
||||||
|
Language(iso639_2: 'zun', name: 'Zuni', native: 'Shiwi\'ma'),
|
||||||
|
Language(iso639_2: 'zza', name: 'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', native: 'kirmanckî; dimilkî; kirdkî; zazakî'),
|
||||||
|
];
|
||||||
|
}
|
88
lib/ref/mp4.dart
Normal file
88
lib/ref/mp4.dart
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
class Mp4 {
|
||||||
|
// adapted from `metadata-extractor`
|
||||||
|
static final brands = <String, String>{
|
||||||
|
'3g2a': '3GPP2 Media compliant with 3GPP2 C.S0050-0 V1.0',
|
||||||
|
'3g2b': '3GPP2 Media compliant with 3GPP2 C.S0050-A V1.0.0',
|
||||||
|
'3g2c': '3GPP2 Media compliant with 3GPP2 C.S0050-B v1.0',
|
||||||
|
'3ge6': '3GPP Release 6 MBMS Extended Presentations',
|
||||||
|
'3ge7': '3GPP Release 7 MBMS Extended Presentations',
|
||||||
|
'3gg6': '3GPP Release 6 General Profile',
|
||||||
|
'3gp1': '3GPP Media Release 1',
|
||||||
|
'3gp2': '3GPP Media Release 2',
|
||||||
|
'3gp3': '3GPP Media Release 3',
|
||||||
|
'3gp4': '3GPP Media Release 4',
|
||||||
|
'3gp5': '3GPP Media Release 5',
|
||||||
|
'3gp6': '3GPP Media Release 6',
|
||||||
|
'3gs7': '3GPP Media Release 7',
|
||||||
|
'avc1': 'MP4 Base w/ AVC ext',
|
||||||
|
'CAEP': 'Canon Digital Camera',
|
||||||
|
'caqv': 'Casio Digital Camera',
|
||||||
|
'CDes': 'Convergent Design',
|
||||||
|
'da0a': 'DMB MAF w/ MPEG Layer II aud, MOT slides, DLS, JPG/PNG/MNG images',
|
||||||
|
'da0b': 'DMB MAF, extending DA0A, with 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'da1a': 'DMB MAF audio with ER-BSAC audio, JPG/PNG/MNG images',
|
||||||
|
'da1b': 'DMB MAF, extending da1a, with 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'da2a': 'DMB MAF aud w/ HE-AAC v2 aud, MOT slides, DLS, JPG/PNG/MNG images',
|
||||||
|
'da2b': 'DMB MAF, extending da2a, with 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'da3a': 'DMB MAF aud with HE-AAC aud, JPG/PNG/MNG images',
|
||||||
|
'da3b': 'DMB MAF, extending da3a w/ BIFS, 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'dmb1': 'DMB MAF supporting all the components defined in the specification',
|
||||||
|
'dmpf': 'Digital Media Project',
|
||||||
|
'drc1': 'Dirac (wavelet compression), encapsulated in ISO base media (MP4)',
|
||||||
|
'dv1a': 'DMB MAF vid w/ AVC vid, ER-BSAC aud, BIFS, JPG/PNG/MNG images, TS',
|
||||||
|
'dv1b': 'DMB MAF, extending dv1a, with 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'dv2a': 'DMB MAF vid w/ AVC vid, HE-AAC v2 aud, BIFS, JPG/PNG/MNG images, TS',
|
||||||
|
'dv2b': 'DMB MAF, extending dv2a, with 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'dv3a': 'DMB MAF vid w/ AVC vid, HE-AAC aud, BIFS, JPG/PNG/MNG images, TS',
|
||||||
|
'dv3b': 'DMB MAF, extending dv3a, with 3GPP timed text, DID, TVA, REL, IPMP',
|
||||||
|
'dvr1': 'DVB over RTP',
|
||||||
|
'dvt1': 'DVB over MPEG-2 Transport Stream',
|
||||||
|
'F4V ': 'Video for Adobe Flash Player 9+',
|
||||||
|
'F4P ': 'Protected Video for Adobe Flash Player 9+',
|
||||||
|
'F4A ': 'Audio for Adobe Flash Player 9+',
|
||||||
|
'F4B ': 'Audio Book for Adobe Flash Player 9+',
|
||||||
|
'isc2': 'ISMACryp 2.0 Encrypted File',
|
||||||
|
'iso2': 'MP4 Base Media v2',
|
||||||
|
'isom': 'MP4 Base Media v1',
|
||||||
|
'JP2 ': 'JPEG 2000 Image',
|
||||||
|
'jpm ': 'JPEG 2000 Compound Image',
|
||||||
|
'jpx ': 'JPEG 2000 w/ extensions',
|
||||||
|
'KDDI': '3GPP2 EZmovie for KDDI 3G cellphones',
|
||||||
|
'M4A ': 'Apple iTunes AAC-LC Audio',
|
||||||
|
'M4B ': 'Apple iTunes AAC-LC Audio Book',
|
||||||
|
'M4P ': 'Apple iTunes AAC-LC AES Protected Audio',
|
||||||
|
'M4V ': 'Apple iTunes Video',
|
||||||
|
'M4VH': 'Apple TV',
|
||||||
|
'M4VP': 'Apple iPhone',
|
||||||
|
'mj2s': 'Motion JPEG 2000 Simple Profile',
|
||||||
|
'mjp2': 'Motion JPEG 2000 General Profile',
|
||||||
|
'mmp4': 'MPEG-4/3GPP Mobile Profile',
|
||||||
|
'mp21': 'MPEG-21',
|
||||||
|
'mp41': 'MP4 v1',
|
||||||
|
'mp42': 'MP4 v2',
|
||||||
|
'mp71': 'MP4 w/ MPEG-7 Metadata',
|
||||||
|
'MPPI': 'Photo Player, MAF',
|
||||||
|
'mqt ': 'Sony / Mobile QuickTime',
|
||||||
|
'MSNV': 'MPEG-4 for SonyPSP',
|
||||||
|
'NDAS': 'MP4 v2 Nero Digital AAC Audio',
|
||||||
|
'NDSC': 'MPEG-4 Nero Cinema Profile',
|
||||||
|
'NDSH': 'MPEG-4 Nero HDTV Profile',
|
||||||
|
'NDSM': 'MPEG-4 Nero Mobile Profile',
|
||||||
|
'NDSP': 'MPEG-4 Nero Portable Profile',
|
||||||
|
'NDSS': 'MPEG-4 Nero Standard Profile',
|
||||||
|
'NDXC': 'H.264/MPEG-4 AVC Nero Cinema Profile',
|
||||||
|
'NDXH': 'H.264/MPEG-4 AVC Nero HDTV Profile',
|
||||||
|
'NDXM': 'H.264/MPEG-4 AVC Nero Mobile Profile',
|
||||||
|
'NDXP': 'H.264/MPEG-4 AVC Nero Portable Profile',
|
||||||
|
'NDXS': 'H.264/MPEG-4 AVC Nero Standard Profile',
|
||||||
|
'odcf': 'OMA DCF DRM Format 2.0',
|
||||||
|
'opf2': 'OMA PDCF DRM Format 2.1',
|
||||||
|
'opx2': 'OMA PDCF DRM + XBS extensions',
|
||||||
|
'pana': 'Panasonic Digital Camera',
|
||||||
|
'qt ': 'Apple QuickTime',
|
||||||
|
'ROSS': 'Ross Video',
|
||||||
|
'sdv ': 'SD Memory Card Video',
|
||||||
|
'ssc1': 'Samsung stereoscopic, single stream',
|
||||||
|
'ssc2': 'Samsung stereoscopic, dual stream',
|
||||||
|
};
|
||||||
|
}
|
|
@ -22,10 +22,10 @@ abstract class MetadataService {
|
||||||
|
|
||||||
Future<String> getContentResolverProp(AvesEntry entry, String prop);
|
Future<String> getContentResolverProp(AvesEntry entry, String prop);
|
||||||
|
|
||||||
Future<List<Uint8List>> getEmbeddedPictures(String uri);
|
|
||||||
|
|
||||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractVideoEmbeddedPicture(String uri);
|
||||||
|
|
||||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,19 +152,6 @@ class PlatformMetadataService implements MetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
|
||||||
try {
|
|
||||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
|
||||||
'uri': uri,
|
|
||||||
});
|
|
||||||
return (result as List).cast<Uint8List>();
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
debugPrint('getEmbeddedPictures failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
|
@ -180,6 +167,19 @@ class PlatformMetadataService implements MetadataService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map> extractVideoEmbeddedPicture(String uri) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
|
||||||
|
'uri': uri,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -40,6 +40,7 @@ class Durations {
|
||||||
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||||
static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
|
static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
|
||||||
static const viewerOverlayPageShadeAnimation = Duration(milliseconds: 150);
|
static const viewerOverlayPageShadeAnimation = Duration(milliseconds: 150);
|
||||||
|
static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
|
||||||
|
|
||||||
// info animations
|
// info animations
|
||||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||||
|
|
|
@ -5,7 +5,6 @@ class AIcons {
|
||||||
static const IconData allCollection = Icons.collections_outlined;
|
static const IconData allCollection = Icons.collections_outlined;
|
||||||
static const IconData image = Icons.photo_outlined;
|
static const IconData image = Icons.photo_outlined;
|
||||||
static const IconData video = Icons.movie_outlined;
|
static const IconData video = Icons.movie_outlined;
|
||||||
static const IconData audio = Icons.audiotrack_outlined;
|
|
||||||
static const IconData vector = Icons.code_outlined;
|
static const IconData vector = Icons.code_outlined;
|
||||||
|
|
||||||
static const IconData android = Icons.android;
|
static const IconData android = Icons.android;
|
||||||
|
|
|
@ -79,10 +79,10 @@ class Constants {
|
||||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Flutter ijkplayer',
|
name: 'fijkplayer',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/deckerst/fijkplayer/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer',
|
sourceUrl: 'https://github.com/deckerst/fijkplayer',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Google API Availability',
|
name: 'Google API Availability',
|
||||||
|
@ -231,8 +231,8 @@ class Constants {
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Expansion Tile Card',
|
name: 'Expansion Tile Card',
|
||||||
license: 'BSD 3-Clause',
|
license: 'BSD 3-Clause',
|
||||||
licenseUrl: 'https://github.com/Skylled/expansion_tile_card/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/Skylled/expansion_tile_card',
|
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Flutter Highlight',
|
name: 'Flutter Highlight',
|
||||||
|
@ -240,12 +240,6 @@ class Constants {
|
||||||
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/git-touch/highlight',
|
sourceUrl: 'https://github.com/git-touch/highlight',
|
||||||
),
|
),
|
||||||
Dependency(
|
|
||||||
name: 'Flutter Localized Locales',
|
|
||||||
license: 'MIT',
|
|
||||||
licenseUrl: 'https://github.com/guidezpl/flutter-localized-locales/blob/master/LICENSE',
|
|
||||||
sourceUrl: 'https://github.com/guidezpl/flutter-localized-locales',
|
|
||||||
),
|
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Flutter Map',
|
name: 'Flutter Map',
|
||||||
license: 'BSD 3-Clause',
|
license: 'BSD 3-Clause',
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
String formatDuration(Duration d) {
|
String formatFriendlyDuration(Duration d) {
|
||||||
String twoDigits(int n) {
|
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
|
||||||
if (n >= 10) return '$n';
|
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
|
||||||
return '0$n';
|
|
||||||
}
|
|
||||||
|
|
||||||
final twoDigitSeconds = twoDigits(d.inSeconds.remainder(Duration.secondsPerMinute));
|
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
|
||||||
if (d.inHours == 0) return '${d.inMinutes}:$twoDigitSeconds';
|
return '${d.inHours}:$minutes:$seconds';
|
||||||
|
}
|
||||||
|
|
||||||
final twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
|
String formatPreciseDuration(Duration d) {
|
||||||
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
|
final millis = ((d.inMicroseconds / 1000.0).round() % 1000).toString().padLeft(3, '0');
|
||||||
|
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
|
||||||
|
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
|
||||||
|
final hours = (d.inHours).toString().padLeft(2, '0');
|
||||||
|
return '$hours:$minutes:$seconds.$millis';
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ExtraDateTime on DateTime {
|
extension ExtraDateTime on DateTime {
|
||||||
|
|
51
lib/widgets/common/video/controller.dart
Normal file
51
lib/widgets/common/video/controller.dart
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
abstract class AvesVideoController {
|
||||||
|
AvesVideoController();
|
||||||
|
|
||||||
|
void dispose();
|
||||||
|
|
||||||
|
Future<void> setDataSource(String uri, {int startMillis = 0});
|
||||||
|
|
||||||
|
Future<void> play();
|
||||||
|
|
||||||
|
Future<void> pause();
|
||||||
|
|
||||||
|
Future<void> seekTo(int targetMillis);
|
||||||
|
|
||||||
|
Future<void> seekToProgress(double progress) async {
|
||||||
|
if (duration != null) {
|
||||||
|
await seekTo((duration * progress).toInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Listenable get playCompletedListenable;
|
||||||
|
|
||||||
|
VideoStatus get status;
|
||||||
|
|
||||||
|
Stream<VideoStatus> get statusStream;
|
||||||
|
|
||||||
|
bool get isPlayable;
|
||||||
|
|
||||||
|
bool get isPlaying => status == VideoStatus.playing;
|
||||||
|
|
||||||
|
int get duration;
|
||||||
|
|
||||||
|
int get currentPosition;
|
||||||
|
|
||||||
|
double get progress => duration == null ? 0 : (currentPosition ?? 0).toDouble() / duration;
|
||||||
|
|
||||||
|
Stream<int> get positionStream;
|
||||||
|
|
||||||
|
Widget buildPlayerWidget(BuildContext context, AvesEntry entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VideoStatus {
|
||||||
|
idle,
|
||||||
|
initialized,
|
||||||
|
paused,
|
||||||
|
playing,
|
||||||
|
completed,
|
||||||
|
error,
|
||||||
|
}
|
|
@ -1,119 +1,331 @@
|
||||||
// import 'dart:async';
|
import 'dart:async';
|
||||||
//
|
import 'dart:ui';
|
||||||
// import 'package:aves/model/entry.dart';
|
|
||||||
// import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
// import 'package:aves/widgets/common/video/video.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
// import 'package:fijkplayer/fijkplayer.dart';
|
import 'package:aves/model/settings/video_loop_mode.dart';
|
||||||
// import 'package:flutter/material.dart';
|
import 'package:aves/model/video/keys.dart';
|
||||||
//
|
import 'package:aves/model/video/metadata.dart';
|
||||||
// class FijkPlayerAvesVideoController extends AvesVideoController {
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
// FijkPlayer _instance;
|
import 'package:aves/widgets/common/video/controller.dart';
|
||||||
// final List<StreamSubscription> _subscriptions = [];
|
import 'package:fijkplayer/fijkplayer.dart';
|
||||||
// final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
|
import 'package:flutter/foundation.dart';
|
||||||
// final AChangeNotifier _playFinishNotifier = AChangeNotifier();
|
import 'package:flutter/material.dart';
|
||||||
//
|
import 'package:tuple/tuple.dart';
|
||||||
// Stream<FijkValue> get _valueStream => _valueStreamController.stream;
|
|
||||||
//
|
class IjkPlayerAvesVideoController extends AvesVideoController {
|
||||||
// FijkPlayerAvesVideoController() {
|
FijkPlayer _instance;
|
||||||
// _instance = FijkPlayer();
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
// _instance.addListener(_onValueChanged);
|
final StreamController<FijkValue> _valueStreamController = StreamController.broadcast();
|
||||||
// _subscriptions.add(_valueStream.where((value) => value.completed).listen((_) => _playFinishNotifier.notifyListeners()));
|
final AChangeNotifier _completedNotifier = AChangeNotifier();
|
||||||
// }
|
Offset _macroBlockCrop = Offset.zero;
|
||||||
//
|
final List<StreamSummary> _streams = [];
|
||||||
// @override
|
final ValueNotifier<StreamSummary> _selectedVideoStream = ValueNotifier(null);
|
||||||
// void dispose() {
|
final ValueNotifier<StreamSummary> _selectedAudioStream = ValueNotifier(null);
|
||||||
// _instance.removeListener(_onValueChanged);
|
final ValueNotifier<StreamSummary> _selectedTextStream = ValueNotifier(null);
|
||||||
// _valueStreamController.close();
|
final ValueNotifier<Tuple2<int, int>> _sar = ValueNotifier(Tuple2(1, 1));
|
||||||
// _subscriptions
|
Timer _initialPlayTimer;
|
||||||
// ..forEach((sub) => sub.cancel())
|
|
||||||
// ..clear();
|
Stream<FijkValue> get _valueStream => _valueStreamController.stream;
|
||||||
// _instance.release();
|
|
||||||
// }
|
static const initialPlayDelay = Duration(milliseconds: 100);
|
||||||
//
|
static const gifLikeVideoDurationThreshold = Duration(seconds: 10);
|
||||||
// void _onValueChanged() => _valueStreamController.add(_instance.value);
|
|
||||||
//
|
IjkPlayerAvesVideoController(AvesEntry entry) {
|
||||||
// // enable autoplay, even when seeking on uninitialized player, otherwise the texture is not updated
|
FijkLog.setLevel(FijkLogLevel.Warn);
|
||||||
// // as a workaround, pausing after a brief duration is possible, but fiddly
|
_instance = FijkPlayer();
|
||||||
// @override
|
|
||||||
// Future<void> setDataSource(String uri) => _instance.setDataSource(uri, autoPlay: true);
|
// FFmpeg options
|
||||||
//
|
// cf https://github.com/Bilibili/ijkplayer/blob/master/ijkmedia/ijkplayer/ff_ffplay_options.h
|
||||||
// @override
|
// cf https://www.jianshu.com/p/843c86a9e9ad
|
||||||
// Future<void> refreshVideoInfo() => null;
|
|
||||||
//
|
final option = FijkOption();
|
||||||
// @override
|
|
||||||
// Future<void> play() => _instance.start();
|
// when accurate seek is enabled and seeking fails, it takes time (cf `accurate-seek-timeout`) to acknowledge the error and proceed
|
||||||
//
|
// failure seems to happen when pause-seeking videos with an audio stream, whatever container or video stream
|
||||||
// @override
|
// player cannot be dynamically set to use accurate seek only when playing
|
||||||
// Future<void> pause() => _instance.pause();
|
const accurateSeekEnabled = false;
|
||||||
//
|
|
||||||
// @override
|
// playing with HW acceleration seems to skip the last frames of some videos
|
||||||
// Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis);
|
// 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;
|
||||||
// @override
|
|
||||||
// Future<void> seekToProgress(double progress) => _instance.seekTo((duration * progress).toInt());
|
// TODO TLAD HW codecs sometimes fail when seek-starting some videos, e.g. MP2TS/h264(HDPR)
|
||||||
//
|
if (hwAccelerationEnabled) {
|
||||||
// @override
|
// when HW acceleration is enabled, videos with dimensions that do not fit 16x macroblocks need cropping
|
||||||
// Listenable get playCompletedListenable => _playFinishNotifier;
|
// TODO TLAD 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;
|
||||||
// @override
|
_macroBlockCrop = Offset(s.width, s.height);
|
||||||
// VideoStatus get status => _instance.state.toAves;
|
}
|
||||||
//
|
|
||||||
// @override
|
final loopEnabled = settings.videoLoopMode.shouldLoop(entry);
|
||||||
// Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.state.toAves);
|
|
||||||
//
|
// `fastseek`: enable fast, but inaccurate seeks for some formats
|
||||||
// @override
|
// in practice the flag seems ineffective, but harmless too
|
||||||
// bool get isVideoReady => _instance.value.videoRenderStart;
|
option.setFormatOption('fflags', 'fastseek');
|
||||||
//
|
|
||||||
// @override
|
// `enable-accurate-seek`: enable accurate seek, default: 0, in [0, 1]
|
||||||
// Stream<bool> get isVideoReadyStream => _valueStream.map((value) => value.videoRenderStart);
|
option.setPlayerOption('enable-accurate-seek', accurateSeekEnabled ? 1 : 0);
|
||||||
//
|
|
||||||
// // we check whether video info is ready instead of checking for `noDatasource` status,
|
// `accurate-seek-timeout`: accurate seek timeout, default: 5000 ms, in [0, 5000]
|
||||||
// // as the controller could also be uninitialized with the `pause` status
|
option.setPlayerOption('accurate-seek-timeout', 1000);
|
||||||
// // (e.g. when switching between video entries without playing them the first time)
|
|
||||||
// @override
|
// `framedrop`: drop frames when cpu is too slow, default: 0, in [-1, 120]
|
||||||
// bool get isPlayable => _instance.isPlayable();
|
option.setPlayerOption('framedrop', 5);
|
||||||
//
|
|
||||||
// @override
|
// `loop`: set number of times the playback shall be looped, default: 1, in [INT_MIN, INT_MAX]
|
||||||
// int get duration => _instance.value.duration.inMilliseconds;
|
option.setPlayerOption('loop', loopEnabled ? -1 : 1);
|
||||||
//
|
|
||||||
// @override
|
// `mediacodec-all-videos`: MediaCodec: enable all videos, default: 0, in [0, 1]
|
||||||
// int get currentPosition => _instance.currentPos.inMilliseconds;
|
option.setPlayerOption('mediacodec-all-videos', hwAccelerationEnabled ? 1 : 0);
|
||||||
//
|
|
||||||
// @override
|
// TODO TLAD try subs
|
||||||
// Stream<int> get positionStream => _instance.onCurrentPosUpdate.map((pos) => pos.inMilliseconds);
|
// `subtitle`: decode subtitle stream, default: 0, in [0, 1]
|
||||||
//
|
// option.setPlayerOption('subtitle', 1);
|
||||||
// @override
|
|
||||||
// Widget buildPlayerWidget(AvesEntry entry) => FijkView(
|
_instance.applyOptions(option);
|
||||||
// player: _instance,
|
|
||||||
// panelBuilder: (player, data, context, viewSize, texturePos) => SizedBox(),
|
_instance.addListener(_onValueChanged);
|
||||||
// color: Colors.transparent,
|
_subscriptions.add(_valueStream.where((value) => value.state == FijkState.completed).listen((_) => _completedNotifier.notifyListeners()));
|
||||||
// );
|
}
|
||||||
// }
|
|
||||||
//
|
@override
|
||||||
// extension ExtraIjkStatus on FijkState {
|
void dispose() {
|
||||||
// VideoStatus get toAves {
|
_initialPlayTimer?.cancel();
|
||||||
// switch (this) {
|
_instance.removeListener(_onValueChanged);
|
||||||
// case FijkState.idle:
|
_valueStreamController.close();
|
||||||
// return VideoStatus.idle;
|
_subscriptions
|
||||||
// case FijkState.initialized:
|
..forEach((sub) => sub.cancel())
|
||||||
// return VideoStatus.initialized;
|
..clear();
|
||||||
// case FijkState.asyncPreparing:
|
_instance.release();
|
||||||
// return VideoStatus.preparing;
|
}
|
||||||
// case FijkState.prepared:
|
|
||||||
// return VideoStatus.prepared;
|
void _fetchSelectedStreams() async {
|
||||||
// case FijkState.started:
|
final mediaInfo = await _instance.getInfo();
|
||||||
// return VideoStatus.playing;
|
if (!mediaInfo.containsKey(Keys.streams)) return;
|
||||||
// case FijkState.paused:
|
|
||||||
// return VideoStatus.paused;
|
_streams.clear();
|
||||||
// case FijkState.completed:
|
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
||||||
// return VideoStatus.completed;
|
allStreams.forEach((stream) {
|
||||||
// case FijkState.stopped:
|
final type = ExtraStreamType.fromTypeString(stream[Keys.streamType]);
|
||||||
// return VideoStatus.stopped;
|
if (type != null) {
|
||||||
// case FijkState.end:
|
_streams.add(StreamSummary(
|
||||||
// return VideoStatus.disposed;
|
type: type,
|
||||||
// case FijkState.error:
|
index: stream[Keys.index],
|
||||||
// return VideoStatus.error;
|
language: stream[Keys.language],
|
||||||
// }
|
title: stream[Keys.title],
|
||||||
// return VideoStatus.idle;
|
));
|
||||||
// }
|
}
|
||||||
// }
|
});
|
||||||
|
|
||||||
|
StreamSummary _getSelectedStream(String selectedIndexKey) {
|
||||||
|
final indexString = mediaInfo[selectedIndexKey];
|
||||||
|
if (indexString != null) {
|
||||||
|
final index = int.tryParse(indexString);
|
||||||
|
if (index != null && index != -1) {
|
||||||
|
return _streams.firstWhere((stream) => stream.index == index, orElse: () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 streamInfo = allStreams.firstWhere((stream) => stream[Keys.index] == streamIndex, orElse: () => null);
|
||||||
|
if (streamInfo != null) {
|
||||||
|
final num = streamInfo[Keys.sarNum];
|
||||||
|
final den = streamInfo[Keys.sarDen];
|
||||||
|
_sar.value = Tuple2((num ?? 0) != 0 ? num : 1, (den ?? 0) != 0 ? den : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onValueChanged() {
|
||||||
|
if (_instance.state == FijkState.prepared && _streams.isEmpty) {
|
||||||
|
_fetchSelectedStreams();
|
||||||
|
}
|
||||||
|
_valueStreamController.add(_instance.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// always start playing, even when seeking on uninitialized player, otherwise the texture is not updated
|
||||||
|
// as a workaround, pausing after a brief duration is possible, but fiddly
|
||||||
|
@override
|
||||||
|
Future<void> setDataSource(String uri, {int startMillis = 0}) async {
|
||||||
|
if (startMillis > 0) {
|
||||||
|
// `seek-at-start`: set offset of player should be seeked, default: 0, in [0, INT_MAX]
|
||||||
|
await _instance.setOption(FijkOption.playerCategory, 'seek-at-start', startMillis);
|
||||||
|
}
|
||||||
|
// 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(uri);
|
||||||
|
_initialPlayTimer = Timer(initialPlayDelay, play);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() {
|
||||||
|
if (_instance.isPlayable()) {
|
||||||
|
_instance.start();
|
||||||
|
}
|
||||||
|
return SynchronousFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() {
|
||||||
|
if (_instance.isPlayable()) {
|
||||||
|
_initialPlayTimer?.cancel();
|
||||||
|
_instance.pause();
|
||||||
|
}
|
||||||
|
return SynchronousFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Listenable get playCompletedListenable => _completedNotifier;
|
||||||
|
|
||||||
|
@override
|
||||||
|
VideoStatus get status => _instance.state.toAves;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.state.toAves);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isPlayable => _instance.isPlayable();
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get duration => _instance.value.duration.inMilliseconds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get currentPosition => _instance.currentPos.inMilliseconds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<int> get positionStream => _instance.onCurrentPosUpdate.map((pos) => pos.inMilliseconds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildPlayerWidget(BuildContext context, AvesEntry entry) {
|
||||||
|
return ValueListenableBuilder<Tuple2<int, int>>(
|
||||||
|
valueListenable: _sar,
|
||||||
|
builder: (context, sar, child) {
|
||||||
|
final sarNum = sar.item1;
|
||||||
|
final sarDen = sar.item2;
|
||||||
|
// derive DAR (Display Aspect Ratio) from SAR (Storage Aspect Ratio), if any
|
||||||
|
// e.g. 960x536 (~16:9) with SAR 4:3 should be displayed as ~2.39:1
|
||||||
|
final dar = entry.displayAspectRatio * sarNum / sarDen;
|
||||||
|
// TODO TLAD notify SAR to make the magnifier and minimap use the rendering DAR instead of entry DAR
|
||||||
|
return FijkView(
|
||||||
|
player: _instance,
|
||||||
|
fit: FijkFit(
|
||||||
|
sizeFactor: 1.0,
|
||||||
|
aspectRatio: dar,
|
||||||
|
alignment: _alignmentForRotation(entry.rotationDegrees),
|
||||||
|
macroBlockCrop: _macroBlockCrop,
|
||||||
|
),
|
||||||
|
panelBuilder: (player, data, context, viewSize, texturePos) => SizedBox(),
|
||||||
|
color: Colors.transparent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Alignment _alignmentForRotation(int rotation) {
|
||||||
|
switch (rotation) {
|
||||||
|
case 90:
|
||||||
|
return Alignment.topRight;
|
||||||
|
case 180:
|
||||||
|
return Alignment.bottomRight;
|
||||||
|
case 270:
|
||||||
|
return Alignment.bottomLeft;
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
return Alignment.topLeft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExtraIjkStatus on FijkState {
|
||||||
|
VideoStatus get toAves {
|
||||||
|
switch (this) {
|
||||||
|
case FijkState.idle:
|
||||||
|
case FijkState.end:
|
||||||
|
case FijkState.stopped:
|
||||||
|
return VideoStatus.idle;
|
||||||
|
case FijkState.initialized:
|
||||||
|
case FijkState.asyncPreparing:
|
||||||
|
return VideoStatus.initialized;
|
||||||
|
case FijkState.prepared:
|
||||||
|
case FijkState.paused:
|
||||||
|
return VideoStatus.paused;
|
||||||
|
case FijkState.started:
|
||||||
|
return VideoStatus.playing;
|
||||||
|
case FijkState.completed:
|
||||||
|
return VideoStatus.completed;
|
||||||
|
case FijkState.error:
|
||||||
|
return VideoStatus.error;
|
||||||
|
}
|
||||||
|
return VideoStatus.idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExtraFijkPlayer on FijkPlayer {
|
||||||
|
Future<void> setDataSourceUntilPrepared(String uri) async {
|
||||||
|
await setDataSource(uri, autoPlay: false);
|
||||||
|
|
||||||
|
final completer = Completer();
|
||||||
|
void onChange() {
|
||||||
|
switch (state) {
|
||||||
|
case FijkState.prepared:
|
||||||
|
removeListener(onChange);
|
||||||
|
completer.complete();
|
||||||
|
break;
|
||||||
|
case FijkState.error:
|
||||||
|
removeListener(onChange);
|
||||||
|
completer.completeError(value.exception);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener(onChange);
|
||||||
|
await prepareAsync();
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StreamType { video, audio, text }
|
||||||
|
|
||||||
|
extension ExtraStreamType on StreamType {
|
||||||
|
static StreamType fromTypeString(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case StreamTypes.video:
|
||||||
|
return StreamType.video;
|
||||||
|
case StreamTypes.audio:
|
||||||
|
return StreamType.audio;
|
||||||
|
case StreamTypes.subtitle:
|
||||||
|
case StreamTypes.timedText:
|
||||||
|
return StreamType.text;
|
||||||
|
default:
|
||||||
|
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}';
|
||||||
|
}
|
||||||
|
|
|
@ -1,144 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
|
||||||
import 'package:aves/widgets/common/video/video.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
|
||||||
|
|
||||||
class FlutterIjkPlayerAvesVideoController extends AvesVideoController {
|
|
||||||
IjkMediaController _instance;
|
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
|
||||||
final AChangeNotifier _playFinishNotifier = AChangeNotifier();
|
|
||||||
|
|
||||||
FlutterIjkPlayerAvesVideoController() {
|
|
||||||
_instance = IjkMediaController();
|
|
||||||
_subscriptions.add(_instance.playFinishStream.listen((_) => _playFinishNotifier.notifyListeners()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_subscriptions
|
|
||||||
..forEach((sub) => sub.cancel())
|
|
||||||
..clear();
|
|
||||||
_instance?.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// enable autoplay, even when seeking on uninitialized player, otherwise the texture is not updated
|
|
||||||
// as a workaround, pausing after a brief duration is possible, but fiddly
|
|
||||||
@override
|
|
||||||
Future<void> setDataSource(String uri) => _instance.setDataSource(DataSource.photoManagerUrl(uri), autoPlay: true);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> refreshVideoInfo() => _instance.refreshVideoInfo();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> play() => _instance.play();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> pause() => _instance.pause();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> seekTo(int targetMillis) => _instance.seekTo(targetMillis / 1000.0);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> seekToProgress(double progress) => _instance.seekToProgress(progress);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Listenable get playCompletedListenable => _playFinishNotifier;
|
|
||||||
|
|
||||||
@override
|
|
||||||
VideoStatus get status => _instance.ijkStatus.toAves;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<VideoStatus> get statusStream => _instance.ijkStatusStream.map((status) => status.toAves);
|
|
||||||
|
|
||||||
// we check whether video info is ready instead of checking for `noDatasource` status,
|
|
||||||
// as the controller could also be uninitialized with the `pause` status
|
|
||||||
// (e.g. when switching between video entries without playing them the first time)
|
|
||||||
@override
|
|
||||||
bool get isPlayable => _videoInfo.hasData;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get isVideoReady => _instance.textureId != null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<bool> get isVideoReadyStream => _instance.textureIdStream.map((id) => id != null);
|
|
||||||
|
|
||||||
// `videoInfo` is never null (even if `toString` prints `null`)
|
|
||||||
// check presence with `hasData` instead
|
|
||||||
VideoInfo get _videoInfo => _instance.videoInfo;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get duration => _videoInfo.durationMillis;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get currentPosition => _videoInfo.currentPositionMillis;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<int> get positionStream => _instance.videoInfoStream.map((info) => info.currentPositionMillis);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget buildPlayerWidget(AvesEntry entry) => IjkPlayer(
|
|
||||||
mediaController: _instance,
|
|
||||||
controllerWidgetBuilder: (controller) => SizedBox.shrink(),
|
|
||||||
statusWidgetBuilder: (context, controller, status) => SizedBox.shrink(),
|
|
||||||
textureBuilder: (context, controller, info) {
|
|
||||||
var id = controller.textureId;
|
|
||||||
var child = id != null
|
|
||||||
? Texture(
|
|
||||||
textureId: id,
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: Colors.black,
|
|
||||||
);
|
|
||||||
|
|
||||||
final degree = entry.rotationDegrees ?? 0;
|
|
||||||
if (degree != 0) {
|
|
||||||
child = RotatedBox(
|
|
||||||
quarterTurns: degree ~/ 90,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: entry.displayAspectRatio,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ExtraVideoInfo on VideoInfo {
|
|
||||||
int get durationMillis => duration == null ? null : (duration * 1000).toInt();
|
|
||||||
|
|
||||||
int get currentPositionMillis => currentPosition == null ? null : (currentPosition * 1000).toInt();
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ExtraIjkStatus on IjkStatus {
|
|
||||||
VideoStatus get toAves {
|
|
||||||
switch (this) {
|
|
||||||
case IjkStatus.noDatasource:
|
|
||||||
return VideoStatus.idle;
|
|
||||||
case IjkStatus.preparing:
|
|
||||||
return VideoStatus.preparing;
|
|
||||||
case IjkStatus.prepared:
|
|
||||||
return VideoStatus.prepared;
|
|
||||||
case IjkStatus.playing:
|
|
||||||
return VideoStatus.playing;
|
|
||||||
case IjkStatus.pause:
|
|
||||||
return VideoStatus.paused;
|
|
||||||
case IjkStatus.complete:
|
|
||||||
return VideoStatus.completed;
|
|
||||||
case IjkStatus.disposed:
|
|
||||||
return VideoStatus.disposed;
|
|
||||||
case IjkStatus.setDatasourceFail:
|
|
||||||
case IjkStatus.error:
|
|
||||||
return VideoStatus.error;
|
|
||||||
}
|
|
||||||
return VideoStatus.idle;
|
|
||||||
}
|
|
||||||
}
|
|
107
lib/widgets/common/video/flutter_vlc_player.dart
Normal file
107
lib/widgets/common/video/flutter_vlc_player.dart
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
// import 'dart:async';
|
||||||
|
// import 'dart:io';
|
||||||
|
//
|
||||||
|
// import 'package:aves/model/entry.dart';
|
||||||
|
// import 'package:aves/utils/change_notifier.dart';
|
||||||
|
// import 'package:aves/widgets/common/video/controller.dart';
|
||||||
|
// import 'package:flutter/material.dart';
|
||||||
|
// import 'package:flutter/src/foundation/change_notifier.dart';
|
||||||
|
// import 'package:flutter/src/widgets/framework.dart';
|
||||||
|
// import 'package:flutter_vlc_player/flutter_vlc_player.dart';
|
||||||
|
// import 'package:provider/provider.dart';
|
||||||
|
//
|
||||||
|
// class VlcAvesVideoController extends AvesVideoController {
|
||||||
|
// VlcPlayerController _instance;
|
||||||
|
// final List<StreamSubscription> _subscriptions = [];
|
||||||
|
// final StreamController<VlcPlayerValue> _valueStreamController = StreamController.broadcast();
|
||||||
|
// final AChangeNotifier _playFinishNotifier = AChangeNotifier();
|
||||||
|
//
|
||||||
|
// Stream<VlcPlayerValue> get _valueStream => _valueStreamController.stream;
|
||||||
|
//
|
||||||
|
// VlcAvesVideoController();
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> setDataSource(String uri, {int startMillis = 0}) async {
|
||||||
|
// _instance = VlcPlayerController.file(
|
||||||
|
// File(uri),
|
||||||
|
// );
|
||||||
|
// _instance.addListener(_onValueChanged);
|
||||||
|
// _subscriptions.add(_valueStream.where((value) => value.isEnded).listen((_) => _playFinishNotifier.notifyListeners()));
|
||||||
|
//
|
||||||
|
// // update value stream to:
|
||||||
|
// // 1) trigger playability check
|
||||||
|
// // 2) show the `VlcPlayer` widget
|
||||||
|
// // 3) initialize its `PlatformView`
|
||||||
|
// // 4) complete `VlcPlayerController` initialization
|
||||||
|
// _valueStreamController.add(_instance.value);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// void dispose() {
|
||||||
|
// _instance?.removeListener(_onValueChanged);
|
||||||
|
// _valueStreamController.close();
|
||||||
|
// _subscriptions
|
||||||
|
// ..forEach((sub) => sub.cancel())
|
||||||
|
// ..clear();
|
||||||
|
// _instance?.dispose();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// void _onValueChanged() => _valueStreamController.add(_instance.value);
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> play() => _instance.play();
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> pause() => _instance?.pause();
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> seekTo(int targetMillis) => _instance.seekTo(Duration(milliseconds: targetMillis));
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Listenable get playCompletedListenable => _playFinishNotifier;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// VideoStatus get status => _instance?.value?.toAves ?? VideoStatus.idle;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.toAves);
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// bool get isPlayable => _instance != null;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// int get duration => _instance?.value?.duration?.inMilliseconds;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// int get currentPosition => _instance?.value?.position?.inMilliseconds;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Stream<int> get positionStream => _valueStream.map((value) => value.position.inMilliseconds);
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Widget buildPlayerWidget(BuildContext context, AvesEntry entry) {
|
||||||
|
// // do not use `Magnifier` with `applyScale` enabled when using this widget,
|
||||||
|
// // as the original video size will be used to create the `PlatformView`
|
||||||
|
// // and a virtual display larger than the device screen may crash the app
|
||||||
|
// final mqWidth = context.select<MediaQueryData, double>((mq) => mq.size.width);
|
||||||
|
// final displaySize = entry.displaySize;
|
||||||
|
// final ratio = mqWidth / displaySize.width;
|
||||||
|
// return SizedBox.fromSize(
|
||||||
|
// size: displaySize * ratio,
|
||||||
|
// child: VlcPlayer(
|
||||||
|
// controller: _instance,
|
||||||
|
// aspectRatio: entry.displayAspectRatio,
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// extension ExtraVlcPlayerValue on VlcPlayerValue {
|
||||||
|
// VideoStatus get toAves {
|
||||||
|
// if (hasError) return VideoStatus.error;
|
||||||
|
// if (!isInitialized) return VideoStatus.idle;
|
||||||
|
// if (isEnded) return VideoStatus.completed;
|
||||||
|
// if (isPlaying) return VideoStatus.playing;
|
||||||
|
// return VideoStatus.paused;
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -1,73 +0,0 @@
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
// import 'package:aves/widgets/common/video/fijkplayer.dart';
|
|
||||||
import 'package:aves/widgets/common/video/flutter_ijkplayer.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
abstract class AvesVideoController {
|
|
||||||
AvesVideoController();
|
|
||||||
|
|
||||||
factory AvesVideoController.flutterIjkPlayer() => FlutterIjkPlayerAvesVideoController();
|
|
||||||
|
|
||||||
// factory AvesVideoController.fijkPlayer() => FijkPlayerAvesVideoController();
|
|
||||||
|
|
||||||
void dispose();
|
|
||||||
|
|
||||||
Future<void> setDataSource(String uri);
|
|
||||||
|
|
||||||
Future<void> refreshVideoInfo();
|
|
||||||
|
|
||||||
Future<void> play();
|
|
||||||
|
|
||||||
Future<void> pause();
|
|
||||||
|
|
||||||
Future<void> seekTo(int targetMillis);
|
|
||||||
|
|
||||||
Future<void> seekToProgress(double progress);
|
|
||||||
|
|
||||||
Listenable get playCompletedListenable;
|
|
||||||
|
|
||||||
VideoStatus get status;
|
|
||||||
|
|
||||||
Stream<VideoStatus> get statusStream;
|
|
||||||
|
|
||||||
bool get isPlayable;
|
|
||||||
|
|
||||||
bool get isPlaying => status == VideoStatus.playing;
|
|
||||||
|
|
||||||
bool get isVideoReady;
|
|
||||||
|
|
||||||
Stream<bool> get isVideoReadyStream;
|
|
||||||
|
|
||||||
int get duration;
|
|
||||||
|
|
||||||
int get currentPosition;
|
|
||||||
|
|
||||||
double get progress => (currentPosition ?? 0).toDouble() / (duration ?? 1);
|
|
||||||
|
|
||||||
Stream<int> get positionStream;
|
|
||||||
|
|
||||||
Widget buildPlayerWidget(AvesEntry entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AvesVideoInfo {
|
|
||||||
// in millis
|
|
||||||
int duration, currentPosition;
|
|
||||||
|
|
||||||
AvesVideoInfo({
|
|
||||||
this.duration,
|
|
||||||
this.currentPosition,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VideoStatus {
|
|
||||||
idle,
|
|
||||||
initialized,
|
|
||||||
preparing,
|
|
||||||
prepared,
|
|
||||||
playing,
|
|
||||||
paused,
|
|
||||||
completed,
|
|
||||||
stopped,
|
|
||||||
disposed,
|
|
||||||
error,
|
|
||||||
}
|
|
83
lib/widgets/common/video/video_player.dart
Normal file
83
lib/widgets/common/video/video_player.dart
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
// import 'dart:async';
|
||||||
|
//
|
||||||
|
// import 'package:aves/model/entry.dart';
|
||||||
|
// import 'package:aves/utils/change_notifier.dart';
|
||||||
|
// import 'package:aves/widgets/common/video/controller.dart';
|
||||||
|
// import 'package:flutter/src/foundation/change_notifier.dart';
|
||||||
|
// import 'package:flutter/src/widgets/framework.dart';
|
||||||
|
// import 'package:video_player/video_player.dart';
|
||||||
|
//
|
||||||
|
// class VideoPlayerAvesVideoController extends AvesVideoController {
|
||||||
|
// VideoPlayerController _instance;
|
||||||
|
// final List<StreamSubscription> _subscriptions = [];
|
||||||
|
// final StreamController<VideoPlayerValue> _valueStreamController = StreamController.broadcast();
|
||||||
|
// final AChangeNotifier _playFinishNotifier = AChangeNotifier();
|
||||||
|
//
|
||||||
|
// Stream<VideoPlayerValue> get _valueStream => _valueStreamController.stream;
|
||||||
|
//
|
||||||
|
// VideoPlayerAvesVideoController();
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> setDataSource(String uri, {int startMillis = 0}) async {
|
||||||
|
// _instance = VideoPlayerController.network(uri);
|
||||||
|
// _instance.addListener(_onValueChanged);
|
||||||
|
// _subscriptions.add(_valueStream.where((value) => value.position > value.duration).listen((_) => _playFinishNotifier.notifyListeners()));
|
||||||
|
//
|
||||||
|
// await _instance.initialize();
|
||||||
|
// await play();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// void dispose() {
|
||||||
|
// _instance?.removeListener(_onValueChanged);
|
||||||
|
// _valueStreamController.close();
|
||||||
|
// _subscriptions
|
||||||
|
// ..forEach((sub) => sub.cancel())
|
||||||
|
// ..clear();
|
||||||
|
// _instance?.dispose();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// void _onValueChanged() => _valueStreamController.add(_instance.value);
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> play() => _instance.play();
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> pause() => _instance?.pause();
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Future<void> seekTo(int targetMillis) => _instance.seekTo(Duration(milliseconds: targetMillis));
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Listenable get playCompletedListenable => _playFinishNotifier;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// VideoStatus get status => _instance?.value?.toAves ?? VideoStatus.idle;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Stream<VideoStatus> get statusStream => _valueStream.map((value) => value.toAves);
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// bool get isPlayable => _instance != null && _instance.value.isInitialized && !_instance.value.hasError;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// int get duration => _instance?.value?.duration?.inMilliseconds;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// int get currentPosition => _instance?.value?.position?.inMilliseconds;
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Stream<int> get positionStream => _valueStream.map((value) => value.position.inMilliseconds);
|
||||||
|
//
|
||||||
|
// @override
|
||||||
|
// Widget buildPlayerWidget(BuildContext context, AvesEntry entry) => VideoPlayer(_instance);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// extension ExtraVideoPlayerValue on VideoPlayerValue {
|
||||||
|
// VideoStatus get toAves {
|
||||||
|
// if (hasError) return VideoStatus.error;
|
||||||
|
// if (!isInitialized) return VideoStatus.idle;
|
||||||
|
// if (isPlaying) return VideoStatus.playing;
|
||||||
|
// return VideoStatus.paused;
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -42,10 +42,6 @@ class CollectionSearchDelegate {
|
||||||
|
|
||||||
CollectionSearchDelegate({@required this.source, this.parentCollection});
|
CollectionSearchDelegate({@required this.source, this.parentCollection});
|
||||||
|
|
||||||
ThemeData appBarTheme(BuildContext context) {
|
|
||||||
return Theme.of(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildLeading(BuildContext context) {
|
Widget buildLeading(BuildContext context) {
|
||||||
return Navigator.canPop(context)
|
return Navigator.canPop(context)
|
||||||
? IconButton(
|
? IconButton(
|
||||||
|
@ -122,7 +118,7 @@ class CollectionSearchDelegate {
|
||||||
album,
|
album,
|
||||||
source.getAlbumDisplayName(context, album),
|
source.getAlbumDisplayName(context, album),
|
||||||
))
|
))
|
||||||
.where((filter) => containQuery(filter.album) || containQuery(filter.displayName))
|
.where((filter) => containQuery(filter.displayName))
|
||||||
.toList()
|
.toList()
|
||||||
..sort();
|
..sort();
|
||||||
return _buildFilterRow(
|
return _buildFilterRow(
|
||||||
|
|
|
@ -88,7 +88,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = widget.delegate.appBarTheme(context);
|
final theme = Theme.of(context);
|
||||||
Widget body;
|
Widget body;
|
||||||
switch (widget.delegate.currentBody) {
|
switch (widget.delegate.currentBody) {
|
||||||
case SearchBody.suggestions:
|
case SearchBody.suggestions:
|
||||||
|
|
|
@ -9,11 +9,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
|
|
||||||
|
|
||||||
class LanguageTile extends StatelessWidget {
|
class LanguageTile extends StatelessWidget {
|
||||||
final Locale _systemLocale = WidgetsBinding.instance.window.locale;
|
|
||||||
|
|
||||||
static const _systemLocaleOption = Locale('system');
|
static const _systemLocaleOption = Locale('system');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -21,14 +18,13 @@ class LanguageTile extends StatelessWidget {
|
||||||
final current = settings.locale;
|
final current = settings.locale;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(context.l10n.settingsLanguage),
|
title: Text(context.l10n.settingsLanguage),
|
||||||
subtitle: Text('${current == null ? '${context.l10n.settingsSystemDefault} • ${_getLocaleName(_systemLocale)}' : _getLocaleName(current)}'),
|
subtitle: Text('${current == null ? context.l10n.settingsSystemDefault : _getLocaleName(current)}'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final value = await showDialog<Locale>(
|
final value = await showDialog<Locale>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AvesSelectionDialog<Locale>(
|
builder: (context) => AvesSelectionDialog<Locale>(
|
||||||
initialValue: settings.locale ?? _systemLocaleOption,
|
initialValue: settings.locale ?? _systemLocaleOption,
|
||||||
options: _getLocaleOptions(context),
|
options: _getLocaleOptions(context),
|
||||||
optionSubtitleBuilder: (locale) => locale == _systemLocaleOption ? _getLocaleName(_systemLocale) : null,
|
|
||||||
title: context.l10n.settingsLanguage,
|
title: context.l10n.settingsLanguage,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -41,12 +37,20 @@ class LanguageTile extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getLocaleName(Locale locale) => LocaleNamesLocalizationsDelegate.nativeLocaleNames[locale.toString()];
|
String _getLocaleName(Locale locale) {
|
||||||
|
// the package `flutter_localized_locales` has the answer for all locales
|
||||||
|
// but it comes with 3 MB of assets
|
||||||
|
switch (locale.languageCode) {
|
||||||
|
case 'en':
|
||||||
|
return 'English';
|
||||||
|
case 'ko':
|
||||||
|
return '한국어';
|
||||||
|
}
|
||||||
|
return locale.toString();
|
||||||
|
}
|
||||||
|
|
||||||
LinkedHashMap<Locale, String> _getLocaleOptions(BuildContext context) {
|
LinkedHashMap<Locale, String> _getLocaleOptions(BuildContext context) {
|
||||||
final supportedLocales = List<Locale>.from(AppLocalizations.supportedLocales);
|
final displayLocales = AppLocalizations.supportedLocales.map((locale) => MapEntry(locale, _getLocaleName(locale))).toList()..sort((a, b) => compareAsciiUpperCase(a.value, b.value));
|
||||||
supportedLocales.removeWhere((locale) => locale == _systemLocale);
|
|
||||||
final displayLocales = supportedLocales.map((locale) => MapEntry(locale, _getLocaleName(locale))).toList()..sort((a, b) => compareAsciiUpperCase(a.value, b.value));
|
|
||||||
|
|
||||||
return LinkedHashMap.of({
|
return LinkedHashMap.of({
|
||||||
_systemLocaleOption: context.l10n.settingsSystemDefault,
|
_systemLocaleOption: context.l10n.settingsSystemDefault,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/home_page.dart';
|
import 'package:aves/model/settings/home_page.dart';
|
||||||
import 'package:aves/model/settings/screen_on.dart';
|
import 'package:aves/model/settings/screen_on.dart';
|
||||||
import 'package:aves/model/settings/settings.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/model/source/collection_source.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
@ -239,6 +240,33 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
|
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
|
||||||
title: Text(context.l10n.settingsVideoShowVideos),
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||||
import 'package:aves/widgets/common/video/video.dart';
|
import 'package:aves/widgets/common/video/controller.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage.dart';
|
import 'package:aves/widgets/viewer/multipage.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -14,7 +14,6 @@ class MultiEntryScroller extends StatefulWidget {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final PageController pageController;
|
final PageController pageController;
|
||||||
final ValueChanged<int> onPageChanged;
|
final ValueChanged<int> onPageChanged;
|
||||||
final VoidCallback onTap;
|
|
||||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||||
final void Function(String uri) onViewDisposed;
|
final void Function(String uri) onViewDisposed;
|
||||||
|
@ -23,7 +22,6 @@ class MultiEntryScroller extends StatefulWidget {
|
||||||
this.collection,
|
this.collection,
|
||||||
this.pageController,
|
this.pageController,
|
||||||
this.onPageChanged,
|
this.onPageChanged,
|
||||||
this.onTap,
|
|
||||||
this.videoControllers,
|
this.videoControllers,
|
||||||
this.multiPageControllers,
|
this.multiPageControllers,
|
||||||
this.onViewDisposed,
|
this.onViewDisposed,
|
||||||
|
@ -89,7 +87,6 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
||||||
mainEntry: entry,
|
mainEntry: entry,
|
||||||
page: page,
|
page: page,
|
||||||
viewportSize: mqSize,
|
viewportSize: mqSize,
|
||||||
onTap: widget.onTap == null ? null : (_) => widget.onTap(),
|
|
||||||
videoControllers: widget.videoControllers,
|
videoControllers: widget.videoControllers,
|
||||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||||
);
|
);
|
||||||
|
@ -107,13 +104,11 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
||||||
|
|
||||||
class SingleEntryScroller extends StatefulWidget {
|
class SingleEntryScroller extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final VoidCallback onTap;
|
|
||||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||||
|
|
||||||
const SingleEntryScroller({
|
const SingleEntryScroller({
|
||||||
this.entry,
|
this.entry,
|
||||||
this.onTap,
|
|
||||||
this.videoControllers,
|
this.videoControllers,
|
||||||
this.multiPageControllers,
|
this.multiPageControllers,
|
||||||
});
|
});
|
||||||
|
@ -163,7 +158,6 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
|
||||||
mainEntry: entry,
|
mainEntry: entry,
|
||||||
page: page,
|
page: page,
|
||||||
viewportSize: mqSize,
|
viewportSize: mqSize,
|
||||||
onTap: widget.onTap == null ? null : (_) => widget.onTap(),
|
|
||||||
videoControllers: widget.videoControllers,
|
videoControllers: widget.videoControllers,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||||
import 'package:aves/widgets/common/video/video.dart';
|
import 'package:aves/widgets/common/video/controller.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
|
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_page.dart';
|
import 'package:aves/widgets/viewer/info/info_page.dart';
|
||||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
|
@ -20,7 +20,7 @@ class ViewerVerticalPageView extends StatefulWidget {
|
||||||
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
final List<Tuple2<String, MultiPageController>> multiPageControllers;
|
||||||
final PageController horizontalPager, verticalPager;
|
final PageController horizontalPager, verticalPager;
|
||||||
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
|
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
|
||||||
final VoidCallback onImageTap, onImagePageRequested;
|
final VoidCallback onImagePageRequested;
|
||||||
final void Function(String uri) onViewDisposed;
|
final void Function(String uri) onViewDisposed;
|
||||||
|
|
||||||
const ViewerVerticalPageView({
|
const ViewerVerticalPageView({
|
||||||
|
@ -32,7 +32,6 @@ class ViewerVerticalPageView extends StatefulWidget {
|
||||||
@required this.horizontalPager,
|
@required this.horizontalPager,
|
||||||
@required this.onVerticalPageChanged,
|
@required this.onVerticalPageChanged,
|
||||||
@required this.onHorizontalPageChanged,
|
@required this.onHorizontalPageChanged,
|
||||||
this.onImageTap,
|
|
||||||
@required this.onImagePageRequested,
|
@required this.onImagePageRequested,
|
||||||
@required this.onViewDisposed,
|
@required this.onViewDisposed,
|
||||||
});
|
});
|
||||||
|
@ -92,7 +91,6 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
? MultiEntryScroller(
|
? MultiEntryScroller(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
pageController: widget.horizontalPager,
|
pageController: widget.horizontalPager,
|
||||||
onTap: widget.onImageTap,
|
|
||||||
onPageChanged: widget.onHorizontalPageChanged,
|
onPageChanged: widget.onHorizontalPageChanged,
|
||||||
videoControllers: widget.videoControllers,
|
videoControllers: widget.videoControllers,
|
||||||
multiPageControllers: widget.multiPageControllers,
|
multiPageControllers: widget.multiPageControllers,
|
||||||
|
@ -100,7 +98,6 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
)
|
)
|
||||||
: SingleEntryScroller(
|
: SingleEntryScroller(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
onTap: widget.onImageTap,
|
|
||||||
videoControllers: widget.videoControllers,
|
videoControllers: widget.videoControllers,
|
||||||
multiPageControllers: widget.multiPageControllers,
|
multiPageControllers: widget.multiPageControllers,
|
||||||
),
|
),
|
||||||
|
|
|
@ -11,13 +11,15 @@ import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.dart';
|
import 'package:aves/widgets/collection/collection_page.dart';
|
||||||
import 'package:aves/widgets/common/basic/insets.dart';
|
import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/video/video.dart';
|
import 'package:aves/widgets/common/video/controller.dart';
|
||||||
|
import 'package:aves/widgets/common/video/fijkplayer.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
|
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
|
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
|
||||||
import 'package:aves/widgets/viewer/hero.dart';
|
import 'package:aves/widgets/viewer/hero.dart';
|
||||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage.dart';
|
import 'package:aves/widgets/viewer/multipage.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/bottom.dart';
|
import 'package:aves/widgets/viewer/overlay/bottom.dart';
|
||||||
|
import 'package:aves/widgets/viewer/overlay/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/panorama.dart';
|
import 'package:aves/widgets/viewer/overlay/panorama.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/top.dart';
|
import 'package:aves/widgets/viewer/overlay/top.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/video.dart';
|
import 'package:aves/widgets/viewer/overlay/video.dart';
|
||||||
|
@ -174,7 +176,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
value: _heroInfoNotifier,
|
value: _heroInfoNotifier,
|
||||||
child: NotificationListener(
|
child: NotificationListener(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
if (notification is FilterNotification) {
|
if (notification is FilterSelectedNotification) {
|
||||||
_goToCollection(notification.filter);
|
_goToCollection(notification.filter);
|
||||||
} else if (notification is ViewStateNotification) {
|
} else if (notification is ViewStateNotification) {
|
||||||
_updateViewState(notification.uri, notification.viewState);
|
_updateViewState(notification.uri, notification.viewState);
|
||||||
|
@ -183,25 +185,33 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: NotificationListener(
|
||||||
children: [
|
onNotification: (notification) {
|
||||||
ViewerVerticalPageView(
|
if (notification is ToggleOverlayNotification) {
|
||||||
collection: collection,
|
_overlayVisible.value = !_overlayVisible.value;
|
||||||
entryNotifier: _entryNotifier,
|
return true;
|
||||||
videoControllers: _videoControllers,
|
}
|
||||||
multiPageControllers: _multiPageControllers,
|
return false;
|
||||||
verticalPager: _verticalPager,
|
},
|
||||||
horizontalPager: _horizontalPager,
|
child: Stack(
|
||||||
onVerticalPageChanged: _onVerticalPageChanged,
|
children: [
|
||||||
onHorizontalPageChanged: _onHorizontalPageChanged,
|
ViewerVerticalPageView(
|
||||||
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
|
collection: collection,
|
||||||
onImagePageRequested: () => _goToVerticalPage(imagePage),
|
entryNotifier: _entryNotifier,
|
||||||
onViewDisposed: (uri) => _updateViewState(uri, null),
|
videoControllers: _videoControllers,
|
||||||
),
|
multiPageControllers: _multiPageControllers,
|
||||||
_buildTopOverlay(),
|
verticalPager: _verticalPager,
|
||||||
_buildBottomOverlay(),
|
horizontalPager: _horizontalPager,
|
||||||
BottomGestureAreaProtector(),
|
onVerticalPageChanged: _onVerticalPageChanged,
|
||||||
],
|
onHorizontalPageChanged: _onHorizontalPageChanged,
|
||||||
|
onImagePageRequested: () => _goToVerticalPage(imagePage),
|
||||||
|
onViewDisposed: (uri) => _updateViewState(uri, null),
|
||||||
|
),
|
||||||
|
_buildTopOverlay(),
|
||||||
|
_buildBottomOverlay(),
|
||||||
|
BottomGestureAreaProtector(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -499,9 +509,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
_initViewSpecificController<AvesVideoController>(
|
_initViewSpecificController<AvesVideoController>(
|
||||||
uri,
|
uri,
|
||||||
_videoControllers,
|
_videoControllers,
|
||||||
() => AvesVideoController.flutterIjkPlayer(),
|
() => IjkPlayerAvesVideoController(entry),
|
||||||
(_) => _.dispose(),
|
(_) => _.dispose(),
|
||||||
);
|
);
|
||||||
|
if (settings.enableVideoAutoPlay) {
|
||||||
|
_playVideo();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (entry.isMultipage) {
|
if (entry.isMultipage) {
|
||||||
_initViewSpecificController<MultiPageController>(
|
_initViewSpecificController<MultiPageController>(
|
||||||
|
@ -515,6 +528,22 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _playVideo() async {
|
||||||
|
await Future.delayed(Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
final entry = _entryNotifier.value;
|
||||||
|
if (entry == null) return;
|
||||||
|
|
||||||
|
final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||||
|
if (videoController != null) {
|
||||||
|
if (videoController.isPlayable) {
|
||||||
|
await videoController.play();
|
||||||
|
} else {
|
||||||
|
await videoController.setDataSource(entry.uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) {
|
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) {
|
||||||
var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
|
var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
|
||||||
if (controller != null) {
|
if (controller != null) {
|
||||||
|
|
|
@ -216,8 +216,9 @@ class _OwnerPropState extends State<OwnerProp> {
|
||||||
Future<void> _getOwner() async {
|
Future<void> _getOwner() async {
|
||||||
if (entry == null) return;
|
if (entry == null) return;
|
||||||
if (_loadedUri.value == entry.uri) return;
|
if (_loadedUri.value == entry.uri) return;
|
||||||
if (isVisible) {
|
final isMediaContent = entry.uri.startsWith('content://media/external/');
|
||||||
_ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name');
|
if (isVisible && isMediaContent) {
|
||||||
|
_ownerPackage = await metadataService.getContentResolverProp(entry, 'owner_package_name');
|
||||||
_loadedUri.value = entry.uri;
|
_loadedUri.value = entry.uri;
|
||||||
} else {
|
} else {
|
||||||
_ownerPackage = null;
|
_ownerPackage = null;
|
||||||
|
|
|
@ -222,6 +222,6 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
|
|
||||||
void _goToCollection(CollectionFilter filter) {
|
void _goToCollection(CollectionFilter filter) {
|
||||||
if (collection == null) return;
|
if (collection == null) return;
|
||||||
FilterNotification(filter).dispatch(context);
|
FilterSelectedNotification(filter).dispatch(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,11 +24,6 @@ class InfoSearchDelegate extends SearchDelegate {
|
||||||
searchFieldLabel: searchFieldLabel,
|
searchFieldLabel: searchFieldLabel,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
|
||||||
ThemeData appBarTheme(BuildContext context) {
|
|
||||||
return Theme.of(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildLeading(BuildContext context) {
|
Widget buildLeading(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
@ -108,7 +103,7 @@ class InfoSearchDelegate extends SearchDelegate {
|
||||||
title: kv.key,
|
title: kv.key,
|
||||||
dir: kv.value,
|
dir: kv.value,
|
||||||
initiallyExpanded: true,
|
initiallyExpanded: true,
|
||||||
showPrefixChildren: false,
|
showThumbnails: false,
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
|
|
|
@ -195,11 +195,12 @@ class _AddressInfoGroupState extends State<_AddressInfoGroup> {
|
||||||
return FutureBuilder<String>(
|
return FutureBuilder<String>(
|
||||||
future: _addressLineLoader,
|
future: _addressLineLoader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final address = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : null;
|
final fullAddress = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : null;
|
||||||
|
final address = fullAddress ?? entry.shortAddress;
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
return InfoRowGroup({
|
return InfoRowGroup({
|
||||||
l10n.viewerInfoLabelCoordinates: settings.coordinateFormat.format(entry.latLng),
|
l10n.viewerInfoLabelCoordinates: settings.coordinateFormat.format(entry.latLng),
|
||||||
if (address?.isNotEmpty == true) l10n.viewerInfoLabelAddress: address,
|
if (address.isNotEmpty) l10n.viewerInfoLabelAddress: address,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:aves/model/settings/enums.dart';
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
@ -9,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:latlong/latlong.dart';
|
import 'package:latlong/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class EntryLeafletMap extends StatefulWidget {
|
class EntryLeafletMap extends StatefulWidget {
|
||||||
|
|
|
@ -2,26 +2,32 @@ import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/brand_colors.dart';
|
import 'package:aves/ref/brand_colors.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
import 'package:aves/services/android_app_service.dart';
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/svg_metadata_service.dart';
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
|
||||||
import 'package:aves/utils/color_utils.dart';
|
import 'package:aves/utils/color_utils.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.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_dialog.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
class MetadataDirTile extends StatelessWidget {
|
class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final String title;
|
final String title;
|
||||||
final MetadataDirectory dir;
|
final MetadataDirectory dir;
|
||||||
final ValueNotifier<String> expandedDirectoryNotifier;
|
final ValueNotifier<String> expandedDirectoryNotifier;
|
||||||
final bool initiallyExpanded, showPrefixChildren;
|
final bool initiallyExpanded, showThumbnails;
|
||||||
|
|
||||||
const MetadataDirTile({
|
const MetadataDirTile({
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
|
@ -29,7 +35,7 @@ class MetadataDirTile extends StatelessWidget {
|
||||||
@required this.dir,
|
@required this.dir,
|
||||||
this.expandedDirectoryNotifier,
|
this.expandedDirectoryNotifier,
|
||||||
this.initiallyExpanded = false,
|
this.initiallyExpanded = false,
|
||||||
this.showPrefixChildren = true,
|
this.showThumbnails = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -38,52 +44,49 @@ class MetadataDirTile extends StatelessWidget {
|
||||||
if (tags.isEmpty) return SizedBox.shrink();
|
if (tags.isEmpty) return SizedBox.shrink();
|
||||||
|
|
||||||
final dirName = dir.name;
|
final dirName = dir.name;
|
||||||
|
Widget tile;
|
||||||
if (dirName == MetadataDirectory.xmpDirectory) {
|
if (dirName == MetadataDirectory.xmpDirectory) {
|
||||||
return XmpDirTile(
|
tile = XmpDirTile(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
expandedNotifier: expandedDirectoryNotifier,
|
expandedNotifier: expandedDirectoryNotifier,
|
||||||
initiallyExpanded: initiallyExpanded,
|
initiallyExpanded: initiallyExpanded,
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
Map<String, InfoLinkHandler> linkHandlers;
|
||||||
Widget thumbnail;
|
|
||||||
final prefixChildren = <Widget>[];
|
|
||||||
if (showPrefixChildren) {
|
|
||||||
switch (dirName) {
|
switch (dirName) {
|
||||||
case MetadataDirectory.exifThumbnailDirectory:
|
case SvgMetadataService.metadataDirectory:
|
||||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
|
linkHandlers = getSvgLinkHandlers(tags);
|
||||||
break;
|
break;
|
||||||
case MetadataDirectory.mediaDirectory:
|
case MetadataDirectory.coverDirectory:
|
||||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
|
linkHandlers = getVideoCoverLinkHandlers(tags);
|
||||||
Widget builder(IconData data) => Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
|
||||||
child: Icon(data),
|
|
||||||
);
|
|
||||||
if (tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
|
|
||||||
if (tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
|
|
||||||
if (tags['Has Image'] == 'yes') prefixChildren.add(builder(AIcons.image));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return AvesExpansionTile(
|
tile = AvesExpansionTile(
|
||||||
title: title,
|
title: title,
|
||||||
color: BrandColors.get(dirName) ?? stringToColor(dirName),
|
color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
|
||||||
expandedNotifier: expandedDirectoryNotifier,
|
expandedNotifier: expandedDirectoryNotifier,
|
||||||
initiallyExpanded: initiallyExpanded,
|
initiallyExpanded: initiallyExpanded,
|
||||||
children: [
|
children: [
|
||||||
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
|
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
|
||||||
if (thumbnail != null) thumbnail,
|
Padding(
|
||||||
Padding(
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
child: InfoRowGroup(
|
||||||
child: InfoRowGroup(
|
tags,
|
||||||
tags,
|
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
linkHandlers: linkHandlers,
|
||||||
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
|
}
|
||||||
|
return NotificationListener<OpenEmbeddedDataNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
_openEmbeddedData(context, notification);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: tile,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,4 +108,46 @@ class MetadataDirTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, InfoLinkHandler> getVideoCoverLinkHandlers(SplayTreeMap<String, String> tags) {
|
||||||
|
return {
|
||||||
|
'Image': InfoLinkHandler(
|
||||||
|
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||||
|
onTap: (context) => OpenEmbeddedDataNotification.videoCover().dispatch(context),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
||||||
|
Map fields;
|
||||||
|
switch (notification.source) {
|
||||||
|
case EmbeddedDataSource.videoCover:
|
||||||
|
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri);
|
||||||
|
break;
|
||||||
|
case EmbeddedDataSource.xmp:
|
||||||
|
fields = await metadataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
|
||||||
|
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mimeType = fields['mimeType'];
|
||||||
|
final uri = fields['uri'];
|
||||||
|
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
|
||||||
|
// open with another app
|
||||||
|
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
|
||||||
|
if (!success) {
|
||||||
|
// fallback to sharing, so that the file can be saved somewhere
|
||||||
|
AndroidAppService.shareSingle(uri, mimeType).then((success) {
|
||||||
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/video/keys.dart';
|
||||||
|
import 'package:aves/model/video/metadata.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/svg_metadata_service.dart';
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/color_utils.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -150,15 +155,13 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
}
|
}
|
||||||
|
|
||||||
final rawTags = dirKV.value as Map ?? {};
|
final rawTags = dirKV.value as Map ?? {};
|
||||||
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
|
return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
|
||||||
final value = (tagKV.value as String ?? '').trim();
|
|
||||||
if (value.isEmpty) return null;
|
|
||||||
final tagName = tagKV.key as String ?? '';
|
|
||||||
return MapEntry(tagName, value);
|
|
||||||
}).where((kv) => kv != null)));
|
|
||||||
return MetadataDirectory(directoryName, parent, tags);
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultipage)) {
|
||||||
|
directories.addAll(await _getStreamDirectories());
|
||||||
|
}
|
||||||
|
|
||||||
final titledDirectories = directories.map((dir) {
|
final titledDirectories = directories.map((dir) {
|
||||||
var title = dir.name;
|
var title = dir.name;
|
||||||
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
||||||
|
@ -176,12 +179,96 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
_expandedDirectoryNotifier.value = null;
|
_expandedDirectoryNotifier.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<MetadataDirectory>> _getStreamDirectories() async {
|
||||||
|
final directories = <MetadataDirectory>[];
|
||||||
|
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry);
|
||||||
|
|
||||||
|
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
|
||||||
|
if (formattedMediaTags.isNotEmpty) {
|
||||||
|
// overwrite generic directory found from the platform side
|
||||||
|
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, null, _toSortedTags(formattedMediaTags)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaInfo.containsKey(Keys.streams)) {
|
||||||
|
String getTypeText(Map stream) {
|
||||||
|
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
||||||
|
switch (type) {
|
||||||
|
case StreamTypes.audio:
|
||||||
|
return 'Audio';
|
||||||
|
case StreamTypes.metadata:
|
||||||
|
return 'Metadata';
|
||||||
|
case StreamTypes.subtitle:
|
||||||
|
case StreamTypes.timedText:
|
||||||
|
return 'Text';
|
||||||
|
case StreamTypes.video:
|
||||||
|
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
|
||||||
|
case StreamTypes.unknown:
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
||||||
|
final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList();
|
||||||
|
final knownStreams = allStreams.whereNot(unknownStreams.contains);
|
||||||
|
|
||||||
|
// display known streams as separate directories (e.g. video, audio, subs)
|
||||||
|
if (knownStreams.isNotEmpty) {
|
||||||
|
final indexDigits = knownStreams.length.toString().length;
|
||||||
|
|
||||||
|
for (final stream in knownStreams) {
|
||||||
|
final index = (stream[Keys.index] ?? 0) + 1;
|
||||||
|
final typeText = getTypeText(stream);
|
||||||
|
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText';
|
||||||
|
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
|
||||||
|
if (formattedStreamTags.isNotEmpty) {
|
||||||
|
final color = stringToColor(typeText);
|
||||||
|
directories.add(MetadataDirectory(dirName, null, _toSortedTags(formattedStreamTags), color: color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// display unknown streams as attachments (e.g. fonts)
|
||||||
|
if (unknownStreams.isNotEmpty) {
|
||||||
|
final unknownCodecCount = <String, List<String>>{};
|
||||||
|
for (final stream in unknownStreams) {
|
||||||
|
final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
|
||||||
|
if (!unknownCodecCount.containsKey(codec)) {
|
||||||
|
unknownCodecCount[codec] = [];
|
||||||
|
}
|
||||||
|
unknownCodecCount[codec].add(stream[Keys.filename]);
|
||||||
|
}
|
||||||
|
if (unknownCodecCount.isNotEmpty) {
|
||||||
|
final rawTags = unknownCodecCount.map((key, value) {
|
||||||
|
final count = value.length;
|
||||||
|
// remove duplicate names, so number of displayed names may not match displayed count
|
||||||
|
final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase);
|
||||||
|
return MapEntry(key, '$count items: ${names.join(', ')}');
|
||||||
|
});
|
||||||
|
directories.add(MetadataDirectory('Attachments', null, _toSortedTags(rawTags)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplayTreeMap<String, String> _toSortedTags(Map rawTags) {
|
||||||
|
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
|
||||||
|
final value = (tagKV.value as String ?? '').trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
final tagName = tagKV.key as String ?? '';
|
||||||
|
return MapEntry(tagName, value);
|
||||||
|
}).where((kv) => kv != null)));
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MetadataDirectory {
|
class MetadataDirectory {
|
||||||
final String name;
|
final String name;
|
||||||
|
final Color color;
|
||||||
final String parent;
|
final String parent;
|
||||||
final SplayTreeMap<String, String> allTags;
|
final SplayTreeMap<String, String> allTags;
|
||||||
final SplayTreeMap<String, String> tags;
|
final SplayTreeMap<String, String> tags;
|
||||||
|
@ -189,14 +276,15 @@ class MetadataDirectory {
|
||||||
// special directory names
|
// special directory names
|
||||||
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
||||||
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
||||||
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
|
static const mediaDirectory = 'Media'; // custom
|
||||||
|
static const coverDirectory = 'Cover'; // custom
|
||||||
|
|
||||||
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> tags})
|
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> tags, this.color})
|
||||||
: allTags = allTags,
|
: allTags = allTags,
|
||||||
tags = tags ?? allTags;
|
tags = tags ?? allTags;
|
||||||
|
|
||||||
MetadataDirectory filterKeys(bool Function(String key) testKey) {
|
MetadataDirectory filterKeys(bool Function(String key) testKey) {
|
||||||
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
|
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
|
||||||
return MetadataDirectory(name, parent, tags, tags: filteredTags);
|
return MetadataDirectory(name, parent, tags, tags: filteredTags, color: color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,11 @@ import 'package:aves/services/services.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
enum MetadataThumbnailSource { embedded, exif }
|
|
||||||
|
|
||||||
class MetadataThumbnails extends StatefulWidget {
|
class MetadataThumbnails extends StatefulWidget {
|
||||||
final MetadataThumbnailSource source;
|
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
const MetadataThumbnails({
|
const MetadataThumbnails({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.source,
|
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -32,14 +28,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
switch (widget.source) {
|
_loader = metadataService.getExifThumbnails(entry);
|
||||||
case MetadataThumbnailSource.embedded:
|
|
||||||
_loader = metadataService.getEmbeddedPictures(uri);
|
|
||||||
break;
|
|
||||||
case MetadataThumbnailSource.exif:
|
|
||||||
_loader = metadataService.getExifThumbnails(entry);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -116,16 +116,3 @@ class XmpProp {
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenEmbeddedDataNotification extends Notification {
|
|
||||||
final String propPath;
|
|
||||||
final String mimeType;
|
|
||||||
|
|
||||||
const OpenEmbeddedDataNotification({
|
|
||||||
@required this.propPath,
|
|
||||||
@required this.mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
abstract class XmpGoogleNamespace extends XmpNamespace {
|
abstract class XmpGoogleNamespace extends XmpNamespace {
|
||||||
|
@ -20,7 +21,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
|
||||||
dataProp.displayKey,
|
dataProp.displayKey,
|
||||||
InfoLinkHandler(
|
InfoLinkHandler(
|
||||||
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||||
onTap: (context) => OpenEmbeddedDataNotification(
|
onTap: (context) => OpenEmbeddedDataNotification.xmp(
|
||||||
propPath: dataProp.path,
|
propPath: dataProp.path,
|
||||||
mimeType: mimeProp.value,
|
mimeType: mimeProp.value,
|
||||||
).dispatch(context),
|
).dispatch(context),
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class XmpBasicNamespace extends XmpNamespace {
|
class XmpBasicNamespace extends XmpNamespace {
|
||||||
|
@ -33,7 +34,7 @@ class XmpBasicNamespace extends XmpNamespace {
|
||||||
if (struct.containsKey(thumbnailDataDisplayKey))
|
if (struct.containsKey(thumbnailDataDisplayKey))
|
||||||
thumbnailDataDisplayKey: InfoLinkHandler(
|
thumbnailDataDisplayKey: InfoLinkHandler(
|
||||||
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||||
onTap: (context) => OpenEmbeddedDataNotification(
|
onTap: (context) => OpenEmbeddedDataNotification.xmp(
|
||||||
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',
|
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',
|
||||||
mimeType: MimeTypes.jpeg,
|
mimeType: MimeTypes.jpeg,
|
||||||
).dispatch(context),
|
).dispatch(context),
|
||||||
|
|
|
@ -1,14 +1,8 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
|
||||||
import 'package:aves/ref/xmp.dart';
|
import 'package:aves/ref/xmp.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
|
||||||
import 'package:aves/services/services.dart';
|
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.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_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
|
||||||
|
@ -17,10 +11,8 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
|
||||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
|
||||||
|
|
||||||
class XmpDirTile extends StatefulWidget {
|
class XmpDirTile extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
@ -39,7 +31,7 @@ class XmpDirTile extends StatefulWidget {
|
||||||
_XmpDirTileState createState() => _XmpDirTileState();
|
_XmpDirTileState createState() => _XmpDirTileState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
class _XmpDirTileState extends State<XmpDirTile> {
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -83,49 +75,18 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
||||||
expandedNotifier: widget.expandedNotifier,
|
expandedNotifier: widget.expandedNotifier,
|
||||||
initiallyExpanded: widget.initiallyExpanded,
|
initiallyExpanded: widget.initiallyExpanded,
|
||||||
children: [
|
children: [
|
||||||
NotificationListener<OpenEmbeddedDataNotification>(
|
Padding(
|
||||||
onNotification: (notification) {
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
_openEmbeddedData(notification.propPath, notification.mimeType);
|
child: Column(
|
||||||
return true;
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
},
|
children: sections.entries
|
||||||
child: Padding(
|
.expand((kv) => kv.key.buildNamespaceSection(
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
rawProps: kv.value,
|
||||||
child: Column(
|
))
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
.toList(),
|
||||||
children: sections.entries
|
|
||||||
.expand((kv) => kv.key.buildNamespaceSection(
|
|
||||||
rawProps: kv.value,
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openEmbeddedData(String propPath, String propMimeType) async {
|
|
||||||
final fields = await metadataService.extractXmpDataProp(entry, propPath, propMimeType);
|
|
||||||
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
|
|
||||||
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final mimeType = fields['mimeType'];
|
|
||||||
final uri = fields['uri'];
|
|
||||||
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
|
|
||||||
// open with another app
|
|
||||||
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
|
|
||||||
if (!success) {
|
|
||||||
// fallback to sharing, so that the file can be saved somewhere
|
|
||||||
AndroidAppService.shareSingle(uri, mimeType).then((success) {
|
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,10 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class BackUpNotification extends Notification {}
|
class BackUpNotification extends Notification {}
|
||||||
|
|
||||||
class FilterNotification extends Notification {
|
class FilterSelectedNotification extends Notification {
|
||||||
final CollectionFilter filter;
|
final CollectionFilter filter;
|
||||||
|
|
||||||
const FilterNotification(this.filter);
|
const FilterSelectedNotification(this.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EntryDeletedNotification extends Notification {
|
class EntryDeletedNotification extends Notification {
|
||||||
|
@ -27,3 +27,34 @@ class OpenTempEntryNotification extends Notification {
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
|
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum EmbeddedDataSource { videoCover, xmp }
|
||||||
|
|
||||||
|
class OpenEmbeddedDataNotification extends Notification {
|
||||||
|
final EmbeddedDataSource source;
|
||||||
|
final String propPath;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
const OpenEmbeddedDataNotification._private({
|
||||||
|
@required this.source,
|
||||||
|
this.propPath,
|
||||||
|
this.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private(
|
||||||
|
source: EmbeddedDataSource.videoCover,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory OpenEmbeddedDataNotification.xmp({
|
||||||
|
@required String propPath,
|
||||||
|
@required String mimeType,
|
||||||
|
}) =>
|
||||||
|
OpenEmbeddedDataNotification._private(
|
||||||
|
source: EmbeddedDataSource.xmp,
|
||||||
|
propPath: propPath,
|
||||||
|
mimeType: mimeType,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}';
|
||||||
|
}
|
||||||
|
|
3
lib/widgets/viewer/overlay/notifications.dart
Normal file
3
lib/widgets/viewer/overlay/notifications.dart
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ToggleOverlayNotification extends Notification {}
|
|
@ -8,8 +8,9 @@ import 'package:aves/utils/time_utils.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/common/video/video.dart';
|
import 'package:aves/widgets/common/video/controller.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:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class VideoControlOverlay extends StatefulWidget {
|
class VideoControlOverlay extends StatefulWidget {
|
||||||
|
@ -35,9 +36,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
double _seekTargetPercent;
|
double _seekTargetPercent;
|
||||||
|
|
||||||
// video info is not refreshed by default, so we use a timer to do so
|
|
||||||
Timer _progressTimer;
|
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
Animation<double> get scale => widget.scale;
|
Animation<double> get scale => widget.scale;
|
||||||
|
@ -74,16 +72,13 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
|
|
||||||
void _registerWidget(VideoControlOverlay widget) {
|
void _registerWidget(VideoControlOverlay widget) {
|
||||||
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
|
_subscriptions.add(widget.controller.statusStream.listen(_onStatusChange));
|
||||||
_subscriptions.add(widget.controller.isVideoReadyStream.listen(_onVideoReadinessChanged));
|
|
||||||
_onStatusChange(widget.controller.status);
|
_onStatusChange(widget.controller.status);
|
||||||
_onVideoReadinessChanged(widget.controller.isVideoReady);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(VideoControlOverlay widget) {
|
void _unregisterWidget(VideoControlOverlay widget) {
|
||||||
_subscriptions
|
_subscriptions
|
||||||
..forEach((sub) => sub.cancel())
|
..forEach((sub) => sub.cancel())
|
||||||
..clear();
|
..clear();
|
||||||
_stopTimer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -122,7 +117,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
icon: AnimatedIcons.play_pause,
|
icon: AnimatedIcons.play_pause,
|
||||||
progress: _playPauseAnimation,
|
progress: _playPauseAnimation,
|
||||||
),
|
),
|
||||||
onPressed: _playPause,
|
onPressed: _togglePlayPause,
|
||||||
tooltip: isPlaying ? context.l10n.viewerPauseTooltip : context.l10n.viewerPlayTooltip,
|
tooltip: isPlaying ? context.l10n.viewerPauseTooltip : context.l10n.viewerPlayTooltip,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -169,20 +164,23 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
// do not use stream snapshot because it is obsolete when switching between videos
|
// do not use stream snapshot because it is obsolete when switching between videos
|
||||||
final position = controller.currentPosition?.floor() ?? 0;
|
final position = controller.currentPosition?.floor() ?? 0;
|
||||||
return Text(formatDuration(Duration(milliseconds: position)));
|
return Text(formatFriendlyDuration(Duration(milliseconds: position)));
|
||||||
}),
|
}),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
Text(entry.durationText),
|
Text(entry.durationText),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
StreamBuilder<int>(
|
ClipRRect(
|
||||||
stream: controller.positionStream,
|
borderRadius: BorderRadius.circular(4),
|
||||||
builder: (context, snapshot) {
|
child: StreamBuilder<int>(
|
||||||
// do not use stream snapshot because it is obsolete when switching between videos
|
stream: controller.positionStream,
|
||||||
var progress = controller.progress;
|
builder: (context, snapshot) {
|
||||||
if (!progress.isFinite) progress = 0.0;
|
// do not use stream snapshot because it is obsolete when switching between videos
|
||||||
return LinearProgressIndicator(value: progress);
|
var progress = controller.progress;
|
||||||
}),
|
if (!progress.isFinite) progress = 0.0;
|
||||||
|
return LinearProgressIndicator(value: progress);
|
||||||
|
}),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -191,24 +189,6 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startTimer() {
|
|
||||||
if (!controller.isVideoReady) return;
|
|
||||||
_progressTimer?.cancel();
|
|
||||||
_progressTimer = Timer.periodic(Durations.videoProgressTimerInterval, (_) => controller.refreshVideoInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _stopTimer() {
|
|
||||||
_progressTimer?.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onVideoReadinessChanged(bool isVideoReady) {
|
|
||||||
if (isVideoReady) {
|
|
||||||
_startTimer();
|
|
||||||
} else {
|
|
||||||
_stopTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onStatusChange(VideoStatus status) {
|
void _onStatusChange(VideoStatus status) {
|
||||||
if (status == VideoStatus.playing && _seekTargetPercent != null) {
|
if (status == VideoStatus.playing && _seekTargetPercent != null) {
|
||||||
_seekFromTarget();
|
_seekFromTarget();
|
||||||
|
@ -216,14 +196,24 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
_updatePlayPauseIcon();
|
_updatePlayPauseIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playPause() async {
|
Future<void> _togglePlayPause() async {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await controller.pause();
|
await controller.pause();
|
||||||
} else if (isPlayable) {
|
} else {
|
||||||
|
await _play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _play() async {
|
||||||
|
if (isPlayable) {
|
||||||
await controller.play();
|
await controller.play();
|
||||||
} else {
|
} else {
|
||||||
await controller.setDataSource(entry.uri);
|
await controller.setDataSource(entry.uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hide overlay
|
||||||
|
await Future.delayed(Durations.iconAnimation);
|
||||||
|
ToggleOverlayNotification().dispatch(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updatePlayPauseIcon() {
|
void _updatePlayPauseIcon() {
|
||||||
|
@ -244,16 +234,17 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
if (isPlayable) {
|
if (isPlayable) {
|
||||||
await _seekFromTarget();
|
await _seekFromTarget();
|
||||||
} else {
|
} else {
|
||||||
await controller.setDataSource(entry.uri);
|
// controller duration is not set yet, so we use the expected duration instead
|
||||||
|
final seekTargetMillis = (entry.durationMillis * _seekTargetPercent).toInt();
|
||||||
|
await controller.setDataSource(entry.uri, startMillis: seekTargetMillis);
|
||||||
|
_seekTargetPercent = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future _seekFromTarget() async {
|
Future _seekFromTarget() async {
|
||||||
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
|
// `seekToProgress` is not safe as it can be called when the `duration` is not set yet
|
||||||
// so we make sure the video info is up to date first
|
// so we make sure the video info is up to date first
|
||||||
if (controller.duration == null) {
|
if (controller.duration != null) {
|
||||||
await controller.refreshVideoInfo();
|
|
||||||
} else {
|
|
||||||
await controller.seekToProgress(_seekTargetPercent);
|
await controller.seekToProgress(_seekTargetPercent);
|
||||||
_seekTargetPercent = null;
|
_seekTargetPercent = null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,18 +2,22 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_images.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/settings/entry_background.dart';
|
import 'package:aves/model/settings/entry_background.dart';
|
||||||
import 'package:aves/model/settings/enums.dart';
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/widgets/collection/collection_page.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||||
import 'package:aves/widgets/common/video/video.dart';
|
import 'package:aves/widgets/common/video/controller.dart';
|
||||||
import 'package:aves/widgets/viewer/hero.dart';
|
import 'package:aves/widgets/viewer/hero.dart';
|
||||||
|
import 'package:aves/widgets/viewer/overlay/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/error.dart';
|
import 'package:aves/widgets/viewer/visual/error.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/raster.dart';
|
import 'package:aves/widgets/viewer/visual/raster.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/state.dart';
|
import 'package:aves/widgets/viewer/visual/state.dart';
|
||||||
|
@ -30,7 +34,6 @@ class EntryPageView extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final SinglePageInfo page;
|
final SinglePageInfo page;
|
||||||
final Size viewportSize;
|
final Size viewportSize;
|
||||||
final MagnifierTapCallback onTap;
|
|
||||||
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
final List<Tuple2<String, AvesVideoController>> videoControllers;
|
||||||
final VoidCallback onDisposed;
|
final VoidCallback onDisposed;
|
||||||
|
|
||||||
|
@ -41,7 +44,6 @@ class EntryPageView extends StatefulWidget {
|
||||||
this.mainEntry,
|
this.mainEntry,
|
||||||
this.page,
|
this.page,
|
||||||
this.viewportSize,
|
this.viewportSize,
|
||||||
@required this.onTap,
|
|
||||||
@required this.videoControllers,
|
@required this.videoControllers,
|
||||||
this.onDisposed,
|
this.onDisposed,
|
||||||
}) : entry = mainEntry.getPageEntry(page) ?? mainEntry,
|
}) : entry = mainEntry.getPageEntry(page) ?? mainEntry,
|
||||||
|
@ -62,8 +64,6 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
|
|
||||||
Size get viewportSize => widget.viewportSize;
|
Size get viewportSize => widget.viewportSize;
|
||||||
|
|
||||||
MagnifierTapCallback get onTap => widget.onTap;
|
|
||||||
|
|
||||||
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
||||||
static const minScale = ScaleLevel(ref: ScaleReference.contained);
|
static const minScale = ScaleLevel(ref: ScaleReference.contained);
|
||||||
static const maxScale = ScaleLevel(factor: 2.0);
|
static const maxScale = ScaleLevel(factor: 2.0);
|
||||||
|
@ -138,7 +138,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
}
|
}
|
||||||
child ??= ErrorView(
|
child ??= ErrorView(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
onTap: onTap == null ? null : () => onTap(null),
|
onTap: _onTap,
|
||||||
);
|
);
|
||||||
return child;
|
return child;
|
||||||
},
|
},
|
||||||
|
@ -162,7 +162,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
viewStateNotifier: _viewStateNotifier,
|
viewStateNotifier: _viewStateNotifier,
|
||||||
errorBuilder: (context, error, stackTrace) => ErrorView(
|
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
onTap: () => onTap?.call(null),
|
onTap: _onTap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -197,11 +197,38 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
Widget _buildVideoView() {
|
Widget _buildVideoView() {
|
||||||
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||||
if (videoController == null) return SizedBox();
|
if (videoController == null) return SizedBox();
|
||||||
return _buildMagnifier(
|
return Stack(
|
||||||
child: VideoView(
|
fit: StackFit.expand,
|
||||||
entry: entry,
|
children: [
|
||||||
controller: videoController,
|
_buildMagnifier(
|
||||||
),
|
child: VideoView(
|
||||||
|
entry: entry,
|
||||||
|
controller: videoController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// fade out image to ease transition with the player
|
||||||
|
StreamBuilder<VideoStatus>(
|
||||||
|
stream: videoController.statusStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final showCover = videoController.isPlayable;
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: showCover,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: showCover ? 0 : 1,
|
||||||
|
curve: Curves.easeInCirc,
|
||||||
|
duration: Durations.viewerVideoPlayerTransition,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _onTap,
|
||||||
|
child: Image(
|
||||||
|
image: entry.getBestThumbnail(settings.getTileExtent(CollectionPage.routeName)),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,11 +248,13 @@ class _EntryPageViewState extends State<EntryPageView> {
|
||||||
initialScale: initialScale,
|
initialScale: initialScale,
|
||||||
scaleStateCycle: scaleStateCycle,
|
scaleStateCycle: scaleStateCycle,
|
||||||
applyScale: applyScale,
|
applyScale: applyScale,
|
||||||
onTap: onTap == null ? null : (c, d, s, childPosition) => onTap(childPosition),
|
onTap: (c, d, s, o) => _onTap(),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTap() => ToggleOverlayNotification().dispatch(context);
|
||||||
|
|
||||||
void _onViewStateChanged(MagnifierState v) {
|
void _onViewStateChanged(MagnifierState v) {
|
||||||
final current = _viewStateNotifier.value;
|
final current = _viewStateNotifier.value;
|
||||||
final viewState = ViewState(v.position, v.scale, current.viewportSize);
|
final viewState = ViewState(v.position, v.scale, current.viewportSize);
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_images.dart';
|
import 'package:aves/widgets/common/video/controller.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
|
||||||
import 'package:aves/widgets/collection/collection_page.dart';
|
|
||||||
import 'package:aves/widgets/common/video/video.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class VideoView extends StatefulWidget {
|
class VideoView extends StatefulWidget {
|
||||||
|
@ -53,23 +48,16 @@ class _VideoViewState extends State<VideoView> {
|
||||||
widget.controller.playCompletedListenable.removeListener(_onPlayCompleted);
|
widget.controller.playCompletedListenable.removeListener(_onPlayCompleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isPlayable(VideoStatus status) => controller != null && [VideoStatus.prepared, VideoStatus.playing, VideoStatus.paused, VideoStatus.completed].contains(status);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (controller == null) return SizedBox();
|
if (controller == null) return SizedBox();
|
||||||
return StreamBuilder<VideoStatus>(
|
return StreamBuilder<VideoStatus>(
|
||||||
stream: widget.controller.statusStream,
|
stream: controller.statusStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final status = snapshot.data;
|
return controller.isPlayable ? controller.buildPlayerWidget(context, entry) : SizedBox();
|
||||||
return isPlayable(status)
|
|
||||||
? controller.buildPlayerWidget(entry)
|
|
||||||
: Image(
|
|
||||||
image: entry.getBestThumbnail(settings.getTileExtent(CollectionPage.routeName)),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// not called when looping
|
||||||
void _onPlayCompleted() => controller.seekTo(0);
|
void _onPlayCompleted() => controller.seekTo(0);
|
||||||
}
|
}
|
||||||
|
|
63
pubspec.lock
63
pubspec.lock
|
@ -7,14 +7,14 @@ packages:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "18.0.0"
|
version: "19.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.3.0"
|
||||||
ansicolor:
|
ansicolor:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -175,7 +175,7 @@ packages:
|
||||||
name: decorated_icon
|
name: decorated_icon
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.1.0"
|
||||||
event_bus:
|
event_bus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -206,6 +206,15 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
fijkplayer:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: aves
|
||||||
|
resolved-ref: "0f25874db46d1af6fcfbeb8722915cbc211a10fb"
|
||||||
|
url: "git://github.com/deckerst/fijkplayer.git"
|
||||||
|
source: git
|
||||||
|
version: "0.8.7"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -219,7 +228,7 @@ packages:
|
||||||
name: firebase
|
name: firebase
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.0"
|
version: "9.0.1"
|
||||||
firebase_analytics:
|
firebase_analytics:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -247,7 +256,7 @@ packages:
|
||||||
name: firebase_core
|
name: firebase_core
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.2"
|
||||||
firebase_core_platform_interface:
|
firebase_core_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -261,7 +270,7 @@ packages:
|
||||||
name: firebase_core_web
|
name: firebase_core_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.2"
|
||||||
firebase_crashlytics:
|
firebase_crashlytics:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -300,15 +309,6 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0"
|
version: "0.7.0"
|
||||||
flutter_ijkplayer:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: "."
|
|
||||||
ref: HEAD
|
|
||||||
resolved-ref: d4e079404ba8e4f82a7e053ffdc47af787a61c3b
|
|
||||||
url: "git://github.com/deckerst/flutter_ijkplayer.git"
|
|
||||||
source: git
|
|
||||||
version: "0.3.7"
|
|
||||||
flutter_image:
|
flutter_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -321,13 +321,6 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_localized_locales:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_localized_locales
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.1"
|
|
||||||
flutter_map:
|
flutter_map:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -341,14 +334,14 @@ packages:
|
||||||
name: flutter_markdown
|
name: flutter_markdown
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.1"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
flutter_staggered_animations:
|
flutter_staggered_animations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -398,7 +391,7 @@ packages:
|
||||||
name: glob
|
name: glob
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
google_api_availability:
|
google_api_availability:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -412,14 +405,14 @@ packages:
|
||||||
name: google_maps_flutter
|
name: google_maps_flutter
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.2"
|
||||||
google_maps_flutter_platform_interface:
|
google_maps_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: google_maps_flutter_platform_interface
|
name: google_maps_flutter_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.4"
|
||||||
highlight:
|
highlight:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -433,7 +426,7 @@ packages:
|
||||||
name: http
|
name: http
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.13.1"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -454,7 +447,7 @@ packages:
|
||||||
name: image
|
name: image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -482,7 +475,7 @@ packages:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.1"
|
||||||
latlong:
|
latlong:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -650,7 +643,7 @@ packages:
|
||||||
name: pdf
|
name: pdf
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.1.0"
|
||||||
pedantic:
|
pedantic:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -664,7 +657,7 @@ packages:
|
||||||
name: percent_indicator
|
name: percent_indicator
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.9+1"
|
version: "3.0.1"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -720,7 +713,7 @@ packages:
|
||||||
name: printing
|
name: printing
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.3"
|
version: "5.0.4"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -984,7 +977,7 @@ packages:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "6.0.3"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1082,7 +1075,7 @@ packages:
|
||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.5"
|
||||||
wkt_parser:
|
wkt_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
46
pubspec.yaml
46
pubspec.yaml
|
@ -1,7 +1,7 @@
|
||||||
name: aves
|
name: aves
|
||||||
description: A visual media gallery and metadata explorer app.
|
description: A visual media gallery and metadata explorer app.
|
||||||
repository: https://github.com/deckerst/aves
|
repository: https://github.com/deckerst/aves
|
||||||
version: 1.3.7+43
|
version: 1.4.0+44
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
@ -15,8 +15,7 @@ environment:
|
||||||
|
|
||||||
# not null safe, as of 2021/03/13
|
# not null safe, as of 2021/03/13
|
||||||
# `charts_flutter` - https://github.com/google/charts/issues/579
|
# `charts_flutter` - https://github.com/google/charts/issues/579
|
||||||
# `decorated_icon` - https://github.com/benPesso/flutter_decorated_icon/issues/2
|
# `fijkplayer` - https://github.com/befovy/fijkplayer/issues/381
|
||||||
# `flutter_ijkplayer` - unmaintained?
|
|
||||||
# `flutter_map` - https://github.com/fleaflet/flutter_map/issues/829
|
# `flutter_map` - https://github.com/fleaflet/flutter_map/issues/829
|
||||||
# `latlong` - archived - migrate to maps_toolkit? cf https://github.com/fleaflet/flutter_map/pull/750
|
# `latlong` - archived - migrate to maps_toolkit? cf https://github.com/fleaflet/flutter_map/pull/750
|
||||||
# `streams_channel` - unmaintained? - no issue/PR
|
# `streams_channel` - unmaintained? - no issue/PR
|
||||||
|
@ -33,22 +32,16 @@ dependencies:
|
||||||
decorated_icon:
|
decorated_icon:
|
||||||
event_bus:
|
event_bus:
|
||||||
expansion_tile_card:
|
expansion_tile_card:
|
||||||
# path: ../expansion_tile_card
|
|
||||||
git:
|
git:
|
||||||
url: git://github.com/deckerst/expansion_tile_card.git
|
url: git://github.com/deckerst/expansion_tile_card.git
|
||||||
|
fijkplayer:
|
||||||
|
git:
|
||||||
|
url: git://github.com/deckerst/fijkplayer.git
|
||||||
|
ref: aves
|
||||||
firebase_core:
|
firebase_core:
|
||||||
firebase_analytics:
|
firebase_analytics:
|
||||||
firebase_crashlytics:
|
firebase_crashlytics:
|
||||||
flutter_highlight:
|
flutter_highlight:
|
||||||
# fijkplayer:
|
|
||||||
## path: ../fijkplayer
|
|
||||||
# git:
|
|
||||||
# url: git://github.com/deckerst/fijkplayer.git
|
|
||||||
# ref: aves-config
|
|
||||||
flutter_ijkplayer:
|
|
||||||
git:
|
|
||||||
url: git://github.com/deckerst/flutter_ijkplayer.git
|
|
||||||
flutter_localized_locales:
|
|
||||||
flutter_map:
|
flutter_map:
|
||||||
flutter_markdown:
|
flutter_markdown:
|
||||||
flutter_staggered_animations:
|
flutter_staggered_animations:
|
||||||
|
@ -114,6 +107,7 @@ flutter:
|
||||||
# - /android/app/src/main/res/values-{language}/strings.xml
|
# - /android/app/src/main/res/values-{language}/strings.xml
|
||||||
# - /android/app/src/debug/res/values-{language}/strings.xml (optional)
|
# - /android/app/src/debug/res/values-{language}/strings.xml (optional)
|
||||||
# - /android/app/src/profile/res/values-{language}/strings.xml (optional)
|
# - /android/app/src/profile/res/values-{language}/strings.xml (optional)
|
||||||
|
# - edit locale name resolution for language setting
|
||||||
|
|
||||||
# generate `AppLocalizations`
|
# generate `AppLocalizations`
|
||||||
# % flutter gen-l10n
|
# % flutter gen-l10n
|
||||||
|
@ -126,29 +120,3 @@ flutter:
|
||||||
|
|
||||||
# capture shaders in profile mode (real device only):
|
# capture shaders in profile mode (real device only):
|
||||||
# % flutter drive -t test_driver/app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json
|
# % flutter drive -t test_driver/app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json
|
||||||
|
|
||||||
################################################################################
|
|
||||||
# Package study
|
|
||||||
|
|
||||||
# brendan-duncan/image (as of v2.1.19):
|
|
||||||
# - does not support TIFF with JPEG compression (issue #184)
|
|
||||||
# - TIFF tile decoding is not public (issue #258)
|
|
||||||
|
|
||||||
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
|
||||||
# - does not support content URIs (by default, but trivial by fork)
|
|
||||||
# - does not support AVI/XVID, AC3
|
|
||||||
# - cannot play if only the video or audio stream is supported
|
|
||||||
|
|
||||||
# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg):
|
|
||||||
# ~ support content URIs (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android <Q)
|
|
||||||
# + does not support AC3 (by default, but possible by custom build)
|
|
||||||
# + can play if only the video or audio stream is supported
|
|
||||||
# - edge smear on some videos, depending on dimensions (dimension not multiple of 16?)
|
|
||||||
# - unmaintained
|
|
||||||
|
|
||||||
# fijkplayer (as of v0.8.7, backed by IJKPlayer & ffmpeg):
|
|
||||||
# + support content URIs
|
|
||||||
# + does not support XVID, AC3 (by default, but possible by custom build)
|
|
||||||
# + can play if only the video or audio stream is supported
|
|
||||||
# + no edge smear (with default build)
|
|
||||||
# - crash when calling `seekTo` for some files, cf https://github.com/befovy/fijkplayer/issues/360
|
|
||||||
|
|
|
@ -254,6 +254,8 @@ void main() {
|
||||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}', '1'),
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}', '1'),
|
||||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Seneca', '1'),
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Seneca', '1'),
|
||||||
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Seneca', '1'),
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Seneca', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Pictures/Cicero', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Marcus Aurelius', '1'),
|
||||||
};
|
};
|
||||||
|
|
||||||
await androidFileUtils.init();
|
await androidFileUtils.init();
|
||||||
|
@ -269,6 +271,8 @@ void main() {
|
||||||
expect(source.getAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription);
|
expect(source.getAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription);
|
||||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca');
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca');
|
||||||
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca');
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Cicero'), 'Cicero');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Marcus Aurelius'), 'Marcus Aurelius');
|
||||||
return Placeholder();
|
return Placeholder();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
Thanks for using Aves!
|
Thanks for using Aves!
|
||||||
v1.3.7:
|
v1.4.0:
|
||||||
- fast scroll label
|
- improved video support
|
||||||
- localized common album names
|
- more consistent and comprehensive info for videos
|
||||||
- customizable shortcut icon image
|
- options to auto play and loop videos
|
||||||
- customizable viewer quick actions
|
|
||||||
- option to hide videos from collection
|
|
||||||
Full changelog available on Github
|
Full changelog available on Github
|
Loading…
Reference in a new issue