motion photo: fixed rotate/flip

This commit is contained in:
Thibault Deckers 2021-04-28 12:06:12 +09:00
parent bec145b0ae
commit 63f7aa1199
24 changed files with 421 additions and 328 deletions

View file

@ -90,7 +90,6 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return return
} }
try {
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes -> MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes val videoStartOffset = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input -> StorageUtils.openInputStream(context, uri)?.let { input ->
@ -99,11 +98,6 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
} }
return return
} }
} 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)
}
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null) 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 extension = extensionFor(mimeType)
val file = File.createTempFile("aves", extension, context.cacheDir).apply { val file = File.createTempFile("aves", extension, context.cacheDir).apply {
deleteOnExit() deleteOnExit()
outputStream().use { outputStream -> outputStream().use { output ->
embeddedByteStream.use { inputStream -> embeddedByteStream.use { input ->
inputStream.copyTo(outputStream) input.copyTo(output)
} }
} }
} }

View file

@ -184,7 +184,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] 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) result.error("changeOrientation-args", "failed because entry fields are missing", null)
return return
} }
@ -195,7 +196,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
return 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 onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message) override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
}) })

View file

@ -47,6 +47,7 @@ import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.getSafeString 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.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -371,7 +372,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
// identification of motion photo // 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 flags = flags or MASK_IS_MULTIPAGE
} }
} catch (e: XMPException) { } catch (e: XMPException) {

View file

@ -114,8 +114,8 @@ class RegionFetcher internal constructor(
val bitmap = target.get() val bitmap = target.get()
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply { val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit() deleteOnExit()
outputStream().use { outputStream -> outputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
} }
} }
return Uri.fromFile(tempFile) return Uri.fromFile(tempFile)

View file

@ -129,11 +129,11 @@ object Metadata {
if (previewFile == null) { if (previewFile == null) {
previewFile = File.createTempFile("aves", null, context.cacheDir).apply { previewFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit() deleteOnExit()
outputStream().use { outputStream -> outputStream().use { output ->
StorageUtils.openInputStream(context, uri)?.use { inputStream -> StorageUtils.openInputStream(context, uri)?.use { input ->
val b = ByteArray(previewSize) val b = ByteArray(previewSize)
inputStream.read(b, 0, previewSize) input.read(b, 0, previewSize)
outputStream.write(b) output.write(b)
} }
} }
} }

View file

@ -135,6 +135,7 @@ object MultiPage {
} }
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
@ -143,6 +144,11 @@ object MultiPage {
return offsetFromEnd 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 return null
} }

View file

