Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2024-05-01 18:03:08 +02:00
commit 645c199b33
134 changed files with 1132 additions and 9814 deletions

@ -1 +1 @@
Subproject commit 300451adae589accbece3490f4396f10bdf15e6e Subproject commit 54e66469a933b60ddf175f858f82eaeb97e48c8d

View file

@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
## <a id="v1.11.0"></a>[v1.11.0] - 2024-05-01
### Added
- Cataloguing: identify Apple variant of HDR images
- Collection: allow using hash (md5/sha1/sha256) when bulk renaming
- Info: color palette
- Video: external subtitle support (SRT)
- option to force using western arabic numerals for dates
### Changed
- logo
- upgraded Flutter to stable v3.19.6
### Fixed
- rendering of SVG with large header
- stopping video playback when changing device orientation on Android >=13
- printing content orientation according to page format
## <a id="v1.10.9"></a>[v1.10.9] - 2024-04-14 ## <a id="v1.10.9"></a>[v1.10.9] - 2024-04-14
### Fixed ### Fixed

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_flavour">#7B1FA2</color> <color name="ic_launcher_flavour">#815AFA</color>
</resources> </resources>

View file

@ -153,13 +153,13 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
} }
val pageIndex = id - 1 val pageIndex = id - 1
val mpEntries = MultiPage.getJpegMpfEntries(context, uri) val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes)
if (mpEntries != null && pageIndex < mpEntries.size) { if (mpEntries != null && pageIndex < mpEntries.size) {
val mpEntry = mpEntries[pageIndex] val mpEntry = mpEntries[pageIndex]
mpEntry.mimeType?.let { embedMimeType -> mpEntry.mimeType?.let { embedMimeType ->
var dataOffset = mpEntry.dataOffset var dataOffset = mpEntry.dataOffset
if (dataOffset > 0) { if (dataOffset > 0) {
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri) val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
if (baseOffset != null) { if (baseOffset != null) {
dataOffset += baseOffset dataOffset += baseOffset
} }

View file

@ -20,6 +20,7 @@ import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifSubIFDDirectory import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.exif.makernotes.AppleMakernoteDirectory
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory import com.drew.metadata.iptc.IptcDirectory
@ -69,6 +70,8 @@ import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString
import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir
import deckers.thibault.aves.metadata.metadataextractor.PngActlDirectory import deckers.thibault.aves.metadata.metadataextractor.PngActlDirectory
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory
import deckers.thibault.aves.metadata.xmp.GoogleXMP import deckers.thibault.aves.metadata.xmp.GoogleXMP
import deckers.thibault.aves.metadata.xmp.XMP import deckers.thibault.aves.metadata.xmp.XMP
@ -82,6 +85,7 @@ import deckers.thibault.aves.metadata.xmp.XMP.isMotionPhoto
import deckers.thibault.aves.metadata.xmp.XMP.isPanorama import deckers.thibault.aves.metadata.xmp.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue
import deckers.thibault.aves.utils.HashUtils
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.TIFF_EXTENSION_PATTERN import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
@ -101,6 +105,7 @@ import org.json.JSONObject
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.ParseException import java.text.ParseException
import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -392,6 +397,21 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// do not overwrite XMP parsed by metadata-extractor // do not overwrite XMP parsed by metadata-extractor
// with raw XMP found by ExifInterface // with raw XMP found by ExifInterface
allTags.remove(Metadata.DIR_XMP) allTags.remove(Metadata.DIR_XMP)
} else {
val xmpTags = allTags[Metadata.DIR_XMP]
if (xmpTags != null) {
val xmpRaw = xmpTags[ExifInterface.TAG_XMP]
if (xmpRaw != null) {
val metadata = com.drew.metadata.Metadata()
val xmpBytes = xmpRaw.toByteArray(Charsets.UTF_8)
SafeXmpReader().extract(xmpBytes, 0, xmpBytes.size, metadata, null)
metadata.getFirstDirectoryOfType(XmpDirectory::class.java)?.let { xmpDir ->
val dirMap = HashMap<String, String>()
processXmp(xmpDir.xmpMeta, dirMap, allowMultiple = true)
allTags[Metadata.DIR_XMP] = dirMap
}
}
}
} }
metadataMap.putAll(allTags.mapValues { it.value.toMutableMap() }) metadataMap.putAll(allTags.mapValues { it.value.toMutableMap() })
} }
@ -639,6 +659,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// JPEG Multi-Picture Format // JPEG Multi-Picture Format
if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) { if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) {
flags = flags or MASK_IS_MULTIPAGE flags = flags or MASK_IS_MULTIPAGE
if (hasAppleHdrGainMap(uri, sizeBytes, metadata)) {
flags = flags or MASK_IS_HDR
}
} }
// XMP // XMP
@ -765,6 +789,29 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
metadataMap[KEY_FLAGS] = flags metadataMap[KEY_FLAGS] = flags
} }
private fun hasAppleHdrGainMap(uri: Uri, sizeBytes: Long?, primaryMetadata: com.drew.metadata.Metadata): Boolean {
if (!primaryMetadata.containsDirectoryOfType(AppleMakernoteDirectory::class.java)) return false
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes) ?: return false
mpEntries.filter { it.type == MpEntry.TYPE_UNDEFINED }.forEach { mpEntry ->
var dataOffset = mpEntry.dataOffset
if (dataOffset > 0) {
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
if (baseOffset != null) {
dataOffset += baseOffset
}
}
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(dataOffset)
val pageMetadata = Helper.safeRead(input)
if (pageMetadata.getDirectoriesOfType(XmpDirectory::class.java).any { it.xmpMeta.hasHdrGainMap() }) {
return true
}
}
}
return false
}
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever( private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
mimeType: String, mimeType: String,
uri: Uri, uri: Uri,
@ -1004,7 +1051,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} else { } else {
when (mimeType) { when (mimeType) {
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri) MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
MimeTypes.JPEG -> MultiPage.getJpegMpfPages(context, uri) MimeTypes.JPEG -> MultiPage.getJpegMpfPages(context, uri, sizeBytes)
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri) MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
else -> null else -> null
} }
@ -1262,10 +1309,36 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return return
} }
val metadataMap = HashMap<String, Any>() val metadataMap = HashMap<String, Any?>()
if (fields.isEmpty() || isVideo(mimeType)) {
val hashFields = fields.filter { it.startsWith(HASH_FIELD_PREFIX) }.toSet()
metadataMap.putAll(getHashFields(uri, mimeType, sizeBytes, hashFields))
val exifFields = fields.filterNot { hashFields.contains(it) }.toSet()
metadataMap.putAll(getExifFields(uri, mimeType, sizeBytes, exifFields))
result.success(metadataMap) result.success(metadataMap)
return }
private fun getHashFields(uri: Uri, mimeType: String, sizeBytes: Long?, fields: Set<String>): FieldMap {
val metadataMap = HashMap<String, Any?>()
fields.forEach { field ->
val function = field.substringAfter(HASH_FIELD_PREFIX).lowercase(Locale.ROOT)
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
metadataMap[field] = HashUtils.getHash(input, function)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get hash for mimeType=$mimeType uri=$uri function=$function", e)
}
}
return metadataMap
}
private fun getExifFields(uri: Uri, mimeType: String, sizeBytes: Long?, fields: Set<String>): FieldMap {
val metadataMap = HashMap<String, Any?>()
if (fields.isEmpty() || isVideo(mimeType)) {
return metadataMap
} }
var foundExif = false var foundExif = false
@ -1314,7 +1387,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
result.success(metadataMap) return metadataMap
} }
companion object { companion object {
@ -1389,6 +1462,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// additional media key // additional media key
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture" private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
private const val HASH_FIELD_PREFIX = "hash"
private const val VALUE_SKIPPED_DATA = "[skipped]" private const val VALUE_SKIPPED_DATA = "[skipped]"
} }
} }

View file

@ -134,7 +134,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return return
} }
val trashDirs = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) } val trashDirs = context.getExternalFilesDirs(null).filterNotNull().mapNotNull { StorageUtils.trashDirFor(context, it.path) }
val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.filterNotNull()?.mapNotNull { file -> file.path } ?: listOf() } val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.filterNotNull()?.mapNotNull { file -> file.path } ?: listOf() }
val untrackedPaths = trashItemPaths.filterNot(knownPaths::contains).toList() val untrackedPaths = trashItemPaths.filterNot(knownPaths::contains).toList()

View file

