viewer: handle media store content uris

This commit is contained in:
Thibault Deckers 2020-03-16 14:40:08 +09:00
parent 73d97f821b
commit b2f72d964f
20 changed files with 713 additions and 437 deletions

View file

@ -1,34 +1,46 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="deckers.thibault.aves"> xmlns:tools="http://schemas.android.com/tools"
package="deckers.thibault.aves"
android:installLocation="auto">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- TODO remove this permission once this issue is fixed: <!-- TODO remove this permission once this issue is fixed:
https://github.com/flutter/flutter/issues/42349 https://github.com/flutter/flutter/issues/42349
https://github.com/flutter/flutter/issues/42451 https://github.com/flutter/flutter/issues/42451
--> -->
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name="io.flutter.app.FlutterApplication" android:name="io.flutter.app.FlutterApplication"
android:label="Aves"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Aves"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<meta-data <meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame" android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="false" /> android:value="false" />
</activity> </activity>
<meta-data android:name="com.google.android.geo.API_KEY" <meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyDf-1dN6JivrQGKSmxAdxERLM2egOvzGWs" /> android:value="AIzaSyDf-1dN6JivrQGKSmxAdxERLM2egOvzGWs" />
</application> </application>
</manifest> </manifest>

View file

@ -3,6 +3,10 @@ package deckers.thibault.aves;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import java.util.HashMap;
import java.util.Map;
import deckers.thibault.aves.channelhandlers.AppAdapterHandler; import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
import deckers.thibault.aves.channelhandlers.ImageFileHandler; import deckers.thibault.aves.channelhandlers.ImageFileHandler;
@ -11,6 +15,7 @@ import deckers.thibault.aves.channelhandlers.MetadataHandler;
import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.Utils;
import io.flutter.app.FlutterActivity; import io.flutter.app.FlutterActivity;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
@ -18,11 +23,19 @@ import io.flutter.plugins.GeneratedPluginRegistrant;
import io.flutter.view.FlutterView; import io.flutter.view.FlutterView;
public class MainActivity extends FlutterActivity { public class MainActivity extends FlutterActivity {
private static final String LOG_TAG = Utils.createLogTag(MainActivity.class);
public static final String VIEWER_CHANNEL = "deckers.thibault/aves/viewer";
private Map<String, String> sharedEntryMap;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this); GeneratedPluginRegistrant.registerWith(this);
handleIntent(getIntent());
MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler(); MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
FlutterView messenger = getFlutterView(); FlutterView messenger = getFlutterView();
@ -30,6 +43,27 @@ public class MainActivity extends FlutterActivity {
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler)); new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler(
(call, result) -> {
if (call.method.contentEquals("getSharedEntry")) {
result.success(sharedEntryMap);
sharedEntryMap = null;
}
});
}
private void handleIntent(Intent intent) {
Log.i(LOG_TAG, "handleIntent intent=" + intent);
if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction())) {
Uri uri = intent.getData();
String mimeType = intent.getType();
if (uri != null && mimeType != null) {
sharedEntryMap = new HashMap<>();
sharedEntryMap.put("uri", uri.toString());
sharedEntryMap.put("mimeType", mimeType);
}
}
} }
@Override @Override

View file

