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"
|
||||
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" />
|
||||
|
||||
<!-- TODO remove this permission once this issue is fixed:
|
||||
https://github.com/flutter/flutter/issues/42349
|
||||
https://github.com/flutter/flutter/issues/42451
|
||||
-->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:label="Aves"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Aves"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/AppTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/AppTheme"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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
|
||||
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
|
||||
android:value="false" />
|
||||
</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" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -3,6 +3,10 @@ package deckers.thibault.aves;
|
|||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
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.ImageFileHandler;
|
||||
|
@ -11,6 +15,7 @@ import deckers.thibault.aves.channelhandlers.MetadataHandler;
|
|||
import deckers.thibault.aves.utils.Constants;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import io.flutter.app.FlutterActivity;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
@ -18,11 +23,19 @@ import io.flutter.plugins.GeneratedPluginRegistrant;
|
|||
import io.flutter.view.FlutterView;
|
||||
|
||||
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
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
GeneratedPluginRegistrant.registerWith(this);
|
||||
|
||||
handleIntent(getIntent());
|
||||
|
||||
MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
|
||||
|
||||
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, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
||||
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
|
||||
|
|
|
@ -35,6 +35,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
mediaStoreStreamHandler.fetchAll(activity);
|
||||
result.success(null);
|
||||
break;
|
||||
case "getImageEntry":
|
||||
getImageEntry(call, result);
|
||||
break;
|
||||
case "getImageBytes":
|
||||
getImageBytes(call, result);
|
||||
break;
|
||||
|
@ -74,6 +77,34 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
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) {
|
||||
Map entryMap = call.argument("entry");
|
||||
if (entryMap == null) {
|
||||
|
|
|
@ -5,9 +5,7 @@ import android.util.Log;
|
|||
|
||||
import java.time.Duration;
|
||||
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.utils.Utils;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
|
|
|
@ -33,6 +33,10 @@ import deckers.thibault.aves.utils.Utils;
|
|||
public abstract class ImageProvider {
|
||||
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) {
|
||||
callback.onFailure();
|
||||
}
|
||||
|
|
|
@ -12,9 +12,11 @@ public class ImageProviderFactory {
|
|||
if (scheme != null) {
|
||||
switch (scheme) {
|
||||
case ContentResolver.SCHEME_CONTENT: // content://
|
||||
String authority = uri.getAuthority();
|
||||
if (authority != null) {
|
||||
switch (authority) {
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to MediaStore's "authority"
|
||||
String host = uri.getHost();
|
||||
if (host != null) {
|
||||
switch (host) {
|
||||
case MediaStore.AUTHORITY:
|
||||
return new MediaStoreImageProvider();
|
||||
// case Constants.DOWNLOADS_AUTHORITY:
|
||||
|
|
|
@ -8,8 +8,10 @@ import android.provider.MediaStore;
|
|||
import android.util.Log;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import deckers.thibault.aves.utils.Constants;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
|
@ -38,15 +40,32 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}).flatMap(Stream::of).toArray(String[]::new);
|
||||
|
||||
public void fetchAll(Activity activity, EventChannel.EventSink entrySink) {
|
||||
fetch(activity, entrySink, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION);
|
||||
fetch(activity, entrySink, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION);
|
||||
fetchFrom(activity, entrySink::success, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, null, null);
|
||||
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";
|
||||
int entryCount = 0;
|
||||
|
||||
try {
|
||||
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy);
|
||||
Cursor cursor = activity.getContentResolver().query(contentUri, projection, selection, selectionArgs, orderBy);
|
||||
if (cursor != null) {
|
||||
// image & video
|
||||
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
|
||||
|
@ -74,7 +93,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
// 2) extract actual mimeType with metadata-extractor
|
||||
// 3) update MediaStore
|
||||
if (width > 0) {
|
||||
entrySink.success(
|
||||
newEntryHandler.handleEntry(
|
||||
new HashMap<String, Object>() {{
|
||||
put("uri", itemUri.toString());
|
||||
put("path", cursor.getString(pathColumn));
|
||||
|
@ -90,6 +109,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn));
|
||||
put("durationMillis", durationColumn != -1 ? cursor.getLong(durationColumn) : 0);
|
||||
}});
|
||||
entryCount++;
|
||||
// } else {
|
||||
// // some images are incorrectly registered in the MediaStore,
|
||||
// // they are valid but miss some attributes, such as width, height, orientation
|
||||
|
@ -107,6 +127,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
} catch (Exception e) {
|
||||
Log.e(LOG_TAG, "failed to get entries", e);
|
||||
}
|
||||
return entryCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -139,4 +160,8 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
|
||||
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_PNG = "image/png";
|
||||
public static final String MIME_MP2T = "video/mp2t"; // .m2ts
|
||||
public static final String MIME_IMAGE = "image";
|
||||
public static final String MIME_VIDEO = "video";
|
||||
|
||||
// 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/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_page.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/fullscreen/fullscreen_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:outline_material_icons/outline_material_icons.dart';
|
||||
|
@ -47,6 +51,7 @@ class HomePage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
ImageEntry _sharedEntry;
|
||||
Future<void> _appSetup;
|
||||
|
||||
@override
|
||||
|
@ -60,6 +65,7 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
Future<void> _setup() async {
|
||||
debugPrint('$runtimeType _setup');
|
||||
|
||||
// TODO reduce permission check time
|
||||
final permissions = await PermissionHandler().requestPermissions([
|
||||
PermissionGroup.storage,
|
||||
|
@ -75,6 +81,11 @@ class _HomePageState extends State<HomePage> {
|
|||
await androidFileUtils.init(); // 170ms
|
||||
|
||||
await settings.init(); // <20ms
|
||||
|
||||
final sharedExtra = await ViewerService.getSharedEntry();
|
||||
if (sharedExtra != null) {
|
||||
_sharedEntry = await ImageFileService.getImageEntry(sharedExtra['uri'], sharedExtra['mimeType']);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -86,7 +97,11 @@ class _HomePageState extends State<HomePage> {
|
|||
if (snapshot.hasError) return const Icon(OMIcons.error);
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
debugPrint('$runtimeType FutureBuilder builder');
|
||||
return const MediaStoreCollectionPage();
|
||||
return _sharedEntry != null
|
||||
? SingleFullscreenPage(
|
||||
entry: _sharedEntry,
|
||||
)
|
||||
: const MediaStoreCollectionPage();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -111,7 +111,7 @@ class ImageEntry {
|
|||
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 {
|
||||
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 {
|
||||
if (width > 0 && height > 0) {
|
||||
// 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(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
pageBuilder: (context, _, __) => FullscreenPage(
|
||||
pageBuilder: (context, _, __) => MultiFullscreenPage(
|
||||
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/image_entry.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/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:aves/widgets/fullscreen/fullscreen_body.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 String initialUri;
|
||||
final ImageEntry initialEntry;
|
||||
|
||||
const FullscreenPage({
|
||||
const MultiFullscreenPage({
|
||||
Key key,
|
||||
this.collection,
|
||||
this.initialUri,
|
||||
this.initialEntry,
|
||||
}) : super(key: key, listenable: collection);
|
||||
|
||||
@override
|
||||
|
@ -34,7 +20,7 @@ class FullscreenPage extends AnimatedWidget {
|
|||
child: Scaffold(
|
||||
body: FullscreenBody(
|
||||
collection: collection,
|
||||
initialUri: initialUri,
|
||||
initialEntry: initialEntry,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
resizeToAvoidBottomInset: false,
|
||||
|
@ -43,342 +29,23 @@ class FullscreenPage extends AnimatedWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class FullscreenBody extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final String initialUri;
|
||||
class SingleFullscreenPage extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
const FullscreenBody({
|
||||
const SingleFullscreenPage({
|
||||
Key key,
|
||||
this.collection,
|
||||
this.initialUri,
|
||||
this.entry,
|
||||
}) : 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
|
||||
Widget build(BuildContext context) {
|
||||
final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
|
||||
return WillPopScope(
|
||||
onWillPop: () {
|
||||
if (_currentVerticalPage.value == infoPage) {
|
||||
_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),
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: FullscreenBody(
|
||||
initialEntry: entry,
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
backgroundColor: Colors.black,
|
||||
resizeToAvoidBottomInset: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,35 +1,33 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/collection_lens.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:photo_view/photo_view.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class ImagePage extends StatefulWidget {
|
||||
class MultiImagePage extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final PageController pageController;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final ValueChanged<PhotoViewScaleState> onScaleChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, VideoPlayerController>> videoControllers;
|
||||
|
||||
const ImagePage({
|
||||
const MultiImagePage({
|
||||
this.collection,
|
||||
this.pageController,
|
||||
this.onTap,
|
||||
this.onPageChanged,
|
||||
this.onScaleChanged,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
});
|
||||
|
||||
@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;
|
||||
|
||||
@override
|
||||
|
@ -37,9 +35,6 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
|
|||
super.build(context);
|
||||
|
||||
const scrollDirection = Axis.horizontal;
|
||||
const backgroundDecoration = BoxDecoration(color: Colors.transparent);
|
||||
final scaleStateChangedCallback = widget.onScaleChanged;
|
||||
|
||||
return PhotoViewGestureDetectorScope(
|
||||
axis: scrollDirection,
|
||||
child: PageView.builder(
|
||||
|
@ -48,46 +43,12 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
|
|||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
if (entry.isVideo) {
|
||||
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2;
|
||||
return PhotoView.customChild(
|
||||
child: videoController != null
|
||||
? AvesVideo(
|
||||
return ImageView(
|
||||
entry: entry,
|
||||
controller: videoController,
|
||||
)
|
||||
: const SizedBox(),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
// 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,
|
||||
heroTag: widget.collection.heroTag(entry),
|
||||
onScaleChanged: widget.onScaleChanged,
|
||||
onTap: widget.onTap,
|
||||
videoControllers: widget.videoControllers,
|
||||
);
|
||||
},
|
||||
scrollDirection: scrollDirection,
|
||||
|
@ -99,3 +60,37 @@ class ImagePageState extends State<ImagePage> with AutomaticKeepAliveClientMixin
|
|||
@override
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final date = entry.bestDate;
|
||||
final dateText = '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}';
|
||||
final resolutionText = '${entry.width} × ${entry.height}${(entry.isVideo || entry.isGif) ? '' : ' (${entry.megaPixels} MP)'}';
|
||||
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 == null) ? '' : ' (${entry.megaPixels} MP)'}';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoRow('Title', entry.title),
|
||||
InfoRow('Title', entry.title ?? '?'),
|
||||
InfoRow('Date', dateText),
|
||||
if (entry.isVideo) ..._buildVideoRows(),
|
||||
InfoRow('Resolution', resolutionText),
|
||||
InfoRow('Size', formatFilesize(entry.sizeBytes)),
|
||||
InfoRow('URI', entry.uri),
|
||||
InfoRow('Path', entry.path),
|
||||
InfoRow('Size', entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?'),
|
||||
InfoRow('URI', entry.uri ?? '?'),
|
||||
InfoRow('Path', entry.path ?? '?'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,12 +16,14 @@ import 'package:tuple/tuple.dart';
|
|||
class FullscreenBottomOverlay extends StatefulWidget {
|
||||
final List<ImageEntry> entries;
|
||||
final int index;
|
||||
final bool showPosition;
|
||||
final EdgeInsets viewInsets, viewPadding;
|
||||
|
||||
const FullscreenBottomOverlay({
|
||||
Key key,
|
||||
@required this.entries,
|
||||
@required this.index,
|
||||
@required this.showPosition,
|
||||
this.viewInsets,
|
||||
this.viewPadding,
|
||||
}) : super(key: key);
|
||||
|
@ -91,7 +93,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
|
|||
: _FullscreenBottomOverlayContent(
|
||||
entry: _lastEntry,
|
||||
details: _lastDetails,
|
||||
position: '${widget.index + 1}/${widget.entries.length}',
|
||||
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
|
||||
maxWidth: overlayContentMaxWidth,
|
||||
);
|
||||
},
|
||||
|
@ -149,7 +151,7 @@ class _FullscreenBottomOverlayContent extends StatelessWidget {
|
|||
children: [
|
||||
SizedBox(
|
||||
width: maxWidth,
|
||||
child: Text('$position – ${entry.title}', strutStyle: Constants.overflowStrutStyle),
|
||||
child: Text('${position != null ? '$position – ' : ''}${entry.title ?? '?'}', strutStyle: Constants.overflowStrutStyle),
|
||||
),
|
||||
if (entry.hasGps)
|
||||
Container(
|
||||
|
@ -219,8 +221,8 @@ class _DateRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = entry.bestDate;
|
||||
final dateText = '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}';
|
||||
final resolution = '${entry.width} × ${entry.height}';
|
||||
final dateText = date != null ? '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}' : '?';
|
||||
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(OMIcons.calendarToday, size: _iconSize),
|
||||
|
|
|
@ -34,7 +34,7 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
children: [
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: const BackButton(),
|
||||
child: ModalRoute.of(context)?.canPop ?? true ? const BackButton(): const CloseButton(),
|
||||
),
|
||||
const Spacer(),
|
||||
OverlayButton(
|
||||
|
|
Loading…
Reference in a new issue