@ -10,6 +10,7 @@ import com.caverock.androidsvg.PreserveAspectRatio
import com.caverock.androidsvg.RenderOptions import com.caverock.androidsvg.RenderOptions
import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
@ -47,7 +48,7 @@ class SvgRegionFetcher internal constructor(
if (currentSvgRef == null) { if (currentSvgRef == null) {
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input -> val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
try { try {
SVG.getFromInputStream(input) SVG.getFromInputStream(SVGParserBufferedInputStream(input))
} catch (ex: SVGParseException) { } catch (ex: SVGParseException) {
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null) result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
return return

View file

@ -146,7 +146,7 @@ class ThumbnailFetcher internal constructor(
return try { return try {
var bitmap = target.get() var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType, pageId)) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
bitmap bitmap

View file

@ -279,6 +279,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ActivityResultStreamHandler>() private val LOG_TAG = LogUtils.createTag<ActivityResultStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/activity_result_stream" const val CHANNEL = "deckers.thibault/aves/activity_result_stream"
private const val BUFFER_SIZE = 2 shl 17 // 256kB private const val BUFFER_SIZE = 1 shl 18 // 256kB
} }
} }

View file

@ -145,7 +145,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
.submit() .submit()
try { try {
var bitmap = withContext(Dispatchers.IO) { target.get() } var bitmap = withContext(Dispatchers.IO) { target.get() }
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType, pageId)) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
if (bitmap != null) { if (bitmap != null) {

View file

@ -18,6 +18,7 @@ import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import kotlin.math.ceil import kotlin.math.ceil
@ -52,7 +53,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
val bitmap: Bitmap? = StorageUtils.openInputStream(context, uri)?.use { input -> val bitmap: Bitmap? = StorageUtils.openInputStream(context, uri)?.use { input ->
try { try {
SVG.getFromInputStream(input)?.let { svg -> SVG.getFromInputStream(SVGParserBufferedInputStream(input))?.let { svg ->
svg.normalizeSize() svg.normalizeSize()
val viewBox = svg.documentViewBox val viewBox = svg.documentViewBox
val svgWidth = viewBox.width() val svgWidth = viewBox.width()
@ -60,7 +61,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
val bitmapWidth: Int val bitmapWidth: Int
val bitmapHeight: Int val bitmapHeight: Int
if (width / height > svgWidth / svgHeight) { if (width / height.toFloat() > svgWidth / svgHeight) {
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt() bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
bitmapHeight = height bitmapHeight = height
} else { } else {

View file

@ -63,7 +63,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
TiffBitmapFactory.decodeFileDescriptor(fd, options) TiffBitmapFactory.decodeFileDescriptor(fd, options)
val imageWidth = options.outWidth val imageWidth = options.outWidth
val imageHeight = options.outHeight val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) { if (imageWidth > width || imageHeight > height) {
while (imageHeight / (sampleSize * 2) >= height && imageWidth / (sampleSize * 2) >= width) { while (imageHeight / (sampleSize * 2) >= height && imageWidth / (sampleSize * 2) >= width) {
sampleSize *= 2 sampleSize *= 2
} }

View file

@ -1,8 +1,11 @@
package deckers.thibault.aves.decoder package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
@ -16,7 +19,9 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
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.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -24,6 +29,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import kotlin.math.ceil
import kotlin.math.roundToInt
@GlideModule @GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() { class VideoThumbnailGlideModule : LibraryGlideModule() {
@ -36,7 +43,7 @@ class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> { internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> { override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model)) return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
} }
override fun handles(model: VideoThumbnail): Boolean = true override fun handles(model: VideoThumbnail): Boolean = true
@ -48,7 +55,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
} }
} }
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> { internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) { override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
@ -68,10 +75,62 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
if (durationMillis != null) { if (durationMillis != null) {
timeMillis = if (durationMillis < 15000) 0 else 15000 timeMillis = if (durationMillis < 15000) 0 else 15000
} }
val frame = if (timeMillis != null) { val timeMicros = if (timeMillis != null) timeMillis * 1000 else -1
retriever.getFrameAtTime(timeMillis * 1000) val option = MediaMetadataRetriever.OPTION_CLOSEST_SYNC
var videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
var videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
if (videoWidth == null || videoHeight == null) {
throw Exception("failed to get video dimensions")
}
var dstWidth = 0
var dstHeight = 0
if (width > 0 && height > 0) {
val rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull()
if (rotationDegrees != null) {
val isRotated = rotationDegrees % 180 == 90
if (isRotated) {
val temp = videoWidth
videoWidth = videoHeight
videoHeight = temp
}
// cover fit
val videoAspectRatio = videoWidth / videoHeight
if (videoWidth > width || videoHeight > height) {
if (width / height.toFloat() > videoAspectRatio) {
dstHeight = ceil(videoHeight * width / videoWidth).toInt()
dstWidth = (dstHeight * videoAspectRatio).roundToInt()
} else { } else {
retriever.frameAtTime dstWidth = ceil(videoWidth * height / videoHeight).toInt()
dstHeight = (dstWidth / videoAspectRatio).roundToInt()
}
}
}
}
// the returned frame is already rotated according to the video metadata
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val targetBitmapSizeBytes: Long = FORMAT_BYTE_SIZE.toLong() * dstWidth * dstHeight
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight, getBitmapParams())
} else {
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
}
} else {
val targetBitmapSizeBytes: Long = (FORMAT_BYTE_SIZE.toLong() * videoWidth * videoHeight).toLong()
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
retriever.getFrameAtTime(timeMicros, option, getBitmapParams())
} else {
retriever.getFrameAtTime(timeMicros, option)
}
} }
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false) bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
} }
@ -91,6 +150,17 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
} }
} }
@RequiresApi(Build.VERSION_CODES.P)
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
// for wide-gamut and HDR content which does not require alpha blending
setPreferredConfig(Bitmap.Config.RGBA_1010102)
} else {
setPreferredConfig(Bitmap.Config.ARGB_8888)
}
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd // already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {} override fun cleanup() {}
@ -100,4 +170,9 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
override fun getDataClass(): Class<InputStream> = InputStream::class.java override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL override fun getDataSource(): DataSource = DataSource.LOCAL
companion object {
// same for either `ARGB_8888` or `RGBA_1010102`
private const val FORMAT_BYTE_SIZE = BitmapUtils.ARGB_8888_BYTE_SIZE
}
} }

View file