@ -35,6 +35,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
mediaStoreStreamHandler.fetchAll(activity); mediaStoreStreamHandler.fetchAll(activity);
result.success(null); result.success(null);
break; break;
case "getImageEntry":
getImageEntry(call, result);
break;
case "getImageBytes": case "getImageBytes":
getImageBytes(call, result); getImageBytes(call, result);
break; break;
@ -74,6 +77,34 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
result.success(null); result.success(null);
} }
private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String uriString = call.argument("uri");
String mimeType = call.argument("mimeType");
if (uriString == null || mimeType == null) {
result.error("getImageEntry-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse(uriString);
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("getImageEntry-provider", "failed to find provider for uri=" + uriString, null);
return;
}
provider.fetchSingle(activity, uri, mimeType, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> entry) {
result.success(entry);
}
@Override
public void onFailure() {
result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, null);
}
});
}
private void delete(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { private void delete(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map entryMap = call.argument("entry"); Map entryMap = call.argument("entry");
if (entryMap == null) { if (entryMap == null) {

View file

@ -5,9 +5,7 @@ import android.util.Log;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider; import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;

View file

@ -33,6 +33,10 @@ import deckers.thibault.aves.utils.Utils;
public abstract class ImageProvider { public abstract class ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) {
callback.onFailure();
}
public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) {
callback.onFailure(); callback.onFailure();
} }

View file

@ -12,9 +12,11 @@ public class ImageProviderFactory {
if (scheme != null) { if (scheme != null) {
switch (scheme) { switch (scheme) {
case ContentResolver.SCHEME_CONTENT: // content:// case ContentResolver.SCHEME_CONTENT: // content://
String authority = uri.getAuthority(); // a URI's authority is [userinfo@]host[:port]
if (authority != null) { // but we only want the host when comparing to MediaStore's "authority"
switch (authority) { String host = uri.getHost();
if (host != null) {
switch (host) {
case MediaStore.AUTHORITY: case MediaStore.AUTHORITY:
return new MediaStoreImageProvider(); return new MediaStoreImageProvider();
// case Constants.DOWNLOADS_AUTHORITY: // case Constants.DOWNLOADS_AUTHORITY:

View file

@ -8,8 +8,10 @@ import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map;
import java.util.stream.Stream; import java.util.stream.Stream;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
@ -38,15 +40,32 @@ public class MediaStoreImageProvider extends ImageProvider {
}).flatMap(Stream::of).toArray(String[]::new); }).flatMap(Stream::of).toArray(String[]::new);
public void fetchAll(Activity activity, EventChannel.EventSink entrySink) { public void fetchAll(Activity activity, EventChannel.EventSink entrySink) {
fetch(activity, entrySink, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION); fetchFrom(activity, entrySink::success, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, null, null);
fetch(activity, entrySink, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION); fetchFrom(activity, entrySink::success, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION, null, null);
} }
private void fetch(final Activity activity, EventChannel.EventSink entrySink, final Uri contentUri, String[] projection) { @Override
public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) {
long id = ContentUris.parseId(uri);
String selection = MediaStore.MediaColumns._ID + "=?";
String[] selectionArgs = new String[]{String.valueOf(id)};
int entryCount = 0;
if (mimeType.startsWith(Constants.MIME_IMAGE)) {
entryCount = fetchFrom(activity, callback::onSuccess, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs);
} else if (mimeType.startsWith(Constants.MIME_VIDEO)) {
entryCount = fetchFrom(activity, callback::onSuccess, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs);
}
if (entryCount == 0) {
callback.onFailure();
}
}
private int fetchFrom(final Activity activity, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection, String selection, String[] selectionArgs) {
String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC"; String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC";
int entryCount = 0;
try { try {
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy); Cursor cursor = activity.getContentResolver().query(contentUri, projection, selection, selectionArgs, orderBy);
if (cursor != null) { if (cursor != null) {
// image & video // image & video
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
@ -74,7 +93,7 @@ public class MediaStoreImageProvider extends ImageProvider {
// 2) extract actual mimeType with metadata-extractor // 2) extract actual mimeType with metadata-extractor
// 3) update MediaStore // 3) update MediaStore
if (width > 0) { if (width > 0) {
entrySink.success( newEntryHandler.handleEntry(
new HashMap<String, Object>() {{ new HashMap<String, Object>() {{
put("uri", itemUri.toString()); put("uri", itemUri.toString());
put("path", cursor.getString(pathColumn)); put("path", cursor.getString(pathColumn));
@ -90,6 +109,7 @@ public class MediaStoreImageProvider extends ImageProvider {
put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn)); put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn));
put("durationMillis", durationColumn != -1 ? cursor.getLong(durationColumn) : 0); put("durationMillis", durationColumn != -1 ? cursor.getLong(durationColumn) : 0);
}}); }});
entryCount++;
// } else { // } else {
// // some images are incorrectly registered in the MediaStore, // // some images are incorrectly registered in the MediaStore,
// // they are valid but miss some attributes, such as width, height, orientation // // they are valid but miss some attributes, such as width, height, orientation
@ -107,6 +127,7 @@ public class MediaStoreImageProvider extends ImageProvider {
} catch (Exception e) { } catch (Exception e) {
Log.e(LOG_TAG, "failed to get entries", e); Log.e(LOG_TAG, "failed to get entries", e);
} }
return entryCount;
} }
@Override @Override
@ -139,4 +160,8 @@ public class MediaStoreImageProvider extends ImageProvider {
callback.onFailure(); callback.onFailure();
} }
private interface NewEntryHandler {
void handleEntry(Map<String, Object> entry);
}
} }

View file

@ -14,6 +14,7 @@ public class Constants {
public static final String MIME_JPEG = "image/jpeg"; public static final String MIME_JPEG = "image/jpeg";
public static final String MIME_PNG = "image/png"; public static final String MIME_PNG = "image/png";
public static final String MIME_MP2T = "video/mp2t"; // .m2ts public static final String MIME_MP2T = "video/mp2t"; // .m2ts
public static final String MIME_IMAGE = "image";
public static final String MIME_VIDEO = "video"; public static final String MIME_VIDEO = "video";
// video metadata keys, from android.media.MediaMetadataRetriever // video metadata keys, from android.media.MediaMetadataRetriever

View file

@ -1,9 +1,13 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_file_service.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/viewer_service.dart';
import 'package:aves/widgets/album/all_collection_drawer.dart'; import 'package:aves/widgets/album/all_collection_drawer.dart';
import 'package:aves/widgets/album/all_collection_page.dart'; import 'package:aves/widgets/album/all_collection_page.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/media_store_collection_provider.dart'; import 'package:aves/widgets/common/providers/media_store_collection_provider.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:outline_material_icons/outline_material_icons.dart';
@ -47,6 +51,7 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
ImageEntry _sharedEntry;
Future<void> _appSetup; Future<void> _appSetup;
@override @override
@ -60,6 +65,7 @@ class _HomePageState extends State<HomePage> {
Future<void> _setup() async { Future<void> _setup() async {
debugPrint('$runtimeType _setup'); debugPrint('$runtimeType _setup');
// TODO reduce permission check time // TODO reduce permission check time
final permissions = await PermissionHandler().requestPermissions([ final permissions = await PermissionHandler().requestPermissions([
PermissionGroup.storage, PermissionGroup.storage,
@ -75,6 +81,11 @@ class _HomePageState extends State<HomePage> {
await androidFileUtils.init(); // 170ms await androidFileUtils.init(); // 170ms
await settings.init(); // <20ms await settings.init(); // <20ms
final sharedExtra = await ViewerService.getSharedEntry();
if (sharedExtra != null) {
_sharedEntry = await ImageFileService.getImageEntry(sharedExtra['uri'], sharedExtra['mimeType']);
}
} }
@override @override
@ -86,7 +97,11 @@ class _HomePageState extends State<HomePage> {
if (snapshot.hasError) return const Icon(OMIcons.error); if (snapshot.hasError) return const Icon(OMIcons.error);
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
debugPrint('$runtimeType FutureBuilder builder'); debugPrint('$runtimeType FutureBuilder builder');
return const MediaStoreCollectionPage(); return _sharedEntry != null
? SingleFullscreenPage(
entry: _sharedEntry,
)
: const MediaStoreCollectionPage();
}), }),
); );
} }

View file

@ -111,7 +111,7 @@ class ImageEntry {
return width / height; return width / height;
} }
int get megaPixels => (width * height / 1000000).round(); int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
DateTime get bestDate { DateTime get bestDate {
if ((catalogMetadata?.dateMillis ?? 0) > 0) return DateTime.fromMillisecondsSinceEpoch(catalogMetadata.dateMillis); if ((catalogMetadata?.dateMillis ?? 0) > 0) return DateTime.fromMillisecondsSinceEpoch(catalogMetadata.dateMillis);

View file

@ -15,6 +15,20 @@ class ImageFileService {
} }
} }
static Future<ImageEntry> getImageEntry(String uri, String mimeType) async {
debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType');
try {
final result = await platform.invokeMethod('getImageEntry', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
}) as Map;
return ImageEntry.fromMap(result);
} on PlatformException catch (e) {
debugPrint('getImageEntry failed with exception=${e.message}');
}
return null;
}
static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async { static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
// debugPrint('getImageBytes width=$width path=${entry.path}'); // debugPrint('getImageBytes width=$width path=${entry.path}');

View file

@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class ViewerService {
static const platform = MethodChannel('deckers.thibault/aves/viewer');
static Future<Map> getSharedEntry() async {
try {
// return nullable map with: 'uri' 'mimeType'
return await platform.invokeMethod('getSharedEntry') as Map;
} on PlatformException catch (e) {
debugPrint('getSharedEntry failed with exception=${e.message}');
}
return {};
}
}

View file

@ -75,9 +75,9 @@ class SectionSliver extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
TransparentMaterialPageRoute( TransparentMaterialPageRoute(
pageBuilder: (context, _, __) => FullscreenPage( pageBuilder: (context, _, __) => MultiFullscreenPage(
collection: collection, collection: collection,
initialUri: entry.uri, initialEntry: entry,
), ),
), ),
); );

View file

@ -0,0 +1,385 @@
import 'dart:io';
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
import 'package:aves/widgets/fullscreen/overlay/top.dart';
import 'package:aves/widgets/fullscreen/overlay/video.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class FullscreenBody extends StatefulWidget {
final CollectionLens collection;
final ImageEntry initialEntry;
const FullscreenBody({
Key key,
this.collection,
this.initialEntry,
}) : super(key: key);
@override
FullscreenBodyState createState() => FullscreenBodyState();
}
class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProviderStateMixin {
int _currentHorizontalPage;
ValueNotifier<int> _currentVerticalPage;
PageController _horizontalPager, _verticalPager;
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
AnimationController _overlayAnimationController;
Animation<double> _topOverlayScale, _bottomOverlayScale;
Animation<Offset> _bottomOverlayOffset;
EdgeInsets _frozenViewInsets, _frozenViewPadding;
FullscreenActionDelegate _actionDelegate;
final List<Tuple2<String, VideoPlayerController>> _videoControllers = [];
CollectionLens get collection => widget.collection;
bool get hasCollection => collection != null;
List<ImageEntry> get entries => hasCollection ? collection.sortedEntries : [widget.initialEntry];
List<String> get pages => hasCollection ? ['transition', 'image', 'info'] : ['image', 'info'];
int get transitionPage => pages.indexOf('transition');
int get imagePage => pages.indexOf('image');
int get infoPage => pages.indexOf('info');
@override
void initState() {
super.initState();
_currentHorizontalPage = max(0, entries.indexOf(widget.initialEntry));
_currentVerticalPage = ValueNotifier(imagePage);
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
_verticalPager = PageController(initialPage: _currentVerticalPage.value);
_overlayAnimationController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_topOverlayScale = CurvedAnimation(
parent: _overlayAnimationController,
// a little bounce at the top
curve: Curves.easeOutBack,
);
_bottomOverlayScale = CurvedAnimation(
parent: _overlayAnimationController,
// no bounce at the bottom, to avoid video controller displacement
curve: Curves.easeOutQuad,
);
_bottomOverlayOffset = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(CurvedAnimation(
parent: _overlayAnimationController,
curve: Curves.easeOutQuad,
));
_overlayVisible.addListener(_onOverlayVisibleChange);
_actionDelegate = FullscreenActionDelegate(
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_initVideoController();
_initOverlay();
}
Future<void> _initOverlay() async {
// wait for MaterialPageRoute.transitionDuration
// to show overlay after hero animation is complete
await Future.delayed(Duration(milliseconds: (300 * timeDilation).toInt()));
await _onOverlayVisibleChange();
}
@override
void dispose() {
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) {
final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
return WillPopScope(
onWillPop: () {
if (_currentVerticalPage.value == infoPage) {
// back from info to image
_goToVerticalPage(imagePage);
return Future.value(false);
}
if (!ModalRoute.of(context).canPop) {
// exit app when trying to pop a fullscreen page that is a viewer for a single entry
exit(0);
}
_onLeave();
return Future.value(true);
},
child: Stack(
children: [
FullscreenVerticalPageView(
collection: collection,
entry: entry,
videoControllers: _videoControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
),
ValueListenableBuilder<int>(
valueListenable: _currentVerticalPage,
builder: (context, page, child) {
final showOverlay = entry != null && page == imagePage;
return showOverlay
? FullscreenTopOverlay(
entries: entries,
index: _currentHorizontalPage,
scale: _topOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
)
: const SizedBox.shrink();
},
),
ValueListenableBuilder<int>(
valueListenable: _currentVerticalPage,
builder: (context, page, child) {
final showOverlay = entry != null && page == imagePage;
final videoController = showOverlay && entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2 : null;
return showOverlay
? Positioned(
bottom: 0,
child: Column(
children: [
if (videoController != null)
VideoControlOverlay(
entry: entry,
controller: videoController,
scale: _bottomOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
showPosition: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
],
),
)
: const SizedBox.shrink();
},
),
],
),
);
}
Future<void> _goToVerticalPage(int page) {
return _verticalPager.animateToPage(
page,
duration: Duration(milliseconds: (300 * timeDilation).toInt()),
curve: Curves.easeInOut,
);
}
void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page;
if (page == transitionPage) {
_onLeave();
Navigator.pop(context);
}
}
void _onLeave() => _showSystemUI();
void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]);
Future<void> _onOverlayVisibleChange() async {
if (_overlayVisible.value) {
_showSystemUI();
_overlayAnimationController.forward();
} else {
final mediaQuery = Provider.of<MediaQueryData>(context, listen: false);
setState(() {
_frozenViewInsets = mediaQuery.viewInsets;
_frozenViewPadding = mediaQuery.viewPadding;
});
_hideSystemUI();
await _overlayAnimationController.reverse();
_frozenViewInsets = null;
_frozenViewPadding = null;
}
}
void _onHorizontalPageChanged(int page) {
_currentHorizontalPage = page;
_pauseVideoControllers();
_initVideoController();
setState(() {});
}
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
void _initVideoController() {
final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (entry == null || !entry.isVideo) return;
final path = entry.path;
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null);
if (controllerEntry != null) {
_videoControllers.remove(controllerEntry);
} else {
final controller = VideoPlayerController.file(File(path))..initialize();
controllerEntry = Tuple2(path, controller);
}
_videoControllers.insert(0, controllerEntry);
while (_videoControllers.length > 3) {
_videoControllers.removeLast().item2.dispose();
}
}
}
class FullscreenVerticalPageView extends StatefulWidget {
final CollectionLens collection;
final ImageEntry entry;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImageTap, onImagePageRequested;
const FullscreenVerticalPageView({
@required this.collection,
@required this.entry,
@required this.videoControllers,
@required this.verticalPager,
@required this.horizontalPager,
@required this.onVerticalPageChanged,
@required this.onHorizontalPageChanged,
@required this.onImageTap,
@required this.onImagePageRequested,
});
@override
_FullscreenVerticalPageViewState createState() => _FullscreenVerticalPageViewState();
}
class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView> {
bool _isInitialScale = true;
ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
ValueNotifier<bool> _infoPageVisibleNotifier = ValueNotifier(false);
CollectionLens get collection => widget.collection;
bool get hasCollection => collection != null;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(FullscreenVerticalPageView oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
super.dispose();
_unregisterWidget(widget);
}
void _registerWidget(FullscreenVerticalPageView widget) {
widget.verticalPager.addListener(_onVerticalPageControllerChange);
widget.entry.imageChangeNotifier.addListener(_onImageChange);
}
void _unregisterWidget(FullscreenVerticalPageView widget) {
widget.verticalPager.removeListener(_onVerticalPageControllerChange);
widget.entry.imageChangeNotifier.removeListener(_onImageChange);
}
void _onVerticalPageControllerChange() {
_backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(min(1.0, widget.verticalPager.page));
}
void _onImageChange() async {
await FileImage(File(widget.entry.path)).evict();
// rebuild to refresh the Image inside ImagePage
setState(() {});
}
@override
Widget build(BuildContext context) {
final onScaleChanged = (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial);
final pages = [
// fake page for opacity transition between collection and fullscreen views
if (hasCollection)
const SizedBox(),
hasCollection
? MultiImagePage(
collection: collection,
pageController: widget.horizontalPager,
onTap: widget.onImageTap,
onPageChanged: widget.onHorizontalPageChanged,
onScaleChanged: onScaleChanged,
videoControllers: widget.videoControllers,
)
: SingleImagePage(
entry: widget.entry,
onScaleChanged: onScaleChanged,
onTap: widget.onImageTap,
videoControllers: widget.videoControllers,
),
NotificationListener(
onNotification: (notification) {
if (notification is BackUpNotification) widget.onImagePageRequested();
return false;
},
child: InfoPage(
collection: collection,
entry: widget.entry,
visibleNotifier: _infoPageVisibleNotifier,
),
),
];
return ValueListenableBuilder(
valueListenable: _backgroundColorNotifier,
builder: (context, backgroundColor, child) => Container(
color: backgroundColor,
child: child,
),
child: PageView(
scrollDirection: Axis.vertical,
controller: widget.verticalPager,
physics: _isInitialScale ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(),
onPageChanged: (page) {
widget.onVerticalPageChanged(page);
_infoPageVisibleNotifier.value = page == pages.length - 1;
},
children: pages,
),
);
}
}

