motion photo: fixed rotate/flip
This commit is contained in:
parent
bec145b0ae
commit
63f7aa1199
24 changed files with 421 additions and 328 deletions
|
@ -90,19 +90,13 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
try {
|
||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(videoStartOffset)
|
||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input)
|
||||
}
|
||||
return
|
||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(videoStartOffset)
|
||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to extract video from motion photo", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to extract video from motion photo", e)
|
||||
return
|
||||
}
|
||||
|
||||
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null)
|
||||
|
@ -198,9 +192,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
val extension = extensionFor(mimeType)
|
||||
val file = File.createTempFile("aves", extension, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { outputStream ->
|
||||
embeddedByteStream.use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
outputStream().use { output ->
|
||||
embeddedByteStream.use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -184,7 +184,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
val sizeBytes = (entryMap["sizeBytes"] as Number?)?.toLong()
|
||||
if (uri == null || path == null || mimeType == null || sizeBytes == null) {
|
||||
result.error("changeOrientation-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
@ -195,7 +196,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
|
||||
provider.changeOrientation(activity, path, uri, mimeType, sizeBytes, op, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
|
||||
})
|
||||
|
|
|
@ -47,6 +47,7 @@ import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
|||
import deckers.thibault.aves.metadata.XMP.getSafeInt
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeString
|
||||
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
|
||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -371,7 +372,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
// identification of motion photo
|
||||
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||
if (xmpMeta.isMotionPhoto()) {
|
||||
flags = flags or MASK_IS_MULTIPAGE
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
|
|
|
@ -114,8 +114,8 @@ class RegionFetcher internal constructor(
|
|||
val bitmap = target.get()
|
||||
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { outputStream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
||||
outputStream().use { output ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
}
|
||||
}
|
||||
return Uri.fromFile(tempFile)
|
||||
|
|
|
@ -129,11 +129,11 @@ object Metadata {
|
|||
if (previewFile == null) {
|
||||
previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
outputStream().use { outputStream ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { inputStream ->
|
||||
outputStream().use { output ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val b = ByteArray(previewSize)
|
||||
inputStream.read(b, 0, previewSize)
|
||||
outputStream.write(b)
|
||||
input.read(b, 0, previewSize)
|
||||
output.write(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -135,13 +135,19 @@ object MultiPage {
|
|||
}
|
||||
|
||||
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
var offsetFromEnd: Long? = null
|
||||
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
return offsetFromEnd
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
var offsetFromEnd: Long? = null
|
||||
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
return offsetFromEnd
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -77,6 +77,19 @@ object XMP {
|
|||
|
||||
// extensions
|
||||
|
||||
fun XMPMeta.isMotionPhoto(): Boolean {
|
||||
try {
|
||||
return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check Google motion photo props from XMP", e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun XMPMeta.isPanorama(): Boolean {
|
||||
// Google
|
||||
try {
|
||||
|
|
|
@ -17,20 +17,18 @@ import com.bumptech.glide.request.RequestOptions
|
|||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BmpWriter
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
@ -233,7 +231,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
|
||||
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, sizeBytes: Long, op: ExifOrientationOp, callback: ImageOpCallback) {
|
||||
if (!canEditExif(mimeType)) {
|
||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||
return
|
||||
|
@ -245,16 +243,44 @@ abstract class ImageProvider {
|
|||
return
|
||||
}
|
||||
|
||||
// copy original file to a temporary file for editing
|
||||
val editablePath = copyFileToTemp(originalDocumentFile, path)
|
||||
if (editablePath == null) {
|
||||
callback.onFailure(Exception("failed to create a temporary file for path=$path"))
|
||||
return
|
||||
val videoSizeBytes = MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.toInt()
|
||||
var videoBytes: ByteArray? = null
|
||||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
outputStream().use { output ->
|
||||
if (videoSizeBytes != null) {
|
||||
// handle motion photo and embedded video separately
|
||||
val imageSizeBytes = (sizeBytes - videoSizeBytes).toInt()
|
||||
videoBytes = ByteArray(videoSizeBytes)
|
||||
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
val imageBytes = ByteArray(imageSizeBytes)
|
||||
input.read(imageBytes, 0, imageSizeBytes)
|
||||
input.read(videoBytes, 0, videoSizeBytes)
|
||||
|
||||
// copy only the image to a temporary file for editing
|
||||
// video will be appended after EXIF modification
|
||||
ByteArrayInputStream(imageBytes).use { imageInput ->
|
||||
imageInput.copyTo(output)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// copy original file to a temporary file for editing
|
||||
originalDocumentFile.openInputStream().use { imageInput ->
|
||||
imageInput.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val newFields = HashMap<String, Any?>()
|
||||
try {
|
||||
val exif = ExifInterface(editablePath)
|
||||
val exif = ExifInterface(editableFile)
|
||||
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
|
||||
// in that case we explicitly set it to `normal` first
|
||||
// because ExifInterface fails to rotate an image with undefined orientation
|
||||
|
@ -270,8 +296,12 @@ abstract class ImageProvider {
|
|||
}
|
||||
exif.saveAttributes()
|
||||
|
||||
if (videoBytes != null) {
|
||||
// append motion photo video, if any
|
||||
editableFile.appendBytes(videoBytes!!)
|
||||
}
|
||||
// copy the edited temporary file back to the original
|
||||
DocumentFileCompat.fromFile(File(editablePath)).copyTo(originalDocumentFile)
|
||||
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
||||
|
||||
newFields["rotationDegrees"] = exif.rotationDegrees
|
||||
newFields["isFlipped"] = exif.isFlipped
|
||||
|
@ -300,7 +330,7 @@ abstract class ImageProvider {
|
|||
// as of androidx.exifinterface:exifinterface:1.3.0
|
||||
private fun canEditExif(mimeType: String): Boolean {
|
||||
return when (mimeType) {
|
||||
"image/jpeg", "image/png", "image/webp" -> true
|
||||
MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,13 +11,11 @@ import android.provider.DocumentsContract
|
|||
import android.provider.MediaStore
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
@ -350,19 +348,6 @@ object StorageUtils {
|
|||
}
|
||||
}
|
||||
|
||||
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
|
||||
try {
|
||||
val temp = File.createTempFile("aves", ".$extension")
|
||||
documentFile.copyTo(DocumentFileCompat.fromFile(temp))
|
||||
temp.deleteOnExit()
|
||||
return temp.path
|
||||
} catch (e: IOException) {
|
||||
Log.e(LOG_TAG, "failed to copy file from path=$path")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
|
||||
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||
|
||||
|
|
|
@ -44,6 +44,12 @@ class EntryActions {
|
|||
EntryAction.setAs,
|
||||
EntryAction.openMap,
|
||||
];
|
||||
|
||||
static const pageActions = [
|
||||
EntryAction.rotateCCW,
|
||||
EntryAction.rotateCW,
|
||||
EntryAction.flip,
|
||||
];
|
||||
}
|
||||
|
||||
extension ExtraEntryAction on EntryAction {
|
||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/geo/countries.dart';
|
|||
import 'package:aves/model/entry_cache.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/geocoding_service.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
|
@ -43,7 +42,7 @@ class AvesEntry {
|
|||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||
|
||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd];
|
||||
static const List<String> undecodable = [MimeTypes.art, MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd];
|
||||
|
||||
AvesEntry({
|
||||
this.uri,
|
||||
|
@ -97,37 +96,6 @@ class AvesEntry {
|
|||
return copied;
|
||||
}
|
||||
|
||||
AvesEntry getPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
|
||||
if (pageInfo == null) return this;
|
||||
|
||||
// do not provide the page ID for the default page,
|
||||
// so that we can treat this page like the main entry
|
||||
// and retrieve cached images for it
|
||||
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
|
||||
|
||||
return AvesEntry(
|
||||
uri: pageInfo.uri ?? uri,
|
||||
path: path,
|
||||
contentId: contentId,
|
||||
pageId: pageId,
|
||||
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
|
||||
width: pageInfo.width ?? width,
|
||||
height: pageInfo.height ?? height,
|
||||
sourceRotationDegrees: pageInfo.rotationDegrees ?? sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: sourceTitle,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: pageInfo.durationMillis ?? durationMillis,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(
|
||||
mimeType: pageInfo.mimeType,
|
||||
isMultiPage: false,
|
||||
rotationDegrees: pageInfo.rotationDegrees,
|
||||
)
|
||||
..addressDetails = _addressDetails?.copyWith();
|
||||
}
|
||||
|
||||
// from DB or platform source entry
|
||||
factory AvesEntry.fromMap(Map map) {
|
||||
return AvesEntry(
|
||||
|
|
|
@ -5,20 +5,21 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
class MultiPageInfo {
|
||||
final AvesEntry mainEntry;
|
||||
final List<SinglePageInfo> pages;
|
||||
final List<SinglePageInfo> _pages;
|
||||
final Map<SinglePageInfo, AvesEntry> _pageEntries = {};
|
||||
|
||||
int get pageCount => pages.length;
|
||||
int get pageCount => _pages.length;
|
||||
|
||||
MultiPageInfo({
|
||||
@required this.mainEntry,
|
||||
this.pages,
|
||||
}) {
|
||||
if (pages.isNotEmpty) {
|
||||
pages.sort();
|
||||
List<SinglePageInfo> pages,
|
||||
}) : _pages = pages {
|
||||
if (_pages.isNotEmpty) {
|
||||
_pages.sort();
|
||||
// make sure there is a page marked as default
|
||||
if (defaultPage == null) {
|
||||
final firstPage = pages.removeAt(0);
|
||||
pages.insert(0, firstPage.copyWith(isDefault: true));
|
||||
final firstPage = _pages.removeAt(0);
|
||||
_pages.insert(0, firstPage.copyWith(isDefault: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -30,31 +31,78 @@ class MultiPageInfo {
|
|||
);
|
||||
}
|
||||
|
||||
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null);
|
||||
SinglePageInfo get defaultPage => _pages.firstWhere((page) => page.isDefault, orElse: () => null);
|
||||
|
||||
SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null);
|
||||
SinglePageInfo getById(int pageId) => _pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
|
||||
|
||||
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
|
||||
SinglePageInfo getByIndex(int pageIndex) => _pages.firstWhere((page) => page.index == pageIndex, orElse: () => null);
|
||||
|
||||
AvesEntry getPageEntryByIndex(int pageIndex) => _getPageEntry(getByIndex(pageIndex));
|
||||
|
||||
AvesEntry _getPageEntry(SinglePageInfo pageInfo) {
|
||||
if (pageInfo != null) {
|
||||
return _pageEntries.putIfAbsent(pageInfo, () => _createPageEntry(pageInfo));
|
||||
} else {
|
||||
return mainEntry;
|
||||
}
|
||||
}
|
||||
|
||||
Set<AvesEntry> get videoPageEntries => _pages.where((page) => page.isVideo).map(_getPageEntry).toSet();
|
||||
|
||||
List<AvesEntry> get exportEntries => _pages.map((pageInfo) => _createPageEntry(pageInfo, eraseDefaultPageId: false)).toList();
|
||||
|
||||
Future<void> extractMotionPhotoVideo() async {
|
||||
final videoPage = pages.firstWhere((page) => page.isVideo, orElse: () => null);
|
||||
final videoPage = _pages.firstWhere((page) => page.isVideo, orElse: () => null);
|
||||
if (videoPage != null && videoPage.uri == null) {
|
||||
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
|
||||
final extractedUri = fields != null ? fields['uri'] as String : null;
|
||||
if (extractedUri != null) {
|
||||
final pageIndex = pages.indexOf(videoPage);
|
||||
pages.removeAt(pageIndex);
|
||||
pages.insert(
|
||||
if (fields != null) {
|
||||
final pageIndex = _pages.indexOf(videoPage);
|
||||
_pages.removeAt(pageIndex);
|
||||
_pages.insert(
|
||||
pageIndex,
|
||||
videoPage.copyWith(
|
||||
uri: extractedUri,
|
||||
uri: fields['uri'] as String,
|
||||
// the initial fake page may contain inaccurate values for the following fields
|
||||
// so we override them with values from the extracted standalone video
|
||||
rotationDegrees: fields['sourceRotationDegrees'] as int,
|
||||
durationMillis: fields['durationMillis'] as int,
|
||||
));
|
||||
_pageEntries.remove(videoPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AvesEntry _createPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
|
||||
// do not provide the page ID for the default page,
|
||||
// so that we can treat this page like the main entry
|
||||
// and retrieve cached images for it
|
||||
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
|
||||
|
||||
return AvesEntry(
|
||||
uri: pageInfo.uri ?? mainEntry.uri,
|
||||
path: mainEntry.path,
|
||||
contentId: mainEntry.contentId,
|
||||
pageId: pageId,
|
||||
sourceMimeType: pageInfo.mimeType ?? mainEntry.sourceMimeType,
|
||||
width: pageInfo.width ?? mainEntry.width,
|
||||
height: pageInfo.height ?? mainEntry.height,
|
||||
sourceRotationDegrees: pageInfo.rotationDegrees ?? mainEntry.sourceRotationDegrees,
|
||||
sizeBytes: mainEntry.sizeBytes,
|
||||
sourceTitle: mainEntry.sourceTitle,
|
||||
dateModifiedSecs: mainEntry.dateModifiedSecs,
|
||||
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
|
||||
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
|
||||
)
|
||||
..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
|
||||
mimeType: pageInfo.mimeType,
|
||||
isMultiPage: false,
|
||||
rotationDegrees: pageInfo.rotationDegrees,
|
||||
)
|
||||
..addressDetails = mainEntry.addressDetails?.copyWith();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$pages}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$_pages}';
|
||||
}
|
||||
|
||||
class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||
|
@ -78,6 +126,8 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
|
|||
SinglePageInfo copyWith({
|
||||
bool isDefault,
|
||||
String uri,
|
||||
int rotationDegrees,
|
||||
int durationMillis,
|
||||
}) {
|
||||
return SinglePageInfo(
|
||||
index: index,
|
||||
|
@ -87,8 +137,8 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
|
|||
mimeType: mimeType,
|
||||
width: width,
|
||||
height: height,
|
||||
rotationDegrees: rotationDegrees,
|
||||
durationMillis: durationMillis,
|
||||
rotationDegrees: rotationDegrees ?? this.rotationDegrees,
|
||||
durationMillis: durationMillis ?? this.durationMillis,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -12,13 +12,14 @@ class MimeTypes {
|
|||
static const tiff = 'image/tiff';
|
||||
static const webp = 'image/webp';
|
||||
|
||||
static const art = 'image/x-jg';
|
||||
static const djvu = 'image/vnd.djvu';
|
||||
static const psd = 'image/vnd.adobe.photoshop';
|
||||
|
||||
static const arw = 'image/x-sony-arw';
|
||||
static const cr2 = 'image/x-canon-cr2';
|
||||
static const crw = 'image/x-canon-crw';
|
||||
static const dcr = 'image/x-kodak-dcr';
|
||||
static const djvu = 'image/vnd.djvu';
|
||||
static const dng = 'image/x-adobe-dng';
|
||||
static const erf = 'image/x-epson-erf';
|
||||
static const k25 = 'image/x-kodak-k25';
|
||||
|
|
|
@ -103,6 +103,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'rotationDegrees': entry.rotationDegrees,
|
||||
'isFlipped': entry.isFlipped,
|
||||
'dateModifiedSecs': entry.dateModifiedSecs,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -175,10 +175,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
await multiPageInfo.extractMotionPhotoVideo();
|
||||
}
|
||||
if (multiPageInfo.pageCount > 1) {
|
||||
for (final page in multiPageInfo.pages) {
|
||||
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
|
||||
selection.add(pageEntry);
|
||||
}
|
||||
selection.addAll(multiPageInfo.exportEntries);
|
||||
}
|
||||
} else {
|
||||
selection.add(entry);
|
||||
|
|
|
@ -47,14 +47,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
|||
if (entry.isMultiPage) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(entry);
|
||||
if (multiPageController != null) {
|
||||
child = FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
child = StreamBuilder<MultiPageInfo>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
final multiPageInfo = multiPageController.info;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildViewer(entry, page: multiPageInfo?.getByIndex(page));
|
||||
return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -72,16 +72,16 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildViewer(AvesEntry entry, {SinglePageInfo page}) {
|
||||
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry pageEntry}) {
|
||||
return Selector<MediaQueryData, Size>(
|
||||
selector: (c, mq) => mq.size,
|
||||
builder: (c, mqSize, child) {
|
||||
return EntryPageView(
|
||||
key: Key('imageview'),
|
||||
mainEntry: entry,
|
||||
page: page,
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
viewportSize: mqSize,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
onDisposed: () => widget.onViewDisposed?.call(mainEntry.uri),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -103,24 +103,24 @@ class SingleEntryScroller extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SingleEntryScrollerState extends State<SingleEntryScroller> with AutomaticKeepAliveClientMixin {
|
||||
AvesEntry get entry => widget.entry;
|
||||
AvesEntry get mainEntry => widget.entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
Widget child;
|
||||
if (entry.isMultiPage) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(entry);
|
||||
if (mainEntry.isMultiPage) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
child = FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
child = StreamBuilder<MultiPageInfo>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
final multiPageInfo = multiPageController.info;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildViewer(page: multiPageInfo?.getByIndex(page));
|
||||
return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -135,13 +135,13 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildViewer({SinglePageInfo page}) {
|
||||
Widget _buildViewer({AvesEntry pageEntry}) {
|
||||
return Selector<MediaQueryData, Size>(
|
||||
selector: (c, mq) => mq.size,
|
||||
builder: (c, mqSize, child) {
|
||||
return EntryPageView(
|
||||
mainEntry: entry,
|
||||
page: page,
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
viewportSize: mqSize,
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
|
@ -219,17 +220,30 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
Widget _buildTopOverlay() {
|
||||
final child = ValueListenableBuilder<AvesEntry>(
|
||||
valueListenable: _entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
if (entry == null) return SizedBox.shrink();
|
||||
builder: (context, mainEntry, child) {
|
||||
if (mainEntry == null) return SizedBox.shrink();
|
||||
|
||||
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == mainEntry.uri, orElse: () => null)?.item2;
|
||||
return ViewerTopOverlay(
|
||||
entry: entry,
|
||||
mainEntry: mainEntry,
|
||||
scale: _topOverlayScale,
|
||||
canToggleFavourite: hasCollection,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
|
||||
onActionSelected: (action) {
|
||||
var targetEntry = mainEntry;
|
||||
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
|
||||
if (pageEntry != null) {
|
||||
targetEntry = pageEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
_actionDelegate.onActionSelected(context, targetEntry, action);
|
||||
},
|
||||
viewStateNotifier: viewStateNotifier,
|
||||
);
|
||||
},
|
||||
|
@ -274,15 +288,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
|
||||
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
|
||||
final extraBottomOverlay = multiPageController != null
|
||||
? FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
? StreamBuilder<MultiPageInfo>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
final multiPageInfo = multiPageController.info;
|
||||
if (multiPageInfo == null) return SizedBox.shrink();
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
final pageEntry = entry.getPageEntry(multiPageInfo?.getByIndex(page));
|
||||
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||
return _buildExtraBottomOverlay(pageEntry) ?? SizedBox();
|
||||
},
|
||||
);
|
||||
|
@ -538,13 +552,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
|
||||
setState(() {});
|
||||
|
||||
final multiPageInfo = await multiPageController.info;
|
||||
final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
|
||||
if (entry.isMotionPhoto) {
|
||||
await multiPageInfo.extractMotionPhotoVideo();
|
||||
}
|
||||
|
||||
final pages = multiPageInfo.pages;
|
||||
final videoPageEntries = pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet();
|
||||
final videoPageEntries = multiPageInfo.videoPageEntries;
|
||||
if (videoPageEntries.isNotEmpty) {
|
||||
// init video controllers for all pages that could need it
|
||||
final videoConductor = context.read<VideoConductor>();
|
||||
|
@ -557,8 +570,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
final page = multiPageController.page;
|
||||
final pageInfo = multiPageInfo.getByIndex(page);
|
||||
if (pageInfo.isVideo) {
|
||||
final pageEntry = entry.getPageEntry(pageInfo);
|
||||
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||
final pageVideoController = videoConductor.getController(pageEntry);
|
||||
assert(pageVideoController != null);
|
||||
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,25 +6,34 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MultiPageController extends ChangeNotifier {
|
||||
class MultiPageController {
|
||||
final AvesEntry entry;
|
||||
Future<MultiPageInfo> info;
|
||||
final ValueNotifier<int> pageNotifier = ValueNotifier(null);
|
||||
|
||||
MultiPageController(this.entry) {
|
||||
info = metadataService.getMultiPageInfo(entry).then((value) {
|
||||
pageNotifier.value = value.defaultPage.index;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
MultiPageInfo _info;
|
||||
|
||||
final StreamController<MultiPageInfo> _infoStreamController = StreamController.broadcast();
|
||||
|
||||
Stream<MultiPageInfo> get infoStream => _infoStreamController.stream;
|
||||
|
||||
MultiPageInfo get info => _info;
|
||||
|
||||
int get page => pageNotifier.value;
|
||||
|
||||
set page(int page) => pageNotifier.value = page;
|
||||
|
||||
@override
|
||||
MultiPageController(this.entry) {
|
||||
metadataService.getMultiPageInfo(entry).then((value) {
|
||||
pageNotifier.value = value.defaultPage.index;
|
||||
_info = value;
|
||||
_infoStreamController.add(_info);
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
pageNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry, page=$page, info=$info}';
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
|||
|
||||
Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent(
|
||||
mainEntry: _lastEntry,
|
||||
page: multiPageInfo?.getByIndex(page),
|
||||
pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry,
|
||||
details: _lastDetails,
|
||||
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
|
||||
availableWidth: availableWidth,
|
||||
|
@ -111,10 +111,10 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
|||
|
||||
if (multiPageController == null) return _buildContent();
|
||||
|
||||
return FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
return StreamBuilder<MultiPageInfo>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
final multiPageInfo = multiPageController.info;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
|
@ -138,8 +138,7 @@ const double _interRowPadding = 2.0;
|
|||
const double _subRowMinWidth = 300.0;
|
||||
|
||||
class _BottomOverlayContent extends AnimatedWidget {
|
||||
final AvesEntry mainEntry, entry;
|
||||
final SinglePageInfo page;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final OverlayMetadata details;
|
||||
final String position;
|
||||
final double availableWidth;
|
||||
|
@ -150,13 +149,18 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
_BottomOverlayContent({
|
||||
Key key,
|
||||
this.mainEntry,
|
||||
this.page,
|
||||
this.pageEntry,
|
||||
this.details,
|
||||
this.position,
|
||||
this.availableWidth,
|
||||
this.multiPageController,
|
||||
}) : entry = mainEntry.getPageEntry(page),
|
||||
super(key: key, listenable: mainEntry.metadataChangeNotifier);
|
||||
}) : super(
|
||||
key: key,
|
||||
listenable: Listenable.merge([
|
||||
mainEntry.metadataChangeNotifier,
|
||||
pageEntry.metadataChangeNotifier,
|
||||
]),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -184,7 +188,6 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MultiPageOverlay(
|
||||
mainEntry: mainEntry,
|
||||
controller: multiPageController,
|
||||
availableWidth: availableWidth,
|
||||
),
|
||||
|
@ -204,7 +207,7 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
final infoMaxWidth = availableWidth - infoPadding.horizontal;
|
||||
final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth;
|
||||
final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth;
|
||||
final positionTitle = _PositionTitleRow(entry: entry, collectionPosition: position, multiPageController: multiPageController);
|
||||
final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController);
|
||||
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
|
||||
|
||||
return Padding(
|
||||
|
@ -223,7 +226,7 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
Container(
|
||||
width: subRowWidth,
|
||||
child: _DateRow(
|
||||
entry: entry,
|
||||
entry: pageEntry,
|
||||
multiPageController: multiPageController,
|
||||
)),
|
||||
_buildDuoShootingRow(subRowWidth, hasShootingDetails),
|
||||
|
@ -235,7 +238,7 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
width: subRowWidth,
|
||||
child: _DateRow(
|
||||
entry: entry,
|
||||
entry: pageEntry,
|
||||
multiPageController: multiPageController,
|
||||
),
|
||||
),
|
||||
|
@ -251,10 +254,10 @@ class _BottomOverlayContent extends AnimatedWidget {
|
|||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: _soloTransition,
|
||||
child: entry.hasGps
|
||||
child: pageEntry.hasGps
|
||||
? Container(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: _LocationRow(entry: entry),
|
||||
child: _LocationRow(entry: pageEntry),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
);
|
||||
|
@ -354,10 +357,10 @@ class _PositionTitleRow extends StatelessWidget {
|
|||
|
||||
if (multiPageController == null) return toText();
|
||||
|
||||
return FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
return StreamBuilder<MultiPageInfo>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
final multiPageInfo = multiPageController.info;
|
||||
String pagePosition;
|
||||
if (multiPageInfo != null) {
|
||||
// page count may be 0 when we know an entry to have multiple pages
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
|
@ -10,17 +9,14 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class MultiPageOverlay extends StatefulWidget {
|
||||
final AvesEntry mainEntry;
|
||||
final MultiPageController controller;
|
||||
final double availableWidth;
|
||||
|
||||
MultiPageOverlay({
|
||||
const MultiPageOverlay({
|
||||
Key key,
|
||||
@required this.mainEntry,
|
||||
@required this.controller,
|
||||
@required this.availableWidth,
|
||||
}) : assert(mainEntry.isMultiPage),
|
||||
assert(controller != null),
|
||||
}) : assert(controller != null),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -31,12 +27,11 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
final _cancellableNotifier = ValueNotifier(true);
|
||||
ScrollController _scrollController;
|
||||
bool _syncScroll = true;
|
||||
int _initControllerPage;
|
||||
|
||||
static const double extent = 48;
|
||||
static const double separatorWidth = 2;
|
||||
|
||||
AvesEntry get mainEntry => widget.mainEntry;
|
||||
|
||||
MultiPageController get controller => widget.controller;
|
||||
|
||||
double get availableWidth => widget.availableWidth;
|
||||
|
@ -64,10 +59,26 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
}
|
||||
|
||||
void _registerWidget() {
|
||||
final page = controller.page ?? 0;
|
||||
final scrollOffset = pageToScrollOffset(page);
|
||||
_initControllerPage = controller.page;
|
||||
final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0);
|
||||
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
|
||||
_scrollController.addListener(_onScrollChange);
|
||||
|
||||
if (_initControllerPage == null) {
|
||||
_correctDefaultPageScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// correct scroll offset to match default page
|
||||
// if default page was unknown when the scroll controller was created
|
||||
void _correctDefaultPageScroll() async {
|
||||
await controller.infoStream.first;
|
||||
if (_initControllerPage == null) {
|
||||
_initControllerPage = controller.page;
|
||||
if (_initControllerPage != 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _goToPage(_initControllerPage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _unregisterWidget() {
|
||||
|
@ -84,39 +95,29 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
return ThumbnailTheme(
|
||||
extent: extent,
|
||||
showLocation: false,
|
||||
child: FutureBuilder<MultiPageInfo>(
|
||||
future: controller.info,
|
||||
child: StreamBuilder<MultiPageInfo>(
|
||||
stream: controller.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox();
|
||||
if (multiPageInfo.mainEntry != mainEntry) return SizedBox();
|
||||
final multiPageInfo = controller.info;
|
||||
final pageCount = multiPageInfo?.pageCount ?? 0;
|
||||
return SizedBox(
|
||||
height: extent,
|
||||
child: ListView.separated(
|
||||
key: ValueKey(mainEntry),
|
||||
key: ValueKey(multiPageInfo),
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: _scrollController,
|
||||
// default padding in scroll direction matches `MediaQuery.viewPadding`,
|
||||
// but we already accommodate for it, so make sure horizontal padding is 0
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin;
|
||||
if (index == 0 || index == pageCount + 1) return horizontalMargin;
|
||||
final page = index - 1;
|
||||
final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page));
|
||||
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
_syncScroll = false;
|
||||
controller.page = page;
|
||||
await _scrollController.animateTo(
|
||||
pageToScrollOffset(page),
|
||||
duration: Durations.viewerOverlayPageScrollAnimation,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_syncScroll = true;
|
||||
},
|
||||
onTap: () => _goToPage(page),
|
||||
child: DecoratedThumbnail(
|
||||
entry: pageEntry,
|
||||
extent: extent,
|
||||
|
@ -140,7 +141,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => separator,
|
||||
itemCount: multiPageInfo.pageCount + 2,
|
||||
itemCount: pageCount + 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -148,6 +149,17 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _goToPage(int page) async {
|
||||
_syncScroll = false;
|
||||
controller.page = page;
|
||||
await _scrollController.animateTo(
|
||||
pageToScrollOffset(page),
|
||||
duration: Durations.viewerOverlayPageScrollAnimation,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_syncScroll = true;
|
||||
}
|
||||
|
||||
void _onScrollChange() {
|
||||
if (_syncScroll) {
|
||||
controller.page = scrollOffsetToPage(_scrollController.offset);
|
||||
|
|
|
@ -1,68 +1,46 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/controller.dart';
|
||||
import 'package:aves/widgets/viewer/visual/state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Minimap extends StatelessWidget {
|
||||
final AvesEntry mainEntry;
|
||||
final AvesEntry entry;
|
||||
final ValueNotifier<ViewState> viewStateNotifier;
|
||||
final MultiPageController multiPageController;
|
||||
final Size size;
|
||||
|
||||
static const defaultSize = Size(96, 96);
|
||||
|
||||
const Minimap({
|
||||
@required this.mainEntry,
|
||||
@required this.entry,
|
||||
@required this.viewStateNotifier,
|
||||
@required this.multiPageController,
|
||||
this.size = defaultSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: multiPageController != null
|
||||
? FutureBuilder<MultiPageInfo>(
|
||||
future: multiPageController.info,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = snapshot.data;
|
||||
if (multiPageInfo == null) return SizedBox.shrink();
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
final pageEntry = mainEntry.getPageEntry(multiPageInfo?.getByIndex(page));
|
||||
return _buildForEntrySize(pageEntry);
|
||||
},
|
||||
);
|
||||
})
|
||||
: _buildForEntrySize(mainEntry),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForEntrySize(AvesEntry entry) {
|
||||
return ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final viewportSize = viewState.viewportSize;
|
||||
if (viewportSize == null) return SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: entry.imageChangeNotifier,
|
||||
builder: (context, child) => CustomPaint(
|
||||
painter: MinimapPainter(
|
||||
viewportSize: viewportSize,
|
||||
entrySize: entry.displaySize,
|
||||
viewCenterOffset: viewState.position,
|
||||
viewScale: viewState.scale,
|
||||
minimapBorderColor: Colors.white30,
|
||||
child: ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final viewportSize = viewState.viewportSize;
|
||||
if (viewportSize == null) return SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: entry.imageChangeNotifier,
|
||||
builder: (context, child) => CustomPaint(
|
||||
painter: MinimapPainter(
|
||||
viewportSize: viewportSize,
|
||||
entrySize: entry.displaySize,
|
||||
viewCenterOffset: viewState.position,
|
||||
viewScale: viewState.scale,
|
||||
minimapBorderColor: Colors.white30,
|
||||
),
|
||||
size: size,
|
||||
),
|
||||
size: size,
|
||||
),
|
||||
);
|
||||
});
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
@ -17,7 +18,7 @@ import 'package:flutter/scheduler.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
class ViewerTopOverlay extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final AvesEntry mainEntry;
|
||||
final Animation<double> scale;
|
||||
final EdgeInsets viewInsets, viewPadding;
|
||||
final Function(EntryAction value) onActionSelected;
|
||||
|
@ -28,7 +29,7 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
|
||||
const ViewerTopOverlay({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
@required this.mainEntry,
|
||||
@required this.scale,
|
||||
@required this.canToggleFavourite,
|
||||
@required this.viewInsets,
|
||||
|
@ -47,78 +48,103 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
selector: (c, mq) => mq.size.width - mq.padding.horizontal,
|
||||
builder: (c, mqWidth, child) {
|
||||
final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
|
||||
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
|
||||
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
||||
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
||||
final buttonRow = _TopOverlayRow(
|
||||
quickActions: quickActions,
|
||||
inAppActions: inAppActions,
|
||||
externalAppActions: externalAppActions,
|
||||
scale: scale,
|
||||
entry: entry,
|
||||
onActionSelected: onActionSelected,
|
||||
);
|
||||
|
||||
return settings.showOverlayMinimap && viewStateNotifier != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buttonRow,
|
||||
SizedBox(height: 8),
|
||||
FadeTransition(
|
||||
opacity: scale,
|
||||
child: Minimap(
|
||||
mainEntry: entry,
|
||||
viewStateNotifier: viewStateNotifier,
|
||||
multiPageController: entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
: buttonRow;
|
||||
Widget child;
|
||||
if (mainEntry.isMultiPage) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
child = StreamBuilder<MultiPageInfo>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return child ??= _buildOverlay(availableCount, mainEntry);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canDo(EntryAction action) {
|
||||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
return canToggleFavourite;
|
||||
case EntryAction.delete:
|
||||
case EntryAction.rename:
|
||||
return entry.canEdit;
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
case EntryAction.flip:
|
||||
return entry.canRotateAndFlip;
|
||||
case EntryAction.export:
|
||||
case EntryAction.print:
|
||||
return !entry.isVideo;
|
||||
case EntryAction.openMap:
|
||||
return entry.hasGps;
|
||||
case EntryAction.viewSource:
|
||||
return entry.isSvg;
|
||||
case EntryAction.share:
|
||||
case EntryAction.info:
|
||||
case EntryAction.open:
|
||||
case EntryAction.edit:
|
||||
case EntryAction.setAs:
|
||||
return true;
|
||||
case EntryAction.debug:
|
||||
return kDebugMode;
|
||||
Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry pageEntry}) {
|
||||
pageEntry ??= mainEntry;
|
||||
|
||||
bool _canDo(EntryAction action) {
|
||||
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
|
||||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
return canToggleFavourite;
|
||||
case EntryAction.delete:
|
||||
case EntryAction.rename:
|
||||
return targetEntry.canEdit;
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
case EntryAction.flip:
|
||||
return targetEntry.canRotateAndFlip;
|
||||
case EntryAction.export:
|
||||
case EntryAction.print:
|
||||
return !targetEntry.isVideo;
|
||||
case EntryAction.openMap:
|
||||
return targetEntry.hasGps;
|
||||
case EntryAction.viewSource:
|
||||
return targetEntry.isSvg;
|
||||
case EntryAction.share:
|
||||
case EntryAction.info:
|
||||
case EntryAction.open:
|
||||
case EntryAction.edit:
|
||||
case EntryAction.setAs:
|
||||
return true;
|
||||
case EntryAction.debug:
|
||||
return kDebugMode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
|
||||
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
||||
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
||||
final buttonRow = _TopOverlayRow(
|
||||
quickActions: quickActions,
|
||||
inAppActions: inAppActions,
|
||||
externalAppActions: externalAppActions,
|
||||
scale: scale,
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry,
|
||||
onActionSelected: onActionSelected,
|
||||
);
|
||||
|
||||
return settings.showOverlayMinimap && viewStateNotifier != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buttonRow,
|
||||
SizedBox(height: 8),
|
||||
FadeTransition(
|
||||
opacity: scale,
|
||||
child: Minimap(
|
||||
entry: pageEntry,
|
||||
viewStateNotifier: viewStateNotifier,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
: buttonRow;
|
||||
}
|
||||
}
|
||||
|
||||
class _TopOverlayRow extends StatelessWidget {
|
||||
final List<EntryAction> quickActions;
|
||||
final List<EntryAction> inAppActions;
|
||||
final List<EntryAction> externalAppActions;
|
||||
final List<EntryAction> quickActions, inAppActions, externalAppActions;
|
||||
final Animation<double> scale;
|
||||
final AvesEntry entry;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final Function(EntryAction value) onActionSelected;
|
||||
|
||||
const _TopOverlayRow({
|
||||
|
@ -127,7 +153,8 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
@required this.inAppActions,
|
||||
@required this.externalAppActions,
|
||||
@required this.scale,
|
||||
@required this.entry,
|
||||
@required this.mainEntry,
|
||||
@required this.pageEntry,
|
||||
@required this.onActionSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -149,7 +176,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
key: Key('entry-menu-button'),
|
||||
itemBuilder: (context) => [
|
||||
...inAppActions.map((action) => _buildPopupMenuItem(context, action)),
|
||||
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
|
||||
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
|
||||
PopupMenuDivider(),
|
||||
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
|
||||
if (kDebugMode) ...[
|
||||
|
@ -173,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
child = _FavouriteToggler(
|
||||
entry: entry,
|
||||
entry: mainEntry,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
break;
|
||||
|
@ -217,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
// in app actions
|
||||
case EntryAction.toggleFavourite:
|
||||
child = _FavouriteToggler(
|
||||
entry: entry,
|
||||
entry: mainEntry,
|
||||
isMenuItem: true,
|
||||
);
|
||||
break;
|
||||
|
|
|
@ -49,15 +49,16 @@ class EntryPrinter with FeedbackMixin {
|
|||
|
||||
if (entry.isMultiPage && !entry.isMotionPhoto) {
|
||||
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
|
||||
if (multiPageInfo.pageCount > 1) {
|
||||
final pageCount = multiPageInfo.pageCount;
|
||||
if (pageCount > 1) {
|
||||
final streamController = StreamController<AvesEntry>.broadcast();
|
||||
showOpReport<AvesEntry>(
|
||||
context: context,
|
||||
opStream: streamController.stream,
|
||||
itemCount: multiPageInfo.pageCount,
|
||||
itemCount: pageCount,
|
||||
);
|
||||
for (final page in multiPageInfo.pages) {
|
||||
final pageEntry = entry.getPageEntry(page);
|
||||
for (var page = 0; page < pageCount; page++) {
|
||||
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||
_addPdfPage(await _buildPageImage(pageEntry));
|
||||
streamController.sink.add(pageEntry);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import 'dart:async';
|
|||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -30,22 +29,19 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntryPageView extends StatefulWidget {
|
||||
final AvesEntry mainEntry;
|
||||
final AvesEntry entry;
|
||||
final SinglePageInfo page;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final Size viewportSize;
|
||||
final VoidCallback onDisposed;
|
||||
|
||||
static const decorationCheckSize = 20.0;
|
||||
|
||||
EntryPageView({
|
||||
const EntryPageView({
|
||||
Key key,
|
||||
this.mainEntry,
|
||||
this.page,
|
||||
this.pageEntry,
|
||||
this.viewportSize,
|
||||
this.onDisposed,
|
||||
}) : entry = mainEntry.getPageEntry(page) ?? mainEntry,
|
||||
super(key: key);
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_EntryPageViewState createState() => _EntryPageViewState();
|
||||
|
@ -58,7 +54,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
|
||||
AvesEntry get mainEntry => widget.mainEntry;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
AvesEntry get entry => widget.pageEntry;
|
||||
|
||||
Size get viewportSize => widget.viewportSize;
|
||||
|
||||
|
@ -76,7 +72,7 @@ class _EntryPageViewState extends State<EntryPageView> {
|
|||
void didUpdateWidget(covariant EntryPageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.entry.displaySize != entry.displaySize) {
|
||||
if (oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) {
|
||||
// do not reset the magnifier view state unless page dimensions change,
|
||||
// in effect locking the zoom & position when browsing entry pages of the same size
|
||||
_unregisterWidget();
|
||||
|
|
Loading…
Reference in a new issue