@ -9,10 +9,15 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import com.drew.imaging.jpeg.JpegSegmentType import com.drew.imaging.jpeg.JpegSegmentType
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory
import deckers.thibault.aves.metadata.xmp.GoogleXMP import deckers.thibault.aves.metadata.xmp.GoogleXMP
@ -20,6 +25,7 @@ import deckers.thibault.aves.metadata.xmp.XMP
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
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.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.indexOfBytes import deckers.thibault.aves.utils.indexOfBytes
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
@ -83,13 +89,58 @@ object MultiPage {
return tracks return tracks
} }
private fun getJpegMpfPrimaryRotation(context: Context, uri: Uri, sizeBytes: Long): Int {
val mimeType = MimeTypes.JPEG
var rotationDegrees = 0
var foundExif = false
if (canReadWithMetadataExtractor(mimeType)) {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input)
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
rotationDegrees = Metadata.getRotationDegreesForExifCode(it)
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
} catch (e: AssertionError) {
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
}
}
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val exif = ExifInterface(input)
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) {
rotationDegrees = exif.rotationDegrees
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e)
}
}
return rotationDegrees
}
// starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]` // starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]`
fun getJpegMpfBaseOffset(context: Context, uri: Uri): Int? { fun getJpegMpfBaseOffset(context: Context, uri: Uri, sizeBytes: Long?): Int? {
val mimeType = MimeTypes.JPEG
val app2Marker = JpegSegmentType.APP2.byteValue val app2Marker = JpegSegmentType.APP2.byteValue
val mpfMarker = "MPF".toByteArray() + 0x00 val mpfMarker = "MPF".toByteArray() + 0x00
try { try {
Metadata.openSafeInputStream(context, uri, MimeTypes.JPEG, null)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
var offset = 0 var offset = 0
while (true) { while (true) {
do { do {
@ -113,9 +164,10 @@ object MultiPage {
return null return null
} }
fun getJpegMpfEntries(context: Context, uri: Uri): List<MpEntry>? { fun getJpegMpfEntries(context: Context, uri: Uri, sizeBytes: Long?): List<MpEntry>? {
val mimeType = MimeTypes.JPEG
try { try {
Metadata.openSafeInputStream(context, uri, MimeTypes.JPEG, null)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = Helper.safeRead(input) val metadata = Helper.safeRead(input)
return metadata.getDirectoriesOfType(MpEntryDirectory::class.java).map { it.entry } return metadata.getDirectoriesOfType(MpEntryDirectory::class.java).map { it.entry }
} }
@ -129,10 +181,12 @@ object MultiPage {
return null return null
} }
fun getJpegMpfPages(context: Context, uri: Uri): ArrayList<FieldMap> { fun getJpegMpfPages(context: Context, uri: Uri, sizeBytes: Long): ArrayList<FieldMap> {
val primaryRotation = getJpegMpfPrimaryRotation(context, uri, sizeBytes)
val pages = ArrayList<FieldMap>() val pages = ArrayList<FieldMap>()
val baseOffset = getJpegMpfBaseOffset(context, uri) val baseOffset = getJpegMpfBaseOffset(context, uri, sizeBytes)
val mpEntries = getJpegMpfEntries(context, uri) val mpEntries = getJpegMpfEntries(context, uri, sizeBytes)
if (mpEntries != null && baseOffset != null) { if (mpEntries != null && baseOffset != null) {
for ((pageIndex, mpEntry) in mpEntries.withIndex()) { for ((pageIndex, mpEntry) in mpEntries.withIndex()) {
mpEntry.mimeType?.let { embedMimeType -> mpEntry.mimeType?.let { embedMimeType ->
@ -140,8 +194,7 @@ object MultiPage {
KEY_PAGE to pageIndex, KEY_PAGE to pageIndex,
KEY_MIME_TYPE to embedMimeType, KEY_MIME_TYPE to embedMimeType,
KEY_IS_DEFAULT to (pageIndex == 0), KEY_IS_DEFAULT to (pageIndex == 0),
// TODO TLAD [MPF] page[KEY_ROTATION_DEGREES] = same as primary KEY_ROTATION_DEGREES to primaryRotation,
KEY_ROTATION_DEGREES to 0,
) )
var dataOffset = mpEntry.dataOffset var dataOffset = mpEntry.dataOffset
@ -167,12 +220,12 @@ object MultiPage {
} }
fun getJpegMpfBitmap(context: Context, uri: Uri, pageIndex: Int): Bitmap? { fun getJpegMpfBitmap(context: Context, uri: Uri, pageIndex: Int): Bitmap? {
val mpEntries = getJpegMpfEntries(context, uri) val mpEntries = getJpegMpfEntries(context, uri, null)
if (mpEntries != null && pageIndex < mpEntries.size) { if (mpEntries != null && pageIndex < mpEntries.size) {
val mpEntry = mpEntries[pageIndex] val mpEntry = mpEntries[pageIndex]
var dataOffset = mpEntry.dataOffset var dataOffset = mpEntry.dataOffset
if (dataOffset > 0) { if (dataOffset > 0) {
val baseOffset = getJpegMpfBaseOffset(context, uri) val baseOffset = getJpegMpfBaseOffset(context, uri, null)
if (baseOffset != null) { if (baseOffset != null) {
dataOffset += baseOffset dataOffset += baseOffset
} }

View file

@ -1,6 +1,9 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVG
import java.io.BufferedInputStream
import java.io.InputStream
import kotlin.math.max
object SvgHelper { object SvgHelper {
fun SVG.normalizeSize() { fun SVG.normalizeSize() {
@ -11,3 +14,18 @@ object SvgHelper {
setDocumentHeight("100%") setDocumentHeight("100%")
} }
} }
// As of AndroidSVG v1.4, SVGParser.ENTITY_WATCH_BUFFER_SIZE is set at 4096.
// This constant is not configurable and used for the internal buffer mark read limit.
// Parsing will fail if the SVG header is larger than this value.
// So we define and apply a minimum read limit.
class SVGParserBufferedInputStream(input: InputStream) : BufferedInputStream(input) {
@Synchronized
override fun mark(readlimit: Int) {
super.mark(max(MINIMUM_READ_LIMIT, readlimit))
}
companion object {
private const val MINIMUM_READ_LIMIT = 1 shl 14 // 16kB
}
}

View file

@ -40,6 +40,7 @@ object XMP {
private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/" private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
// other namespaces // other namespaces
private const val APPLE_HDRGM_NS_URI = "http://ns.apple.com/HDRGainMap/1.0/"
private const val HDRGM_NS_URI = "http://ns.adobe.com/hdr-gain-map/1.0/" private const val HDRGM_NS_URI = "http://ns.adobe.com/hdr-gain-map/1.0/"
private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01" private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01"
@ -59,6 +60,7 @@ object XMP {
// HDR gain map // HDR gain map
private val HDRGM_VERSION_PROP_NAME = XMPPropName(HDRGM_NS_URI, "Version") private val HDRGM_VERSION_PROP_NAME = XMPPropName(HDRGM_NS_URI, "Version")
private val APPLE_HDRGM_VERSION_PROP_NAME = XMPPropName(APPLE_HDRGM_NS_URI, "HDRGainMapVersion")
// panorama // panorama
@ -137,6 +139,9 @@ object XMP {
// `Ultra HDR` // `Ultra HDR`
if (GoogleXMP.isUltraHdPhoto(this)) return true if (GoogleXMP.isUltraHdPhoto(this)) return true
// Apple HDR gain map
if (doesPropExist(APPLE_HDRGM_VERSION_PROP_NAME)) return true
return false return false
} catch (e: XMPException) { } catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) { if (e.errorCode != XMPError.BADSCHEMA) {

View file

@ -334,7 +334,7 @@ abstract class ImageProvider {
.load(model) .load(model)
.submit(targetWidthPx, targetHeightPx) .submit(targetWidthPx, targetHeightPx)
var bitmap = withContext(Dispatchers.IO) { target.get() } var bitmap = withContext(Dispatchers.IO) { target.get() }
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
} }
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId") bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")

View file

@ -0,0 +1,35 @@
package deckers.thibault.aves.utils
import java.io.InputStream
import java.math.BigInteger
import java.security.MessageDigest
object HashUtils {
fun getHash(input: InputStream, algorithmKey: String): String {
val algorithm = toMessageDigestAlgorithm(algorithmKey)
val digest = MessageDigest.getInstance(algorithm)
val buffer = ByteArray(1 shl 14)
var read: Int
while ((input.read(buffer).also { read = it }) > 0) {
digest.update(buffer, 0, read)
}
val md5sum = digest.digest()
val output = BigInteger(1, md5sum).toString(16)
return when (algorithm) {
"MD5" -> output.padStart(32, '0') // 128 bits = 32 hex digits
"SHA-1" -> output.padStart(40, '0') // 160 bits = 40 hex digits
"SHA-256" -> output.padStart(64, '0') // 256 bits = 64 hex digits
else -> throw IllegalArgumentException("unsupported hash algorithm: $algorithmKey")
}
}
private fun toMessageDigestAlgorithm(algorithmKey: String): String {
return when (algorithmKey) {
"md5" -> "MD5"
"sha1" -> "SHA-1"
"sha256" -> "SHA-256"
else -> throw IllegalArgumentException("unsupported hash algorithm: $algorithmKey")
}
}
}

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.utils
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import deckers.thibault.aves.decoder.MultiPageImage
object MimeTypes { object MimeTypes {
const val ANY = "*/*" const val ANY = "*/*"
@ -137,10 +138,14 @@ object MimeTypes {
// but we need to rotate the decoded bitmap for the other formats // but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide: // maybe related to ExifInterface version used by Glide:
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21 // https://github.com/bumptech/glide/blob/master/gradle.properties#L21
fun needRotationAfterGlide(mimeType: String) = when (mimeType) { fun needRotationAfterGlide(mimeType: String, pageId: Int?): Boolean {
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
true
} else when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true DNG, HEIC, HEIF, PNG, WEBP -> true
else -> false else -> false
} }
}
// Thumbnails obtained from the Media Store are automatically rotated // Thumbnails obtained from the Media Store are automatically rotated
// according to EXIF orientation when decoding images of known formats // according to EXIF orientation when decoding images of known formats

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -4,23 +4,35 @@
android:viewportWidth="100" android:viewportWidth="100"
android:viewportHeight="100"> android:viewportHeight="100">
<path <path
android:pathData="M3.925,16.034 L60.825,72.933a2.421,2.421 0.001,0 0,3.423 0l10.604,-10.603a6.789,6.789 90.001,0 0,0 -9.601L34.066,11.942A8.264,8.264 22.5,0 0,28.222 9.522H6.623A3.815,3.815 112.5,0 0,3.925 16.034Z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="m31.78,63.61 l16.47,16.47a2.91,2.91 0,0 1,0 4.12l-7.87,7.87a15.07,15.07 0,0 1,-21.31 0L11.2,84.2a2.91,2.91 0,0 1,0 -4.12l16.47,-16.47a2.91,2.91 0,0 1,4.12 0z"
android:strokeWidth="3.05097"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
<path <path
android:pathData="m36.36,65.907v28.743a2.557,2.557 22.5,0 0,4.364 1.808L53.817,83.364a6.172,6.172 90,0 0,0 -8.729L42.532,63.35a3.616,3.616 157.5,0 0,-6.172 2.557z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="M56.37,39.03L72.84,55.49A2.91,2.91 90,0 1,72.84 59.61L56.37,76.08A2.91,2.91 0,0 1,52.25 76.08L35.78,59.61A2.91,2.91 0,0 1,35.78 55.49L52.25,39.03A2.91,2.91 90,0 1,56.37 39.03z"
android:strokeWidth="3.05095"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
<path <path
android:pathData="M79.653,40.078V11.335A2.557,2.557 22.5,0 0,75.289 9.527L62.195,22.62a6.172,6.172 90,0 0,0 8.729l11.285,11.285a3.616,3.616 157.5,0 0,6.172 -2.557z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="m60.37,30.91a2.91,2.91 0,0 0,0 4.12l16.47,16.47a2.91,2.91 0,0 0,4.12 0l16.47,-16.47a2.91,2.91 0,0 0,0 -4.12l-8.17,-8.17a14.64,14.64 0,0 0,-20.7 0zM76.62,30.7a3.21,3.21 0,0 1,4.54 0,3.21 3.21,0 0,1 0,4.54 3.21,3.21 0,0 1,-4.54 0,3.21 3.21,0 0,1 0,-4.54z"
android:strokeWidth="3.05095"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
<path <path
android:pathData="M96.613,16.867 L89.085,9.339a1.917,1.917 157.5,0 0,-3.273 1.356v6.172a4.629,4.629 45,0 0,4.629 4.629h4.255a2.712,2.712 112.5,0 0,1.917 -4.629z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="m24.31,6.96 l24.04,24.04a2.91,2.91 0,0 1,0 4.12L31.88,51.59a2.91,2.91 0,0 1,-4.12 0L17.08,40.91a22.74,22.74 0,0 1,0 -32.16L18.87,6.96a3.84,3.84 0,0 1,5.43 0z"
android:strokeWidth="3.05097"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
</vector> </vector>

View file

@ -1,30 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="100dp"
android:height="108dp" android:height="100dp"
android:viewportWidth="108" android:viewportWidth="100"
android:viewportHeight="108"> android:viewportHeight="100">
<path
android:fillColor="#ef435a"
android:fillType="evenOdd"
android:pathData="m41.18,56.69 l8.74,8.74a1.54,1.54 0,0 1,0 2.18l-4.18,4.18a7.99,7.99 0,0 1,-11.3 0L30.26,67.61a1.54,1.54 0,0 1,0 -2.18l8.74,-8.74a1.54,1.54 0,0 1,2.18 0z"
android:strokeWidth="1.61863"
android:strokeColor="#000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:fillColor="#e0e0e0"
android:fillType="evenOdd"
android:pathData="M54.22,43.64L62.96,52.38A1.54,1.54 0,0 1,62.96 54.56L54.22,63.3A1.54,1.54 0,0 1,52.04 63.3L43.3,54.56A1.54,1.54 0,0 1,43.3 52.38L52.04,43.64A1.54,1.54 90,0 1,54.22 43.64z"
android:strokeWidth="1.61862"
android:strokeColor="#000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:fillColor="#ffc11f"
android:fillType="evenOdd"
android:pathData="m56.35,39.33a1.54,1.54 0,0 0,0 2.18l8.74,8.74a1.54,1.54 0,0 0,2.18 0l8.74,-8.74a1.54,1.54 0,0 0,0 -2.18l-4.34,-4.34a7.77,7.77 0,0 0,-10.98 0zM64.97,39.22a1.7,1.7 0,0 1,2.41 0,1.7 1.7,0 0,1 0,2.41 1.7,1.7 0,0 1,-2.41 0,1.7 1.7,0 0,1 0,-2.41z"
android:strokeWidth="1.61862"
android:strokeColor="#000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path <path
android:fillColor="@color/ic_launcher_flavour" android:fillColor="@color/ic_launcher_flavour"
android:pathData="M29.77,36.566 L60.448,67.245a1.306,1.306 0,0 0,1.846 0l5.717,-5.717a3.661,3.661 0,0 0,0 -5.177L46.02,34.36A4.456,4.456 0,0 0,42.869 33.055H31.225a2.057,2.057 0,0 0,-1.455 3.511z" android:fillType="evenOdd"
android:strokeWidth="1.345" android:pathData="m37.21,26.63 l12.76,12.76a1.54,1.54 0,0 1,0 2.18l-8.74,8.74a1.54,1.54 0,0 1,-2.18 0l-5.67,-5.67a12.06,12.06 0,0 1,0 -17.06l0.95,-0.95a2.04,2.04 0,0 1,2.88 0z"
android:strokeColor="#000000" android:strokeWidth="1.61863"
android:strokeLineJoin="round" />
<path
android:fillColor="#4caf50"
android:pathData="m47.258,63.456v15.497a1.379,1.379 0,0 0,2.352 0.974l7.059,-7.059a3.328,3.328 0,0 0,0 -4.706l-6.085,-6.086a1.95,1.95 0,0 0,-3.328 1.379z"
android:strokeWidth="1.345"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:fillColor="#ffc107"
android:pathData="M70.6,49.53V34.032a1.379,1.379 0,0 0,-2.353 -0.974l-7.058,7.059a3.328,3.328 0,0 0,0 4.707l6.085,6.085a1.95,1.95 0,0 0,3.328 -1.379z"
android:strokeWidth="1.345"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:fillColor="#ff5722"
android:pathData="m79.744,37.015 l-4.059,-4.059a1.035,1.035 0,0 0,-1.765 0.731v3.328a2.496,2.496 0,0 0,2.496 2.496h2.294a1.462,1.462 0,0 0,1.034 -2.496z"
android:strokeWidth="1.345"
android:strokeColor="#000000" android:strokeColor="#000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
</vector> </vector>

View file

@ -3,30 +3,36 @@
android:height="100dp" android:height="100dp"
android:viewportWidth="100" android:viewportWidth="100"
android:viewportHeight="100"> android:viewportHeight="100">
<group
android:scaleX=".44"
android:scaleY=".44"
android:translateX="28"
android:translateY="30">
<path <path
android:pathData="M3.925,16.034 L60.825,72.933a2.421,2.421 0.001,0 0,3.423 0l10.604,-10.603a6.789,6.789 90.001,0 0,0 -9.601L34.066,11.942A8.264,8.264 22.5,0 0,28.222 9.522H6.623A3.815,3.815 112.5,0 0,3.925 16.034Z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="m41.18,56.69 l8.74,8.74a1.54,1.54 0,0 1,0 2.18l-4.18,4.18a7.99,7.99 0,0 1,-11.3 0L30.26,67.61a1.54,1.54 0,0 1,0 -2.18l8.74,-8.74a1.54,1.54 0,0 1,2.18 0z"
android:strokeWidth="1.61863"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
<path <path
android:pathData="m36.36,65.907v28.743a2.557,2.557 22.5,0 0,4.364 1.808L53.817,83.364a6.172,6.172 90,0 0,0 -8.729L42.532,63.35a3.616,3.616 157.5,0 0,-6.172 2.557z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="M54.22,43.64L62.96,52.38A1.54,1.54 0,0 1,62.96 54.56L54.22,63.3A1.54,1.54 0,0 1,52.04 63.3L43.3,54.56A1.54,1.54 0,0 1,43.3 52.38L52.04,43.64A1.54,1.54 90,0 1,54.22 43.64z"
android:strokeWidth="1.61862"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
<path <path
android:pathData="M79.653,40.078V11.335A2.557,2.557 22.5,0 0,75.289 9.527L62.195,22.62a6.172,6.172 90,0 0,0 8.729l11.285,11.285a3.616,3.616 157.5,0 0,6.172 -2.557z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="m56.35,39.33a1.54,1.54 0,0 0,0 2.18l8.74,8.74a1.54,1.54 0,0 0,2.18 0l8.74,-8.74a1.54,1.54 0,0 0,0 -2.18l-4.34,-4.34a7.77,7.77 0,0 0,-10.98 0zM64.97,39.22a1.7,1.7 0,0 1,2.41 0,1.7 1.7,0 0,1 0,2.41 1.7,1.7 0,0 1,-2.41 0,1.7 1.7,0 0,1 0,-2.41z"
android:strokeWidth="1.61862"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
<path <path
android:pathData="M96.613,16.867 L89.085,9.339a1.917,1.917 157.5,0 0,-3.273 1.356v6.172a4.629,4.629 45,0 0,4.629 4.629h4.255a2.712,2.712 112.5,0 0,1.917 -4.629z" android:fillColor="#000000"
android:strokeWidth="5" android:fillType="evenOdd"
android:strokeColor="#000000" android:pathData="m37.21,26.63 l12.76,12.76a1.54,1.54 0,0 1,0 2.18l-8.74,8.74a1.54,1.54 0,0 1,-2.18 0l-5.67,-5.67a12.06,12.06 0,0 1,0 -17.06l0.95,-0.95a2.04,2.04 0,0 1,2.88 0z"
android:strokeWidth="1.61863"
android:strokeColor="#00000000"
android:strokeLineCap="round"
android:strokeLineJoin="round" /> android:strokeLineJoin="round" />
</group>
</vector> </vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -3,5 +3,5 @@
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_shortcut_background">#FFFFFF</color> <color name="ic_shortcut_background">#FFFFFF</color>
<color name="ic_shortcut_foreground">#455A64</color> <color name="ic_shortcut_foreground">#455A64</color>
<color name="ic_launcher_flavour">#3f51b5</color> <color name="ic_launcher_flavour">#1cc8eb</color>
</resources> </resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_flavour">#D32F2F</color> <color name="ic_launcher_flavour">#007A78</color>
</resources> </resources>

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M3.925 16.034l56.9 56.9a2.42 2.42.001 003.423 0L74.852 62.33a6.79 6.79 90.001 000-9.601L34.067 11.942a8.264 8.264 22.5 00-5.844-2.42h-21.6a3.815 3.815 112.5 00-2.697 6.512z" fill="#3f51b5" stroke="#000" stroke-width="2.346" stroke-linejoin="round"/><path d="M36.36 65.907V94.65a2.557 2.557 22.5 004.364 1.808l13.093-13.094a6.172 6.172 90 000-8.728L42.532 63.35a3.616 3.616 157.5 00-6.172 2.557z" fill="#4caf50" stroke="#000" stroke-width="2.346" stroke-linejoin="round"/><path d="M79.653 40.078V11.335a2.557 2.557 22.5 00-4.364-1.808L62.195 22.62a6.172 6.172 90 000 8.729l11.286 11.285a3.616 3.616 157.5 006.172-2.556z" fill="#ffc107" stroke="#000" stroke-width="2.346" stroke-linejoin="round"/><path d="M96.613 16.867l-7.528-7.528a1.917 1.917 157.5 00-3.273 1.355v6.173a4.63 4.63 45 004.629 4.629h4.255a2.712 2.712 112.5 001.917-4.63z" fill="#ff5722" stroke="#000" stroke-width="2.346" stroke-linejoin="round"/></svg> <svg width="100" height="100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.04 62.547 16.498 16.497a2.916 2.916 0 0 1 0 4.125l-7.887 7.887a15.094 15.094 0 0 1-21.345 0l-7.887-7.887a2.916 2.916 0 0 1 0-4.124l16.497-16.498a2.916 2.916 0 0 1 4.125 0z" style="fill:#ef435a;fill-opacity:1;fill-rule:evenodd;stroke:#000;stroke-width:3.0565;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"/><path style="fill:#e0e0e0;fill-rule:evenodd;stroke:#000;stroke-width:3.05648;stroke-linecap:round;stroke-linejoin:round" d="m53.669 37.917 16.498 16.498a2.91 2.91 0 0 1 0 4.123L53.669 75.036a2.91 2.91 0 0 1-4.124 0L33.047 58.538a2.91 2.91 0 0 1 0-4.123l16.498-16.498a2.91 2.91 0 0 1 4.124 0z"/><path style="fill:#ffc11f;fill-opacity:1;fill-rule:evenodd;stroke:#000;stroke-width:3.05648;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" d="M57.678 29.784a2.916 2.916 0 0 0 0 4.124l16.498 16.497a2.916 2.916 0 0 0 4.124 0l16.497-16.497a2.916 2.916 0 0 0 0-4.124l-8.188-8.189a14.667 14.667 0 0 0-20.742 0zm16.284-.213a3.219 3.219 0 0 1 4.55 0 3.219 3.219 0 0 1 0 4.55 3.219 3.219 0 0 1-4.55 0 3.219 3.219 0 0 1 0-4.551z"/><path d="M21.548 5.792 45.636 29.88a2.916 2.916 0 0 1 0 4.124L29.138 50.501a2.916 2.916 0 0 1-4.124 0l-10.7-10.7a22.78 22.78 0 0 1 0-32.215l1.792-1.794a3.848 3.848 0 0 1 5.442 0z" style="fill:#1cc8eb;fill-opacity:1;fill-rule:evenodd;stroke:#000;stroke-width:3.0565;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"/></svg>

Before

Width:  |  Height:  |  Size: 989 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/ca/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/cs/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
fastlane/metadata/android/de/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,3 @@
In v1.11.0:
- watch videos with SRT subtitle files
Full changelog available on GitHub

View file

@ -0,0 +1,3 @@
In v1.11.0:
- watch videos with SRT subtitle files
Full changelog available on GitHub

BIN
fastlane/metadata/android/en-US/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
fastlane/metadata/android/es-MX/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
fastlane/metadata/android/eu/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/fr/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/ko/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/pl/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/pt-BR/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/ro/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/ru/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
fastlane/metadata/android/uk/images/featureGraphic.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -8,5 +8,3 @@
preferred-supported-locales: preferred-supported-locales:
- en - en
untranslated-messages-file: untranslated.json

View file

@ -53,13 +53,20 @@ extension ExtraMetadataFieldConvert on MetadataField {
return MetadataType.mp4; return MetadataType.mp4;
case MetadataField.xmpXmpCreateDate: case MetadataField.xmpXmpCreateDate:
return MetadataType.xmp; return MetadataType.xmp;
case MetadataField.hashMd5:
case MetadataField.hashSha1:
case MetadataField.hashSha256:
return MetadataType.file;
} }
} }
String? get toPlatform { String? get toPlatform {
if (type == MetadataType.exif) { switch (type) {
case MetadataType.exif:
return _toExifInterfaceTag(); return _toExifInterfaceTag();
} else { case MetadataType.file:
return name;
default:
switch (this) { switch (this) {
case MetadataField.mp4GpsCoordinates: case MetadataField.mp4GpsCoordinates:
return 'gpsCoordinates'; return 'gpsCoordinates';

View file

@ -23,6 +23,8 @@ extension ExtraMetadataTypeConvert on MetadataType {
return 'photoshop_irb'; return 'photoshop_irb';
case MetadataType.xmp: case MetadataType.xmp:
return 'xmp'; return 'xmp';
case MetadataType.file:
return 'file';
} }
} }
} }

View file

@ -1532,5 +1532,9 @@
"stopTooltip": "توقف", "stopTooltip": "توقف",
"@stopTooltip": {}, "@stopTooltip": {},
"videoRepeatActionSetStart": "تعيين بداية التشغيل", "videoRepeatActionSetStart": "تعيين بداية التشغيل",
"@videoRepeatActionSetStart": {} "@videoRepeatActionSetStart": {},
"settingsForceWesternArabicNumeralsTile": "فرض الأرقام العربية",
"@settingsForceWesternArabicNumeralsTile": {},
"renameProcessorHash": "تجزئة",
"@renameProcessorHash": {}
} }

View file

@ -1518,5 +1518,19 @@
"entryActionCast": "Promítat", "entryActionCast": "Promítat",
"@entryActionCast": {}, "@entryActionCast": {},
"castDialogTitle": "Zařízení pro promítání", "castDialogTitle": "Zařízení pro promítání",
"@castDialogTitle": {} "@castDialogTitle": {},
"setHomeCustomCollection": "Vlastní sbírka",
"@setHomeCustomCollection": {},
"settingsThumbnailShowHdrIcon": "Zobrazit ikonu HDR",
"@settingsThumbnailShowHdrIcon": {},
"settingsForceWesternArabicNumeralsTile": "Vynutit arabské číslice",
"@settingsForceWesternArabicNumeralsTile": {},
"stopTooltip": "Zastavit",
"@stopTooltip": {},
"videoRepeatActionSetStart": "Nastavit začátek",
"@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "Nastavit konec",
"@videoRepeatActionSetEnd": {},
"videoActionABRepeat": "Opakování A-B",
"@videoActionABRepeat": {}
} }

View file

@ -440,6 +440,7 @@
"renameEntrySetPagePreviewSectionTitle": "Preview", "renameEntrySetPagePreviewSectionTitle": "Preview",
"renameProcessorCounter": "Counter", "renameProcessorCounter": "Counter",
"renameProcessorHash": "Hash",
"renameProcessorName": "Name", "renameProcessorName": "Name",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Delete this album and the item in it?} other{Delete this album and the {count} items in it?}}", "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Delete this album and the item in it?} other{Delete this album and the {count} items in it?}}",
@ -933,6 +934,7 @@
"settingsCoordinateFormatDialogTitle": "Coordinate Format", "settingsCoordinateFormatDialogTitle": "Coordinate Format",
"settingsUnitSystemTile": "Units", "settingsUnitSystemTile": "Units",
"settingsUnitSystemDialogTitle": "Units", "settingsUnitSystemDialogTitle": "Units",
"settingsForceWesternArabicNumeralsTile": "Force Arabic numerals",
"settingsScreenSaverPageTitle": "Screen Saver", "settingsScreenSaverPageTitle": "Screen Saver",

View file

@ -717,9 +717,9 @@
"@settingsHomeDialogTitle": {}, "@settingsHomeDialogTitle": {},
"settingsShowBottomNavigationBar": "Mostrar barra de navegación inferior", "settingsShowBottomNavigationBar": "Mostrar barra de navegación inferior",
"@settingsShowBottomNavigationBar": {}, "@settingsShowBottomNavigationBar": {},
"settingsKeepScreenOnTile": "Mantener pantalla encendida", "settingsKeepScreenOnTile": "Mantener la pantalla encendida",
"@settingsKeepScreenOnTile": {}, "@settingsKeepScreenOnTile": {},
"settingsKeepScreenOnDialogTitle": "Mantener pantalla encendida", "settingsKeepScreenOnDialogTitle": "Mantener la pantalla encendida",
"@settingsKeepScreenOnDialogTitle": {}, "@settingsKeepScreenOnDialogTitle": {},
"settingsDoubleBackExit": "Presione «atrás» dos veces para salir", "settingsDoubleBackExit": "Presione «atrás» dos veces para salir",
"@settingsDoubleBackExit": {}, "@settingsDoubleBackExit": {},
@ -1374,5 +1374,9 @@
"videoActionABRepeat": "Repetir de A a B", "videoActionABRepeat": "Repetir de A a B",
"@videoActionABRepeat": {}, "@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Fijar el fin", "videoRepeatActionSetEnd": "Fijar el fin",
"@videoRepeatActionSetEnd": {} "@videoRepeatActionSetEnd": {},
"settingsForceWesternArabicNumeralsTile": "Forzar números arábigos",
"@settingsForceWesternArabicNumeralsTile": {},
"renameProcessorHash": "Hash",
"@renameProcessorHash": {}
} }

View file

@ -1524,5 +1524,17 @@
"collectionActionSetHome": "Ezarri hasiera gisa", "collectionActionSetHome": "Ezarri hasiera gisa",
"@collectionActionSetHome": {}, "@collectionActionSetHome": {},
"setHomeCustomCollection": "Bilduma pertsonalizatua", "setHomeCustomCollection": "Bilduma pertsonalizatua",
"@setHomeCustomCollection": {} "@setHomeCustomCollection": {},
"renameProcessorHash": "Hash-a",
"@renameProcessorHash": {},
"settingsForceWesternArabicNumeralsTile": "Behartu arabiar zifrak",
"@settingsForceWesternArabicNumeralsTile": {},
"videoRepeatActionSetStart": "Ezarri hasiera",
"@videoRepeatActionSetStart": {},
"stopTooltip": "Gelditu",
"@stopTooltip": {},
"videoActionABRepeat": "Atik Brako errepikapena",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Ezarri amaiera",
"@videoRepeatActionSetEnd": {}
} }

View file

@ -1374,5 +1374,9 @@
"videoActionABRepeat": "Lecture répétée A-B", "videoActionABRepeat": "Lecture répétée A-B",
"@videoActionABRepeat": {}, "@videoActionABRepeat": {},
"videoRepeatActionSetStart": "Définir le début", "videoRepeatActionSetStart": "Définir le début",
"@videoRepeatActionSetStart": {} "@videoRepeatActionSetStart": {},
"renameProcessorHash": "Hash",
"@renameProcessorHash": {},
"settingsForceWesternArabicNumeralsTile": "Toujours utiliser les chiffres arabes",
"@settingsForceWesternArabicNumeralsTile": {}
} }

View file

@ -1374,5 +1374,9 @@
"videoRepeatActionSetStart": "Tetapkan awal", "videoRepeatActionSetStart": "Tetapkan awal",
"@videoRepeatActionSetStart": {}, "@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "Tetapkan akhir", "videoRepeatActionSetEnd": "Tetapkan akhir",
"@videoRepeatActionSetEnd": {} "@videoRepeatActionSetEnd": {},
"settingsForceWesternArabicNumeralsTile": "Paksa angka Arab",
"@settingsForceWesternArabicNumeralsTile": {},
"renameProcessorHash": "Hash",
"@renameProcessorHash": {}
} }

View file

@ -1524,5 +1524,17 @@
"collectionActionSetHome": "Setja sem upphafsskjá", "collectionActionSetHome": "Setja sem upphafsskjá",
"@collectionActionSetHome": {}, "@collectionActionSetHome": {},
"setHomeCustomCollection": "Sérsniðið safn", "setHomeCustomCollection": "Sérsniðið safn",
"@setHomeCustomCollection": {} "@setHomeCustomCollection": {},
"renameProcessorHash": "Tætigildi",
"@renameProcessorHash": {},
"videoRepeatActionSetStart": "Stilla byrjun",
"@videoRepeatActionSetStart": {},
"stopTooltip": "Stöðva",
"@stopTooltip": {},
"settingsForceWesternArabicNumeralsTile": "Þvinga arabískar tölur",
"@settingsForceWesternArabicNumeralsTile": {},
"videoActionABRepeat": "Endurtekning A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Stilla endi",
"@videoRepeatActionSetEnd": {}
} }

View file

@ -1360,5 +1360,19 @@
"aboutDataUsageClearCache": "Pulisci Cache", "aboutDataUsageClearCache": "Pulisci Cache",
"@aboutDataUsageClearCache": {}, "@aboutDataUsageClearCache": {},
"castDialogTitle": "Dispositivi per Cast", "castDialogTitle": "Dispositivi per Cast",
"@castDialogTitle": {} "@castDialogTitle": {},
"stopTooltip": "Ferma",
"@stopTooltip": {},
"videoActionABRepeat": "Ripeti A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetStart": "Imposta inizio",
"@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "Imposta fine",
"@videoRepeatActionSetEnd": {},
"settingsThumbnailShowHdrIcon": "Mostra icona HDR",
"@settingsThumbnailShowHdrIcon": {},
"collectionActionSetHome": "Imposta come pagina iniziale",
"@collectionActionSetHome": {},
"setHomeCustomCollection": "Collezione personalizzata",
"@setHomeCustomCollection": {}
} }

View file

@ -1374,5 +1374,9 @@
"stopTooltip": "취소", "stopTooltip": "취소",
"@stopTooltip": {}, "@stopTooltip": {},
"videoActionABRepeat": "A-B 반복", "videoActionABRepeat": "A-B 반복",
"@videoActionABRepeat": {} "@videoActionABRepeat": {},
"renameProcessorHash": "해시",
"@renameProcessorHash": {},
"settingsForceWesternArabicNumeralsTile": "아라비아 숫자 항상 사용",
"@settingsForceWesternArabicNumeralsTile": {}
} }