View file

@ -1,31 +1,17 @@
import 'dart:io';
import 'dart:math';
import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart'; import 'package:aves/widgets/fullscreen/fullscreen_body.dart';
import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
import 'package:aves/widgets/fullscreen/overlay/top.dart';
import 'package:aves/widgets/fullscreen/overlay/video.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class FullscreenPage extends AnimatedWidget { class MultiFullscreenPage extends AnimatedWidget {
final CollectionLens collection; final CollectionLens collection;
final String initialUri; final ImageEntry initialEntry;
const FullscreenPage({ const MultiFullscreenPage({
Key key, Key key,
this.collection, this.collection,
this.initialUri, this.initialEntry,
}) : super(key: key, listenable: collection); }) : super(key: key, listenable: collection);
@override @override
@ -34,7 +20,7 @@ class FullscreenPage extends AnimatedWidget {
child: Scaffold( child: Scaffold(
body: FullscreenBody( body: FullscreenBody(
collection: collection, collection: collection,
initialUri: initialUri, initialEntry: initialEntry,
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
@ -43,342 +29,23 @@ class FullscreenPage extends AnimatedWidget {
} }
} }
class FullscreenBody extends StatefulWidget { class SingleFullscreenPage extends StatelessWidget {
final CollectionLens collection; final ImageEntry entry;
final String initialUri;
const FullscreenBody({ const SingleFullscreenPage({
Key key, Key key,
this.collection, this.entry,
this.initialUri,
}) : super(key: key); }) : super(key: key);
@override
FullscreenBodyState createState() => FullscreenBodyState();
}
class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProviderStateMixin {
int _currentHorizontalPage;
final ValueNotifier<int> _currentVerticalPage = ValueNotifier(imagePage);
PageController _horizontalPager, _verticalPager;
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
AnimationController _overlayAnimationController;
Animation<double> _topOverlayScale, _bottomOverlayScale;
Animation<Offset> _bottomOverlayOffset;
EdgeInsets _frozenViewInsets, _frozenViewPadding;
FullscreenActionDelegate _actionDelegate;
final List<Tuple2<String, VideoPlayerController>> _videoControllers = [];
CollectionLens get collection => widget.collection;
List<ImageEntry> get entries => widget.collection.sortedEntries;
static const transitionPage = 0;
static const imagePage = 1;
static const infoPage = 2;
@override
void initState() {
super.initState();
final index = entries.indexWhere((entry) => entry.uri == widget.initialUri);
_currentHorizontalPage = max(0, index);
_horizontalPager = PageController(initialPage: _currentHorizontalPage);
_verticalPager = PageController(initialPage: _currentVerticalPage.value);
_overlayAnimationController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_topOverlayScale = CurvedAnimation(
parent: _overlayAnimationController,
// a little bounce at the top
curve: Curves.easeOutBack,
);
_bottomOverlayScale = CurvedAnimation(
parent: _overlayAnimationController,
// no bounce at the bottom, to avoid video controller displacement
curve: Curves.easeOutQuad,
);
_bottomOverlayOffset = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(CurvedAnimation(
parent: _overlayAnimationController,
curve: Curves.easeOutQuad,
));
_overlayVisible.addListener(_onOverlayVisibleChange);
_actionDelegate = FullscreenActionDelegate(
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_initVideoController();
_initOverlay();
}
Future<void> _initOverlay() async {
// wait for MaterialPageRoute.transitionDuration
// to show overlay after hero animation is complete
await Future.delayed(Duration(milliseconds: (300 * timeDilation).toInt()));
await _onOverlayVisibleChange();
}
@override
void dispose() {
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
_videoControllers.forEach((kv) => kv.item2.dispose());
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; return MediaQueryDataProvider(
return WillPopScope( child: Scaffold(
onWillPop: () { body: FullscreenBody(
if (_currentVerticalPage.value == infoPage) { initialEntry: entry,
_goToVerticalPage(imagePage);
return Future.value(false);
}
_onLeave();
return Future.value(true);
},
child: Stack(
children: [
FullscreenVerticalPageView(
collection: collection,
entry: entry,
videoControllers: _videoControllers,
verticalPager: _verticalPager,
horizontalPager: _horizontalPager,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage),
), ),
ValueListenableBuilder<int>( backgroundColor: Colors.black,
valueListenable: _currentVerticalPage, resizeToAvoidBottomInset: false,
builder: (context, page, child) {
final showOverlay = entry != null && page == imagePage;
return showOverlay
? FullscreenTopOverlay(
entries: entries,
index: _currentHorizontalPage,
scale: _topOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
)
: const SizedBox.shrink();
},
),
ValueListenableBuilder<int>(
valueListenable: _currentVerticalPage,
builder: (context, page, child) {
final showOverlay = entry != null && page == imagePage;
final videoController = showOverlay && entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2 : null;
return showOverlay
? Positioned(
bottom: 0,
child: Column(
children: [
if (videoController != null)
VideoControlOverlay(
entry: entry,
controller: videoController,
scale: _bottomOverlayScale,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
SlideTransition(
position: _bottomOverlayOffset,
child: FullscreenBottomOverlay(
entries: entries,
index: _currentHorizontalPage,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
),
],
),
)
: const SizedBox.shrink();
},
),
],
),
);
}
Future<void> _goToVerticalPage(int page) {
return _verticalPager.animateToPage(
page,
duration: Duration(milliseconds: (300 * timeDilation).toInt()),
curve: Curves.easeInOut,
);
}
void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page;
if (page == transitionPage) {
_onLeave();
Navigator.pop(context);
}
}
void _onLeave() => _showSystemUI();
void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]);
Future<void> _onOverlayVisibleChange() async {
if (_overlayVisible.value) {
_showSystemUI();
_overlayAnimationController.forward();
} else {
final mediaQuery = Provider.of<MediaQueryData>(context, listen: false);
setState(() {
_frozenViewInsets = mediaQuery.viewInsets;
_frozenViewPadding = mediaQuery.viewPadding;
});
_hideSystemUI();
await _overlayAnimationController.reverse();
_frozenViewInsets = null;
_frozenViewPadding = null;
}
}
void _onHorizontalPageChanged(int page) {
_currentHorizontalPage = page;
_pauseVideoControllers();
_initVideoController();
setState(() {});
}
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
void _initVideoController() {
final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (entry == null || !entry.isVideo) return;
final path = entry.path;
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null);
if (controllerEntry != null) {
_videoControllers.remove(controllerEntry);
} else {
final controller = VideoPlayerController.file(File(path))..initialize();
controllerEntry = Tuple2(path, controller);
}
_videoControllers.insert(0, controllerEntry);
while (_videoControllers.length > 3) {
_videoControllers.removeLast().item2.dispose();
}
}
}
class FullscreenVerticalPageView extends StatefulWidget {
final CollectionLens collection;
final ImageEntry entry;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImageTap, onImagePageRequested;
const FullscreenVerticalPageView({
@required this.collection,
@required this.entry,
@required this.videoControllers,
@required this.verticalPager,
@required this.horizontalPager,
@required this.onVerticalPageChanged,
@required this.onHorizontalPageChanged,
@required this.onImageTap,
@required this.onImagePageRequested,
});
@override
_FullscreenVerticalPageViewState createState() => _FullscreenVerticalPageViewState();
}
class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView> {
bool _isInitialScale = true;
ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
ValueNotifier<bool> _infoPageVisibleNotifier = ValueNotifier(false);
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(FullscreenVerticalPageView oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
super.dispose();
_unregisterWidget(widget);
}
void _registerWidget(FullscreenVerticalPageView widget) {
widget.verticalPager.addListener(_onVerticalPageControllerChange);
widget.entry.imageChangeNotifier.addListener(_onImageChange);
}
void _unregisterWidget(FullscreenVerticalPageView widget) {
widget.verticalPager.removeListener(_onVerticalPageControllerChange);
widget.entry.imageChangeNotifier.removeListener(_onImageChange);
}
void _onVerticalPageControllerChange() {
_backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(min(1.0, widget.verticalPager.page));
}
void _onImageChange() async {
await FileImage(File(widget.entry.path)).evict();
// rebuild to refresh the Image inside ImagePage
setState(() {});
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: _backgroundColorNotifier,
builder: (context, backgroundColor, child) => Container(
color: backgroundColor,
child: child,
),
child: PageView(
scrollDirection: Axis.vertical,
controller: widget.verticalPager,
physics: _isInitialScale ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(),
onPageChanged: (page) {
widget.onVerticalPageChanged(page);
_infoPageVisibleNotifier.value = page == FullscreenBodyState.infoPage;
},
children: [
// fake page for opacity transition between collection and fullscreen views
const SizedBox(),
ImagePage(
collection: widget.collection,
pageController: widget.horizontalPager,
onTap: widget.onImageTap,
onPageChanged: widget.onHorizontalPageChanged,
onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial),
videoControllers: widget.videoControllers,
),
NotificationListener(
onNotification: (notification) {
if (notification is BackUpNotification) widget.onImagePageRequested();
return false;
},
child: InfoPage(
collection: widget.collection,
entry: widget.entry,
visibleNotifier: _infoPageVisibleNotifier,
),
),
],
), ),
); );
} }

