viewer: handle media store content uris
This commit is contained in:
parent
73d97f821b
commit
b2f72d964f
20 changed files with 713 additions and 437 deletions
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}');
|
||||||
|
|
16
lib/utils/viewer_service.dart
Normal file
16
lib/utils/viewer_service.dart
Normal 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 {};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
385
lib/widgets/fullscreen/fullscreen_body.dart
Normal file
385
lib/widgets/fullscreen/fullscreen_body.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
75
lib/widgets/fullscreen/image_view.dart
Normal file
75
lib/widgets/fullscreen/image_view.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 ?? '?'),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in a new issue