View file

@ -1532,5 +1532,9 @@
"videoActionABRepeat": "Powtarzanie A-B", "videoActionABRepeat": "Powtarzanie A-B",
"@videoActionABRepeat": {}, "@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Ustaw koniec", "videoRepeatActionSetEnd": "Ustaw koniec",
"@videoRepeatActionSetEnd": {} "@videoRepeatActionSetEnd": {},
"settingsForceWesternArabicNumeralsTile": "Wymuszaj cyfry arabskie",
"@settingsForceWesternArabicNumeralsTile": {},
"renameProcessorHash": "Skrót",
"@renameProcessorHash": {}
} }

View file

@ -1374,5 +1374,9 @@
"videoActionABRepeat": "Повторить от А до Б", "videoActionABRepeat": "Повторить от А до Б",
"@videoActionABRepeat": {}, "@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Установить конец", "videoRepeatActionSetEnd": "Установить конец",
"@videoRepeatActionSetEnd": {} "@videoRepeatActionSetEnd": {},
"renameProcessorHash": "Хэш",
"@renameProcessorHash": {},
"settingsForceWesternArabicNumeralsTile": "Принудительные арабские цифры",
"@settingsForceWesternArabicNumeralsTile": {}
} }

View file

@ -1374,5 +1374,7 @@
"videoRepeatActionSetStart": "Başlangıç noktası seç", "videoRepeatActionSetStart": "Başlangıç noktası seç",
"@videoRepeatActionSetStart": {}, "@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "Bitiş noktası seç", "videoRepeatActionSetEnd": "Bitiş noktası seç",
"@videoRepeatActionSetEnd": {} "@videoRepeatActionSetEnd": {},
"settingsForceWesternArabicNumeralsTile": "Arap rakamlarını zorla",
"@settingsForceWesternArabicNumeralsTile": {}
} }