@ -77,6 +77,19 @@ object XMP {
// extensions // 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 { fun XMPMeta.isPanorama(): Boolean {
// Google // Google
try { try {

View file

@ -17,20 +17,18 @@ import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.BmpWriter
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo 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.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException 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)) { if (!canEditExif(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return return
@ -245,16 +243,44 @@ abstract class ImageProvider {
return 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 // copy original file to a temporary file for editing
val editablePath = copyFileToTemp(originalDocumentFile, path) originalDocumentFile.openInputStream().use { imageInput ->
if (editablePath == null) { imageInput.copyTo(output)
callback.onFailure(Exception("failed to create a temporary file for path=$path")) }
}
}
} catch (e: Exception) {
callback.onFailure(e)
return return
} }
}
val newFields = HashMap<String, Any?>() val newFields = HashMap<String, Any?>()
try { 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)` // 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 // in that case we explicitly set it to `normal` first
// because ExifInterface fails to rotate an image with undefined orientation // because ExifInterface fails to rotate an image with undefined orientation
@ -270,8 +296,12 @@ abstract class ImageProvider {
} }
exif.saveAttributes() exif.saveAttributes()
if (videoBytes != null) {
// append motion photo video, if any
editableFile.appendBytes(videoBytes!!)
}
// copy the edited temporary file back to the original // 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["rotationDegrees"] = exif.rotationDegrees
newFields["isFlipped"] = exif.isFlipped newFields["isFlipped"] = exif.isFlipped
@ -300,7 +330,7 @@ abstract class ImageProvider {
// as of androidx.exifinterface:exifinterface:1.3.0 // as of androidx.exifinterface:exifinterface:1.3.0
private fun canEditExif(mimeType: String): Boolean { private fun canEditExif(mimeType: String): Boolean {
return when (mimeType) { return when (mimeType) {
"image/jpeg", "image/png", "image/webp" -> true MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP -> true
else -> false else -> false
} }
} }

View file

@ -11,13 +11,11 @@ import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.* import java.util.*
import java.util.regex.Pattern 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? { private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null

View file

@ -44,6 +44,12 @@ class EntryActions {
EntryAction.setAs, EntryAction.setAs,
EntryAction.openMap, EntryAction.openMap,
]; ];
static const pageActions = [
EntryAction.rotateCCW,
EntryAction.rotateCW,
EntryAction.flip,
];
} }
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {

View file

@ -4,7 +4,6 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
@ -43,7 +42,7 @@ class AvesEntry {
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions // 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({ AvesEntry({
this.uri, this.uri,
@ -97,37 +96,6 @@ class AvesEntry {
return copied; 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 // from DB or platform source entry
factory AvesEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return AvesEntry( return AvesEntry(

View file

@ -5,20 +5,21 @@ import 'package:flutter/foundation.dart';
class MultiPageInfo { class MultiPageInfo {
final AvesEntry mainEntry; 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({ MultiPageInfo({
@required this.mainEntry, @required this.mainEntry,
this.pages, List<SinglePageInfo> pages,
}) { }) : _pages = pages {
if (pages.isNotEmpty) { if (_pages.isNotEmpty) {
pages.sort(); _pages.sort();
// make sure there is a page marked as default // make sure there is a page marked as default
if (defaultPage == null) { if (defaultPage == null) {
final firstPage = pages.removeAt(0); final firstPage = _pages.removeAt(0);
pages.insert(0, firstPage.copyWith(isDefault: true)); _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 { 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) { if (videoPage != null && videoPage.uri == null) {
final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry); final fields = await embeddedDataService.extractMotionPhotoVideo(mainEntry);
final extractedUri = fields != null ? fields['uri'] as String : null; if (fields != null) {
if (extractedUri != null) { final pageIndex = _pages.indexOf(videoPage);
final pageIndex = pages.indexOf(videoPage); _pages.removeAt(pageIndex);
pages.removeAt(pageIndex); _pages.insert(
pages.insert(
pageIndex, pageIndex,
videoPage.copyWith( 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 @override
String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$pages}'; String toString() => '$runtimeType#${shortHash(this)}{mainEntry=$mainEntry, pages=$_pages}';
} }
class SinglePageInfo implements Comparable<SinglePageInfo> { class SinglePageInfo implements Comparable<SinglePageInfo> {
@ -78,6 +126,8 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
SinglePageInfo copyWith({ SinglePageInfo copyWith({
bool isDefault, bool isDefault,
String uri, String uri,
int rotationDegrees,
int durationMillis,
}) { }) {
return SinglePageInfo( return SinglePageInfo(
index: index, index: index,
@ -87,8 +137,8 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
mimeType: mimeType, mimeType: mimeType,
width: width, width: width,
height: height, height: height,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees ?? this.rotationDegrees,
durationMillis: durationMillis, durationMillis: durationMillis ?? this.durationMillis,
); );
} }

View file

@ -12,13 +12,14 @@ class MimeTypes {
static const tiff = 'image/tiff'; static const tiff = 'image/tiff';
static const webp = 'image/webp'; 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 psd = 'image/vnd.adobe.photoshop';
static const arw = 'image/x-sony-arw'; static const arw = 'image/x-sony-arw';
static const cr2 = 'image/x-canon-cr2'; static const cr2 = 'image/x-canon-cr2';
static const crw = 'image/x-canon-crw'; static const crw = 'image/x-canon-crw';
static const dcr = 'image/x-kodak-dcr'; static const dcr = 'image/x-kodak-dcr';
static const djvu = 'image/vnd.djvu';
static const dng = 'image/x-adobe-dng'; static const dng = 'image/x-adobe-dng';
static const erf = 'image/x-epson-erf'; static const erf = 'image/x-epson-erf';
static const k25 = 'image/x-kodak-k25'; static const k25 = 'image/x-kodak-k25';

View file

@ -103,6 +103,7 @@ class PlatformImageFileService implements ImageFileService {
'rotationDegrees': entry.rotationDegrees, 'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped, 'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs, 'dateModifiedSecs': entry.dateModifiedSecs,
'sizeBytes': entry.sizeBytes,
}; };
} }

View file

@ -175,10 +175,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
await multiPageInfo.extractMotionPhotoVideo(); await multiPageInfo.extractMotionPhotoVideo();
} }
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
for (final page in multiPageInfo.pages) { selection.addAll(multiPageInfo.exportEntries);
final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false);
selection.add(pageEntry);
}
} }
} else { } else {
selection.add(entry); selection.add(entry);

View file

@ -47,14 +47,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
if (entry.isMultiPage) { if (entry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry); final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) { if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>( child = StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { 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>( return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size, selector: (c, mq) => mq.size,
builder: (c, mqSize, child) { builder: (c, mqSize, child) {
return EntryPageView( return EntryPageView(
key: Key('imageview'), key: Key('imageview'),
mainEntry: entry, mainEntry: mainEntry,
page: page, pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize, 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 { class _SingleEntryScrollerState extends State<SingleEntryScroller> with AutomaticKeepAliveClientMixin {
AvesEntry get entry => widget.entry; AvesEntry get mainEntry => widget.entry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
Widget child; Widget child;
if (entry.isMultiPage) { if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry); final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) { if (multiPageController != null) {
child = FutureBuilder<MultiPageInfo>( child = StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { 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>( return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size, selector: (c, mq) => mq.size,
builder: (c, mqSize, child) { builder: (c, mqSize, child) {
return EntryPageView( return EntryPageView(
mainEntry: entry, mainEntry: mainEntry,
page: page, pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize, viewportSize: mqSize,
); );
}, },

View file

@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
@ -219,17 +220,30 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
Widget _buildTopOverlay() { Widget _buildTopOverlay() {
final child = ValueListenableBuilder<AvesEntry>( final child = ValueListenableBuilder<AvesEntry>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, entry, child) { builder: (context, mainEntry, child) {
if (entry == null) return SizedBox.shrink(); 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( return ViewerTopOverlay(
entry: entry, mainEntry: mainEntry,
scale: _topOverlayScale, scale: _topOverlayScale,
canToggleFavourite: hasCollection, canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, 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, viewStateNotifier: viewStateNotifier,
); );
}, },
@ -274,15 +288,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null; final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
final extraBottomOverlay = multiPageController != null final extraBottomOverlay = multiPageController != null
? FutureBuilder<MultiPageInfo>( ? StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
if (multiPageInfo == null) return SizedBox.shrink(); if (multiPageInfo == null) return SizedBox.shrink();
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { builder: (context, page, child) {
final pageEntry = entry.getPageEntry(multiPageInfo?.getByIndex(page)); final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return _buildExtraBottomOverlay(pageEntry) ?? SizedBox(); return _buildExtraBottomOverlay(pageEntry) ?? SizedBox();
}, },
); );
@ -538,13 +552,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry); final multiPageController = context.read<MultiPageConductor>().getOrCreateController(entry);
setState(() {}); setState(() {});
final multiPageInfo = await multiPageController.info; final multiPageInfo = multiPageController.info ?? await multiPageController.infoStream.first;
if (entry.isMotionPhoto) { if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo(); await multiPageInfo.extractMotionPhotoVideo();
} }
final pages = multiPageInfo.pages; final videoPageEntries = multiPageInfo.videoPageEntries;
final videoPageEntries = pages.where((page) => page.isVideo).map(entry.getPageEntry).toSet();
if (videoPageEntries.isNotEmpty) { if (videoPageEntries.isNotEmpty) {
// init video controllers for all pages that could need it // init video controllers for all pages that could need it
final videoConductor = context.read<VideoConductor>(); final videoConductor = context.read<VideoConductor>();
@ -557,8 +570,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final page = multiPageController.page; final page = multiPageController.page;
final pageInfo = multiPageInfo.getByIndex(page); final pageInfo = multiPageInfo.getByIndex(page);
if (pageInfo.isVideo) { if (pageInfo.isVideo) {
final pageEntry = entry.getPageEntry(pageInfo); final pageEntry = multiPageInfo.getPageEntryByIndex(page);
final pageVideoController = videoConductor.getController(pageEntry); final pageVideoController = videoConductor.getController(pageEntry);
assert(pageVideoController != null);
await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page); await _playVideo(pageVideoController, () => entry == _entryNotifier.value && page == multiPageController.page);
} }
} }

View file

@ -6,25 +6,34 @@ import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MultiPageController extends ChangeNotifier { class MultiPageController {
final AvesEntry entry; final AvesEntry entry;
Future<MultiPageInfo> info;
final ValueNotifier<int> pageNotifier = ValueNotifier(null); final ValueNotifier<int> pageNotifier = ValueNotifier(null);
MultiPageController(this.entry) { MultiPageInfo _info;
info = metadataService.getMultiPageInfo(entry).then((value) {
pageNotifier.value = value.defaultPage.index; final StreamController<MultiPageInfo> _infoStreamController = StreamController.broadcast();
return value;
}); Stream<MultiPageInfo> get infoStream => _infoStreamController.stream;
}
MultiPageInfo get info => _info;
int get page => pageNotifier.value; int get page => pageNotifier.value;
set page(int page) => pageNotifier.value = page; 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() { void dispose() {
pageNotifier.dispose(); pageNotifier.dispose();
super.dispose();
} }
@override
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry, page=$page, info=$info}';
} }

View file

@ -102,7 +102,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent( Widget _buildContent({MultiPageInfo multiPageInfo, int page}) => _BottomOverlayContent(
mainEntry: _lastEntry, mainEntry: _lastEntry,
page: multiPageInfo?.getByIndex(page), pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry,
details: _lastDetails, details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
availableWidth: availableWidth, availableWidth: availableWidth,
@ -111,10 +111,10 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
if (multiPageController == null) return _buildContent(); if (multiPageController == null) return _buildContent();
return FutureBuilder<MultiPageInfo>( return StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int>( return ValueListenableBuilder<int>(
valueListenable: multiPageController.pageNotifier, valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) { builder: (context, page, child) {
@ -138,8 +138,7 @@ const double _interRowPadding = 2.0;
const double _subRowMinWidth = 300.0; const double _subRowMinWidth = 300.0;
class _BottomOverlayContent extends AnimatedWidget { class _BottomOverlayContent extends AnimatedWidget {
final AvesEntry mainEntry, entry; final AvesEntry mainEntry, pageEntry;
final SinglePageInfo page;
final OverlayMetadata details; final OverlayMetadata details;
final String position; final String position;
final double availableWidth; final double availableWidth;
@ -150,13 +149,18 @@ class _BottomOverlayContent extends AnimatedWidget {
_BottomOverlayContent({ _BottomOverlayContent({
Key key, Key key,
this.mainEntry, this.mainEntry,
this.page, this.pageEntry,
this.details, this.details,
this.position, this.position,
this.availableWidth, this.availableWidth,
this.multiPageController, this.multiPageController,
}) : entry = mainEntry.getPageEntry(page), }) : super(
super(key: key, listenable: mainEntry.metadataChangeNotifier); key: key,
listenable: Listenable.merge([
mainEntry.metadataChangeNotifier,
pageEntry.metadataChangeNotifier,
]),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -184,7 +188,6 @@ class _BottomOverlayContent extends AnimatedWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MultiPageOverlay( MultiPageOverlay(
mainEntry: mainEntry,
controller: multiPageController, controller: multiPageController,
availableWidth: availableWidth, availableWidth: availableWidth,
), ),
@ -204,7 +207,7 @@ class _BottomOverlayContent extends AnimatedWidget {
final infoMaxWidth = availableWidth - infoPadding.horizontal; final infoMaxWidth = availableWidth - infoPadding.horizontal;
final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth;
final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; 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; final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
return Padding( return Padding(
@ -223,7 +226,7 @@ class _BottomOverlayContent extends AnimatedWidget {
Container( Container(
width: subRowWidth, width: subRowWidth,
child: _DateRow( child: _DateRow(
entry: entry, entry: pageEntry,
multiPageController: multiPageController, multiPageController: multiPageController,
)), )),
_buildDuoShootingRow(subRowWidth, hasShootingDetails), _buildDuoShootingRow(subRowWidth, hasShootingDetails),
@ -235,7 +238,7 @@ class _BottomOverlayContent extends AnimatedWidget {
padding: EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth, width: subRowWidth,
child: _DateRow( child: _DateRow(
entry: entry, entry: pageEntry,
multiPageController: multiPageController, multiPageController: multiPageController,
), ),
), ),
@ -251,10 +254,10 @@ class _BottomOverlayContent extends AnimatedWidget {
switchInCurve: Curves.easeInOutCubic, switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition, transitionBuilder: _soloTransition,
child: entry.hasGps child: pageEntry.hasGps
? Container( ? Container(
padding: EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry), child: _LocationRow(entry: pageEntry),
) )
: SizedBox.shrink(), : SizedBox.shrink(),
); );
@ -354,10 +357,10 @@ class _PositionTitleRow extends StatelessWidget {
if (multiPageController == null) return toText(); if (multiPageController == null) return toText();
return FutureBuilder<MultiPageInfo>( return StreamBuilder<MultiPageInfo>(
future: multiPageController.info, stream: multiPageController.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = multiPageController.info;
String pagePosition; String pagePosition;
if (multiPageInfo != null) { if (multiPageInfo != null) {
// page count may be 0 when we know an entry to have multiple pages // page count may be 0 when we know an entry to have multiple pages

View file

@ -1,6 +1,5 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
@ -10,17 +9,14 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MultiPageOverlay extends StatefulWidget { class MultiPageOverlay extends StatefulWidget {
final AvesEntry mainEntry;
final MultiPageController controller; final MultiPageController controller;
final double availableWidth; final double availableWidth;
MultiPageOverlay({ const MultiPageOverlay({
Key key, Key key,
@required this.mainEntry,
@required this.controller, @required this.controller,
@required this.availableWidth, @required this.availableWidth,
}) : assert(mainEntry.isMultiPage), }) : assert(controller != null),
assert(controller != null),
super(key: key); super(key: key);
@override @override
@ -31,12 +27,11 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
final _cancellableNotifier = ValueNotifier(true); final _cancellableNotifier = ValueNotifier(true);
ScrollController _scrollController; ScrollController _scrollController;
bool _syncScroll = true; bool _syncScroll = true;
int _initControllerPage;
static const double extent = 48; static const double extent = 48;
static const double separatorWidth = 2; static const double separatorWidth = 2;
AvesEntry get mainEntry => widget.mainEntry;
MultiPageController get controller => widget.controller; MultiPageController get controller => widget.controller;
double get availableWidth => widget.availableWidth; double get availableWidth => widget.availableWidth;
@ -64,10 +59,26 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
} }
void _registerWidget() { void _registerWidget() {
final page = controller.page ?? 0; _initControllerPage = controller.page;
final scrollOffset = pageToScrollOffset(page); final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0);
_scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange); _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() { void _unregisterWidget() {
@ -84,39 +95,29 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
return ThumbnailTheme( return ThumbnailTheme(
extent: extent, extent: extent,
showLocation: false, showLocation: false,
child: FutureBuilder<MultiPageInfo>( child: StreamBuilder<MultiPageInfo>(
future: controller.info, stream: controller.infoStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final multiPageInfo = snapshot.data; final multiPageInfo = controller.info;
if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); final pageCount = multiPageInfo?.pageCount ?? 0;
if (multiPageInfo.mainEntry != mainEntry) return SizedBox();
return SizedBox( return SizedBox(
height: extent, height: extent,
child: ListView.separated( child: ListView.separated(
key: ValueKey(mainEntry), key: ValueKey(multiPageInfo),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
controller: _scrollController, controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`, // default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0 // but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemBuilder: (context, index) { 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 page = index - 1;
final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page)); final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return Stack( return Stack(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () async { onTap: () => _goToPage(page),
_syncScroll = false;
controller.page = page;
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
curve: Curves.easeOutCubic,
);
_syncScroll = true;
},
child: DecoratedThumbnail( child: DecoratedThumbnail(
entry: pageEntry, entry: pageEntry,
extent: extent, extent: extent,
@ -140,7 +141,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
); );
}, },
separatorBuilder: (context, index) => separator, 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() { void _onScrollChange() {
if (_syncScroll) { if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset); controller.page = scrollOffsetToPage(_scrollController.offset);

View file

@ -1,50 +1,27 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart'; 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:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class Minimap extends StatelessWidget { class Minimap extends StatelessWidget {
final AvesEntry mainEntry; final AvesEntry entry;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final MultiPageController multiPageController;
final Size size; final Size size;
static const defaultSize = Size(96, 96); static const defaultSize = Size(96, 96);
const Minimap({ const Minimap({
@required this.mainEntry, @required this.entry,
@required this.viewStateNotifier, @required this.viewStateNotifier,
@required this.multiPageController,
this.size = defaultSize, this.size = defaultSize,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IgnorePointer( return IgnorePointer(
child: multiPageController != null child: ValueListenableBuilder<ViewState>(
? 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, valueListenable: viewStateNotifier,
builder: (context, viewState, child) { builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize; final viewportSize = viewState.viewportSize;
@ -62,7 +39,8 @@ class Minimap extends StatelessWidget {
size: size, size: size,
), ),
); );
}); }),
);
} }
} }