View file

@ -1,35 +1,33 @@
import 'dart:io';
import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/video.dart'; import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
class ImagePage extends StatefulWidget { class MultiImagePage extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
final PageController pageController; final PageController pageController;
final VoidCallback onTap;
final ValueChanged<int> onPageChanged; final ValueChanged<int> onPageChanged;
final ValueChanged<PhotoViewScaleState> onScaleChanged; final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, VideoPlayerController>> videoControllers; final List<Tuple2<String, VideoPlayerController>> videoControllers;
const ImagePage({ const MultiImagePage({
this.collection, this.collection,
this.pageController, this.pageController,
this.onTap,
this.onPageChanged, this.onPageChanged,
this.onScaleChanged, this.onScaleChanged,
this.onTap,
this.videoControllers, this.videoControllers,
}); });
@override @override
State<StatefulWidget> createState() => ImagePageState(); State<StatefulWidget> createState() => MultiImagePageState();
} }
class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin { class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveClientMixin {
List<ImageEntry> get entries => widget.collection.sortedEntries; List<ImageEntry> get entries => widget.collection.sortedEntries;
@override @override
@ -37,9 +35,6 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
super.build(context); super.build(context);
const scrollDirection = Axis.horizontal; const scrollDirection = Axis.horizontal;
const backgroundDecoration = BoxDecoration(color: Colors.transparent);
final scaleStateChangedCallback = widget.onScaleChanged;
return PhotoViewGestureDetectorScope( return PhotoViewGestureDetectorScope(
axis: scrollDirection, axis: scrollDirection,
child: PageView.builder( child: PageView.builder(
@ -48,46 +43,12 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
itemCount: entries.length, itemCount: entries.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
if (entry.isVideo) { return ImageView(
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2;
return PhotoView.customChild(
child: videoController != null
? AvesVideo(
entry: entry, entry: entry,
controller: videoController, heroTag: widget.collection.heroTag(entry),
) onScaleChanged: widget.onScaleChanged,
: const SizedBox(), onTap: widget.onTap,
backgroundDecoration: backgroundDecoration, videoControllers: widget.videoControllers,
// no hero as most videos fullscreen image is different from its thumbnail
scaleStateChangedCallback: scaleStateChangedCallback,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => widget.onTap?.call(),
);
}
return PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: FileImage(File(entry.path)),
loadingBuilder: (context, event) => const Center(
child: SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
backgroundDecoration: backgroundDecoration,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.collection.heroTag(entry),
transitionOnUserGestures: true,
),
scaleStateChangedCallback: scaleStateChangedCallback,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => widget.onTap?.call(),
filterQuality: FilterQuality.low,
); );
}, },
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
@ -99,3 +60,37 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class SingleImagePage extends StatefulWidget {
final ImageEntry entry;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
const SingleImagePage({
this.entry,
this.onScaleChanged,
this.onTap,
this.videoControllers,
});
@override
State<StatefulWidget> createState() => SingleImagePageState();
}
class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ImageView(
entry: widget.entry,
onScaleChanged: widget.onScaleChanged,
onTap: widget.onTap,
videoControllers: widget.videoControllers,
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,75 @@
import 'dart:io';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/video.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:transparent_image/transparent_image.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
class ImageView extends StatelessWidget {
final ImageEntry entry;
final Object heroTag;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap;
final List<Tuple2<String, VideoPlayerController>> videoControllers;
const ImageView({
this.entry,
this.heroTag,
this.onScaleChanged,
this.onTap,
this.videoControllers,
});
@override
Widget build(BuildContext context) {
const backgroundDecoration = BoxDecoration(color: Colors.transparent);
if (entry.isVideo) {
final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2;
return PhotoView.customChild(
child: videoController != null
? AvesVideo(
entry: entry,
controller: videoController,
)
: const SizedBox(),
backgroundDecoration: backgroundDecoration,
// no hero as most videos fullscreen image is different from its thumbnail
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
return PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: entry.path != null ? FileImage(File(entry.path)) : MemoryImage(kTransparentImage),
loadingBuilder: (context, event) => const Center(
child: SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
backgroundDecoration: backgroundDecoration,
heroAttributes: heroTag != null
? PhotoViewHeroAttributes(
tag: heroTag,
transitionOnUserGestures: true,
)
: null,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
}

View file

@ -15,19 +15,19 @@ class BasicSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}'; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}' : '?';
final resolutionText = '${entry.width} × ${entry.height}${(entry.isVideo || entry.isGif) ? '' : ' (${entry.megaPixels} MP)'}'; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${(entry.isVideo || entry.isGif || entry.megaPixels == null) ? '' : ' (${entry.megaPixels} MP)'}';
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InfoRow('Title', entry.title), InfoRow('Title', entry.title ?? '?'),
InfoRow('Date', dateText), InfoRow('Date', dateText),
if (entry.isVideo) ..._buildVideoRows(), if (entry.isVideo) ..._buildVideoRows(),
InfoRow('Resolution', resolutionText), InfoRow('Resolution', resolutionText),
InfoRow('Size', formatFilesize(entry.sizeBytes)), InfoRow('Size', entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?'),
InfoRow('URI', entry.uri), InfoRow('URI', entry.uri ?? '?'),
InfoRow('Path', entry.path), InfoRow('Path', entry.path ?? '?'),
], ],
); );
} }

View file

@ -16,12 +16,14 @@ import 'package:tuple/tuple.dart';
class FullscreenBottomOverlay extends StatefulWidget { class FullscreenBottomOverlay extends StatefulWidget {
final List<ImageEntry> entries; final List<ImageEntry> entries;
final int index; final int index;
final bool showPosition;
final EdgeInsets viewInsets, viewPadding; final EdgeInsets viewInsets, viewPadding;
const FullscreenBottomOverlay({ const FullscreenBottomOverlay({
Key key, Key key,
@required this.entries, @required this.entries,
@required this.index, @required this.index,
@required this.showPosition,
this.viewInsets, this.viewInsets,
this.viewPadding, this.viewPadding,
}) : super(key: key); }) : super(key: key);
@ -91,7 +93,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
: _FullscreenBottomOverlayContent( : _FullscreenBottomOverlayContent(
entry: _lastEntry, entry: _lastEntry,
details: _lastDetails, details: _lastDetails,
position: '${widget.index + 1}/${widget.entries.length}', position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
maxWidth: overlayContentMaxWidth, maxWidth: overlayContentMaxWidth,
); );
}, },
@ -149,7 +151,7 @@ class _FullscreenBottomOverlayContent extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: maxWidth, width: maxWidth,
child: Text('$position ${entry.title}', strutStyle: Constants.overflowStrutStyle), child: Text('${position != null ? '$position ' : ''}${entry.title ?? '?'}', strutStyle: Constants.overflowStrutStyle),
), ),
if (entry.hasGps) if (entry.hasGps)
Container( Container(
@ -219,8 +221,8 @@ class _DateRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}'; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}' : '?';
final resolution = '${entry.width} × ${entry.height}'; final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
return Row( return Row(
children: [ children: [
const Icon(OMIcons.calendarToday, size: _iconSize), const Icon(OMIcons.calendarToday, size: _iconSize),

View file

@ -34,7 +34,7 @@ class FullscreenTopOverlay extends StatelessWidget {
children: [ children: [
OverlayButton( OverlayButton(
scale: scale, scale: scale,
child: const BackButton(), child: ModalRoute.of(context)?.canPop ?? true ? const BackButton(): const CloseButton(),
), ),
const Spacer(), const Spacer(),
OverlayButton( OverlayButton(