View file

@ -1524,5 +1524,17 @@
"settingsThumbnailShowHdrIcon": "Hiển thị biểu tượng HDR", "settingsThumbnailShowHdrIcon": "Hiển thị biểu tượng HDR",
"@settingsThumbnailShowHdrIcon": {}, "@settingsThumbnailShowHdrIcon": {},
"collectionActionSetHome": "Đặt làm nhà", "collectionActionSetHome": "Đặt làm nhà",
"@collectionActionSetHome": {} "@collectionActionSetHome": {},
"stopTooltip": "Dừng",
"@stopTooltip": {},
"videoActionABRepeat": "Lặp lại A-B",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "Đặt kết thúc",
"@videoRepeatActionSetEnd": {},
"videoRepeatActionSetStart": "Đặt bắt đầu",
"@videoRepeatActionSetStart": {},
"renameProcessorHash": "Băm",
"@renameProcessorHash": {},
"settingsForceWesternArabicNumeralsTile": "Buộc chữ số Ả Rập",
"@settingsForceWesternArabicNumeralsTile": {}
} }

View file

@ -1366,5 +1366,13 @@
"collectionActionSetHome": "设置为首页", "collectionActionSetHome": "设置为首页",
"@collectionActionSetHome": {}, "@collectionActionSetHome": {},
"setHomeCustomCollection": "自定义媒体集", "setHomeCustomCollection": "自定义媒体集",
"@setHomeCustomCollection": {} "@setHomeCustomCollection": {},
"videoRepeatActionSetStart": "设置起点",
"@videoRepeatActionSetStart": {},
"stopTooltip": "停止",
"@stopTooltip": {},
"videoActionABRepeat": "A-B 循环播放",
"@videoActionABRepeat": {},
"videoRepeatActionSetEnd": "设置终点",
"@videoRepeatActionSetEnd": {}
} }