View file

@ -1,6 +1,7 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -17,7 +18,7 @@ import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ViewerTopOverlay extends StatelessWidget { class ViewerTopOverlay extends StatelessWidget {
final AvesEntry entry; final AvesEntry mainEntry;
final Animation<double> scale; final Animation<double> scale;
final EdgeInsets viewInsets, viewPadding; final EdgeInsets viewInsets, viewPadding;
final Function(EntryAction value) onActionSelected; final Function(EntryAction value) onActionSelected;
@ -28,7 +29,7 @@ class ViewerTopOverlay extends StatelessWidget {
const ViewerTopOverlay({ const ViewerTopOverlay({
Key key, Key key,
@required this.entry, @required this.mainEntry,
@required this.scale, @required this.scale,
@required this.canToggleFavourite, @required this.canToggleFavourite,
@required this.viewInsets, @required this.viewInsets,
@ -47,6 +48,67 @@ class ViewerTopOverlay extends StatelessWidget {
selector: (c, mq) => mq.size.width - mq.padding.horizontal, selector: (c, mq) => mq.size.width - mq.padding.horizontal,
builder: (c, mqWidth, child) { builder: (c, mqWidth, child) {
final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2; final availableCount = (mqWidth / (OverlayButton.getSize(context) + padding)).floor() - 2;
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);
},
),
),
);
}
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;
}
final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList(); final quickActions = settings.viewerQuickActions.where(_canDo).take(availableCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
@ -55,7 +117,8 @@ class ViewerTopOverlay extends StatelessWidget {
inAppActions: inAppActions, inAppActions: inAppActions,
externalAppActions: externalAppActions, externalAppActions: externalAppActions,
scale: scale, scale: scale,
entry: entry, mainEntry: mainEntry,
pageEntry: pageEntry,
onActionSelected: onActionSelected, onActionSelected: onActionSelected,
); );
@ -68,57 +131,20 @@ class ViewerTopOverlay extends StatelessWidget {
FadeTransition( FadeTransition(
opacity: scale, opacity: scale,
child: Minimap( child: Minimap(
mainEntry: entry, entry: pageEntry,
viewStateNotifier: viewStateNotifier, viewStateNotifier: viewStateNotifier,
multiPageController: entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null,
), ),
) )
], ],
) )
: buttonRow; : buttonRow;
},
),
),
);
}
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;
}
return false;
} }
} }
class _TopOverlayRow extends StatelessWidget { class _TopOverlayRow extends StatelessWidget {
final List<EntryAction> quickActions; final List<EntryAction> quickActions, inAppActions, externalAppActions;
final List<EntryAction> inAppActions;
final List<EntryAction> externalAppActions;
final Animation<double> scale; final Animation<double> scale;
final AvesEntry entry; final AvesEntry mainEntry, pageEntry;
final Function(EntryAction value) onActionSelected; final Function(EntryAction value) onActionSelected;
const _TopOverlayRow({ const _TopOverlayRow({
@ -127,7 +153,8 @@ class _TopOverlayRow extends StatelessWidget {
@required this.inAppActions, @required this.inAppActions,
@required this.externalAppActions, @required this.externalAppActions,
@required this.scale, @required this.scale,
@required this.entry, @required this.mainEntry,
@required this.pageEntry,
@required this.onActionSelected, @required this.onActionSelected,
}) : super(key: key); }) : super(key: key);
@ -149,7 +176,7 @@ class _TopOverlayRow extends StatelessWidget {
key: Key('entry-menu-button'), key: Key('entry-menu-button'),
itemBuilder: (context) => [ itemBuilder: (context) => [
...inAppActions.map((action) => _buildPopupMenuItem(context, action)), ...inAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
PopupMenuDivider(), PopupMenuDivider(),
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (kDebugMode) ...[ if (kDebugMode) ...[
@ -173,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
entry: entry, entry: mainEntry,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -217,7 +244,7 @@ class _TopOverlayRow extends StatelessWidget {
// in app actions // in app actions
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
entry: entry, entry: mainEntry,
isMenuItem: true, isMenuItem: true,
); );
break; break;

View file

@ -49,15 +49,16 @@ class EntryPrinter with FeedbackMixin {
if (entry.isMultiPage && !entry.isMotionPhoto) { if (entry.isMultiPage && !entry.isMotionPhoto) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry); final multiPageInfo = await metadataService.getMultiPageInfo(entry);
if (multiPageInfo.pageCount > 1) { final pageCount = multiPageInfo.pageCount;
if (pageCount > 1) {
final streamController = StreamController<AvesEntry>.broadcast(); final streamController = StreamController<AvesEntry>.broadcast();
showOpReport<AvesEntry>( showOpReport<AvesEntry>(
context: context, context: context,
opStream: streamController.stream, opStream: streamController.stream,
itemCount: multiPageInfo.pageCount, itemCount: pageCount,
); );
for (final page in multiPageInfo.pages) { for (var page = 0; page < pageCount; page++) {
final pageEntry = entry.getPageEntry(page); final pageEntry = multiPageInfo.getPageEntryByIndex(page);
_addPdfPage(await _buildPageImage(pageEntry)); _addPdfPage(await _buildPageImage(pageEntry));
streamController.sink.add(pageEntry); streamController.sink.add(pageEntry);
} }

View file

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart'; import 'package:aves/model/entry_images.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -30,22 +29,19 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class EntryPageView extends StatefulWidget { class EntryPageView extends StatefulWidget {
final AvesEntry mainEntry; final AvesEntry mainEntry, pageEntry;
final AvesEntry entry;
final SinglePageInfo page;
final Size viewportSize; final Size viewportSize;
final VoidCallback onDisposed; final VoidCallback onDisposed;
static const decorationCheckSize = 20.0; static const decorationCheckSize = 20.0;
EntryPageView({ const EntryPageView({
Key key, Key key,
this.mainEntry, this.mainEntry,
this.page, this.pageEntry,
this.viewportSize, this.viewportSize,
this.onDisposed, this.onDisposed,
}) : entry = mainEntry.getPageEntry(page) ?? mainEntry, }) : super(key: key);
super(key: key);
@override @override
_EntryPageViewState createState() => _EntryPageViewState(); _EntryPageViewState createState() => _EntryPageViewState();
@ -58,7 +54,7 @@ class _EntryPageViewState extends State<EntryPageView> {
AvesEntry get mainEntry => widget.mainEntry; AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.pageEntry;
Size get viewportSize => widget.viewportSize; Size get viewportSize => widget.viewportSize;
@ -76,7 +72,7 @@ class _EntryPageViewState extends State<EntryPageView> {
void didUpdateWidget(covariant EntryPageView oldWidget) { void didUpdateWidget(covariant EntryPageView oldWidget) {
super.didUpdateWidget(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, // 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 // in effect locking the zoom & position when browsing entry pages of the same size
_unregisterWidget(); _unregisterWidget();