View file

@ -1524,5 +1524,13 @@
"entryActionCast": "投放", "entryActionCast": "投放",
"@entryActionCast": {}, "@entryActionCast": {},
"castDialogTitle": "投放裝置", "castDialogTitle": "投放裝置",
"@castDialogTitle": {} "@castDialogTitle": {},
"stopTooltip": "停止",
"@stopTooltip": {},
"videoActionABRepeat": "A-B 重複播放",
"@videoActionABRepeat": {},
"videoRepeatActionSetStart": "設置起點",
"@videoRepeatActionSetStart": {},
"videoRepeatActionSetEnd": "設置終點",
"@videoRepeatActionSetEnd": {}
} }

View file

@ -82,6 +82,7 @@ class Contributors {
Contributor('しいたけ', 'Shiitake@users.noreply.hosted.weblate.org'), Contributor('しいたけ', 'Shiitake@users.noreply.hosted.weblate.org'),
Contributor('wanzh', 'wanzh66666@gmail.com'), Contributor('wanzh', 'wanzh66666@gmail.com'),
Contributor('ID J', 'tabby4442@gmail.com'), Contributor('ID J', 'tabby4442@gmail.com'),
Contributor('randint', 'lancameb@hotmail.com'),
// Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali // Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali
// Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese // Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese
// Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese // Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese

View file

@ -17,7 +17,8 @@ extension ExtraAvesEntryImages on AvesEntry {
} }
ThumbnailProviderKey _getThumbnailProviderKey(double extent) { ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
EntryCache.markThumbnailExtent(extent); final requestExtent = extent.roundToDouble();
EntryCache.markThumbnailExtent(requestExtent);
return ThumbnailProviderKey( return ThumbnailProviderKey(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
@ -25,7 +26,7 @@ extension ExtraAvesEntryImages on AvesEntry {
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: isFlipped, isFlipped: isFlipped,
dateModifiedSecs: dateModifiedSecs ?? -1, dateModifiedSecs: dateModifiedSecs ?? -1,
extent: extent, extent: requestExtent,
); );
} }

View file

@ -10,6 +10,7 @@ import 'package:aves/ref/unicode.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/text.dart'; import 'package:aves/theme/text.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/time_utils.dart';
extension ExtraAvesEntryProps on AvesEntry { extension ExtraAvesEntryProps on AvesEntry {
bool get isValid => !isMissingAtPath && sizeBytes != 0 && width > 0 && height > 0; bool get isValid => !isMissingAtPath && sizeBytes != 0 && width > 0 && height > 0;
@ -85,7 +86,7 @@ extension ExtraAvesEntryProps on AvesEntry {
int? get trashDaysLeft { int? get trashDaysLeft {
final dateMillis = trashDetails?.dateMillis; final dateMillis = trashDetails?.dateMillis;
if (dateMillis == null) return null; if (dateMillis == null) return null;
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).difference(DateTime.now()).inDays; return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).difference(DateTime.now()).inHumanDays;
} }
// storage // storage

View file

@ -39,18 +39,6 @@ class NamingPattern {
final processorKey = match.group(1); final processorKey = match.group(1);
final processorOptions = match.group(3); final processorOptions = match.group(3);
switch (processorKey) { switch (processorKey) {
case DateNamingProcessor.key:
if (processorOptions != null) {
processors.add(DateNamingProcessor(processorOptions.trim(), locale));
}
case TagsNamingProcessor.key:
processors.add(TagsNamingProcessor(processorOptions?.trim() ?? ''));
case MetadataFieldNamingProcessor.key:
if (processorOptions != null) {
processors.add(MetadataFieldNamingProcessor(processorOptions.trim()));
}
case NameNamingProcessor.key:
processors.add(const NameNamingProcessor());
case CounterNamingProcessor.key: case CounterNamingProcessor.key:
int? start, padding; int? start, padding;
_applyProcessorOptions(processorOptions, (key, value) { _applyProcessorOptions(processorOptions, (key, value) {
@ -65,6 +53,22 @@ class NamingPattern {
} }
}); });
processors.add(CounterNamingProcessor(start: start ?? defaultCounterStart, padding: padding ?? defaultCounterPadding)); processors.add(CounterNamingProcessor(start: start ?? defaultCounterStart, padding: padding ?? defaultCounterPadding));
case DateNamingProcessor.key:
if (processorOptions != null) {
processors.add(DateNamingProcessor(processorOptions.trim(), locale));
}
case HashNamingProcessor.key:
if (processorOptions != null) {
processors.add(HashNamingProcessor(processorOptions.trim()));
}
case MetadataFieldNamingProcessor.key:
if (processorOptions != null) {
processors.add(MetadataFieldNamingProcessor(processorOptions.trim()));
}
case NameNamingProcessor.key:
processors.add(const NameNamingProcessor());
case TagsNamingProcessor.key:
processors.add(TagsNamingProcessor(processorOptions?.trim() ?? ''));
default: default:
debugPrint('unsupported naming processor: ${match.group(0)}'); debugPrint('unsupported naming processor: ${match.group(0)}');
} }
@ -106,6 +110,8 @@ class NamingPattern {
switch (processorKey) { switch (processorKey) {
case DateNamingProcessor.key: case DateNamingProcessor.key:
return '<$processorKey, yyyyMMdd-HHmmss>'; return '<$processorKey, yyyyMMdd-HHmmss>';
case HashNamingProcessor.key:
return '<$processorKey, md5>';
case TagsNamingProcessor.key: case TagsNamingProcessor.key:
return '<$processorKey, ->'; return '<$processorKey, ->';
case CounterNamingProcessor.key: case CounterNamingProcessor.key:
@ -204,9 +210,7 @@ class MetadataFieldNamingProcessor extends NamingProcessor {
} }
@override @override
Set<MetadataField> getRequiredFields() { Set<MetadataField> getRequiredFields() => {field}.whereNotNull().toSet();
return {field}.whereNotNull().toSet();
}
@override @override
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) { String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) {
@ -247,3 +251,27 @@ class CounterNamingProcessor extends NamingProcessor {
@override @override
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) => '${index + start}'.padLeft(padding, '0'); String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) => '${index + start}'.padLeft(padding, '0');
} }
@immutable
class HashNamingProcessor extends NamingProcessor {
static const key = 'hash';
static const optionFunction = 'function';
late final MetadataField? function;
@override
List<Object?> get props => [function];
HashNamingProcessor(String function) {
final lowerField = 'hash${function.toLowerCase()}';
this.function = MetadataField.values.firstWhereOrNull((v) => v.name.toLowerCase() == lowerField);
}
@override
Set<MetadataField> getRequiredFields() => {function}.whereNotNull().toSet();
@override
String? process(AvesEntry entry, int index, Map<String, dynamic> fieldValues) {
return fieldValues[function?.toPlatform]?.toString();
}
}

View file

@ -79,6 +79,10 @@ mixin AppSettings on SettingsAccess {
return _appliedLocale!; return _appliedLocale!;
} }
bool get forceWesternArabicNumerals => getBool(SettingKeys.forceWesternArabicNumeralsKey) ?? false;
set forceWesternArabicNumerals(bool newValue) => set(SettingKeys.forceWesternArabicNumeralsKey, newValue);
int get catalogTimeZoneRawOffsetMillis => getInt(SettingKeys.catalogTimeZoneRawOffsetMillisKey) ?? 0; int get catalogTimeZoneRawOffsetMillis => getInt(SettingKeys.catalogTimeZoneRawOffsetMillisKey) ?? 0;
set catalogTimeZoneRawOffsetMillis(int newValue) => set(SettingKeys.catalogTimeZoneRawOffsetMillisKey, newValue); set catalogTimeZoneRawOffsetMillis(int newValue) => set(SettingKeys.catalogTimeZoneRawOffsetMillisKey, newValue);

View file

@ -375,6 +375,7 @@ class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings
} }
case SettingKeys.isInstalledAppAccessAllowedKey: case SettingKeys.isInstalledAppAccessAllowedKey:
case SettingKeys.isErrorReportingAllowedKey: case SettingKeys.isErrorReportingAllowedKey:
case SettingKeys.forceWesternArabicNumeralsKey:
case SettingKeys.enableDynamicColorKey: case SettingKeys.enableDynamicColorKey:
case SettingKeys.enableBlurEffectKey: case SettingKeys.enableBlurEffectKey:
case SettingKeys.enableBottomNavigationBarKey: case SettingKeys.enableBottomNavigationBarKey:

View file

@ -1,7 +1,10 @@
class XmpNamespaces { class XmpNamespaces {
static const acdsee = 'http://ns.acdsee.com/iptc/1.0/'; static const acdsee = 'http://ns.acdsee.com/iptc/1.0/';
static const adsmlat = 'http://adsml.org/xmlns/'; static const adsmlat = 'http://adsml.org/xmlns/';
static const appleDepthData = 'http://ns.apple.com/depthData/1.0/';
static const appleDesktop = 'http://ns.apple.com/namespace/1.0/'; static const appleDesktop = 'http://ns.apple.com/namespace/1.0/';
static const appleHDRGainMap = 'http://ns.apple.com/HDRGainMap/1.0/';
static const applePixelDataInfo = 'http://ns.apple.com/pixeldatainfo/1.0/';
static const avm = 'http://www.communicatingastronomy.org/avm/1.0/'; static const avm = 'http://www.communicatingastronomy.org/avm/1.0/';
static const camera = 'http://pix4d.com/camera/1.0/'; static const camera = 'http://pix4d.com/camera/1.0/';
static const cc = 'http://creativecommons.org/ns#'; static const cc = 'http://creativecommons.org/ns#';

View file

@ -38,6 +38,7 @@ class PlatformAppService implements AppService {
static final _stream = StreamsChannel('deckers.thibault/aves/activity_result_stream'); static final _stream = StreamsChannel('deckers.thibault/aves/activity_result_stream');
static final _knownAppDirs = { static final _knownAppDirs = {
'com.google.android.apps.photos': {'Google Photos'},
'com.kakao.talk': {'KakaoTalkDownload'}, 'com.kakao.talk': {'KakaoTalkDownload'},
'com.sony.playmemories.mobile': {'Imaging Edge Mobile'}, 'com.sony.playmemories.mobile': {'Imaging Edge Mobile'},
'nekox.messenger': {'NekoX'}, 'nekox.messenger': {'NekoX'},

View file

@ -60,6 +60,7 @@ class ADurations {
static const highlightJumpDelay = Duration(milliseconds: 400); static const highlightJumpDelay = Duration(milliseconds: 400);
static const highlightScrollInitDelay = Duration(milliseconds: 800); static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const motionPhotoAutoPlayDelay = Duration(milliseconds: 700); static const motionPhotoAutoPlayDelay = Duration(milliseconds: 700);
static const videoPauseAppInactiveDelay = Duration(milliseconds: 300);
static const videoOverlayHideDelay = Duration(milliseconds: 500); static const videoOverlayHideDelay = Duration(milliseconds: 500);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);

View file

@ -47,6 +47,7 @@ class AIcons {
static const mainStorage = Icons.smartphone_outlined; static const mainStorage = Icons.smartphone_outlined;
static const mimeType = Icons.code_outlined; static const mimeType = Icons.code_outlined;
static const opacity = Icons.opacity; static const opacity = Icons.opacity;
static const palette = Icons.palette_outlined;
static final privacy = MdiIcons.shieldAccountOutline; static final privacy = MdiIcons.shieldAccountOutline;
static const rating = Icons.star_border_outlined; static const rating = Icons.star_border_outlined;
static const ratingFull = Icons.star; static const ratingFull = Icons.star;

View file

@ -61,6 +61,13 @@ class Themes {
// COMPONENT THEMES // COMPONENT THEMES
checkboxTheme: _checkboxTheme(colors), checkboxTheme: _checkboxTheme(colors),
floatingActionButtonTheme: _floatingActionButtonTheme(colors), floatingActionButtonTheme: _floatingActionButtonTheme(colors),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: colors.background,
selectedIconTheme: IconThemeData(color: colors.primary),
unselectedIconTheme: IconThemeData(color: _unselectedWidgetColor(colors)),
selectedLabelTextStyle: TextStyle(color: colors.primary),
unselectedLabelTextStyle: TextStyle(color: _unselectedWidgetColor(colors)),
),
radioTheme: _radioTheme(colors), radioTheme: _radioTheme(colors),
sliderTheme: _sliderTheme(colors), sliderTheme: _sliderTheme(colors),
tooltipTheme: _tooltipTheme, tooltipTheme: _tooltipTheme,

View file

@ -26,6 +26,12 @@ extension ExtraDateTime on DateTime {
DateTime addDays(int days) => DateTime(year, month, day + days, hour, minute, second, millisecond, microsecond); DateTime addDays(int days) => DateTime(year, month, day + days, hour, minute, second, millisecond, microsecond);
} }
extension ExtraDuration on Duration {
// using `Duration.inDays` may yield surprising results when crossing DST boundaries
// because a day will not have 24 hours, so we use the following approximation instead
int get inHumanDays => (inMicroseconds / Duration.microsecondsPerDay).round();
}
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
// Overflowing timestamps that are supposed to be in milliseconds // Overflowing timestamps that are supposed to be in milliseconds

Some files were not shown because too many files have changed in this diff Show more