diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index 0fc854452..346dfa84e 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -9,6 +9,7 @@ import java.util.HashMap; import java.util.Map; import deckers.thibault.aves.channelhandlers.AppAdapterHandler; +import deckers.thibault.aves.channelhandlers.FileAdapterHandler; import deckers.thibault.aves.channelhandlers.ImageFileHandler; import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler; import deckers.thibault.aves.channelhandlers.MetadataHandler; @@ -39,6 +40,7 @@ public class MainActivity extends FlutterActivity { MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler(); FlutterView messenger = getFlutterView(); + new MethodChannel(messenger, FileAdapterHandler.CHANNEL).setMethodCallHandler(new FileAdapterHandler(this)); new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this)); new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java new file mode 100644 index 000000000..f60035ef9 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java @@ -0,0 +1,62 @@ +package deckers.thibault.aves.channelhandlers; + +import android.app.Activity; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import deckers.thibault.aves.utils.Env; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +public class FileAdapterHandler implements MethodChannel.MethodCallHandler { + public static final String CHANNEL = "deckers.thibault/aves/file"; + + private Activity activity; + + public FileAdapterHandler(Activity activity) { + this.activity = activity; + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + switch (call.method) { + case "getStorageVolumes": { + List> volumes = new ArrayList<>(); + StorageManager sm = activity.getSystemService(StorageManager.class); + if (sm != null) { + for (String path : Env.getStorageVolumes(activity)) { + try { + File file = new File(path); + StorageVolume volume = sm.getStorageVolume(file); + if (volume != null) { + Map volumeMap = new HashMap<>(); + volumeMap.put("path", path); + volumeMap.put("description", volume.getDescription(activity)); + volumeMap.put("isPrimary", volume.isPrimary()); + volumeMap.put("isRemovable", volume.isRemovable()); + volumeMap.put("isEmulated", volume.isEmulated()); + volumeMap.put("state", volume.getState()); + volumes.add(volumeMap); + } + } catch (IllegalArgumentException e) { + // ignore + } + } + } + result.success(volumes); + break; + } + default: + result.notImplemented(); + break; + } + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java index 203705860..15e9ebd18 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -23,7 +23,6 @@ import com.bumptech.glide.signature.ObjectKey; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; import deckers.thibault.aves.decoder.VideoThumbnail; import deckers.thibault.aves.model.ImageEntry; @@ -37,14 +36,12 @@ public class ImageDecodeTask extends AsyncTask complete; - Params(ImageEntry entry, int width, int height, MethodChannel.Result result, Consumer complete) { + Params(ImageEntry entry, int width, int height, MethodChannel.Result result) { this.entry = entry; this.width = width; this.height = height; this.result = result; - this.complete = complete; } } @@ -127,10 +124,13 @@ public class ImageDecodeTask extends AsyncTask taskParamsQueue; - private boolean running = true; - - ImageDecodeTaskManager(Activity activity) { - taskParamsQueue = new LinkedBlockingDeque<>(); - new Thread(() -> { - try { - while (running) { - ImageDecodeTask.Params params = taskParamsQueue.take(); - new ImageDecodeTask(activity).execute(params); - Thread.sleep(10); - } - } catch (InterruptedException ex) { - Log.w(LOG_TAG, ex); - } - }).start(); - } - - void fetch(MethodChannel.Result result, ImageEntry entry, Integer width, Integer height) { - taskParamsQueue.addFirst(new ImageDecodeTask.Params(entry, width, height, result, this::complete)); - } - - void cancel(String uri) { - boolean removed = taskParamsQueue.removeIf(p -> uri.equals(p.entry.uri.toString())); - if (removed) Log.d(LOG_TAG, "cancelled uri=" + uri); - } - - private void complete(String uri) { - // nothing for now - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java index f89ca956e..a0c6d7ec9 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java @@ -34,12 +34,10 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { public static final String CHANNEL = "deckers.thibault/aves/image"; private Activity activity; - private ImageDecodeTaskManager imageDecodeTaskManager; private MediaStoreStreamHandler mediaStoreStreamHandler; public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) { this.activity = activity; - imageDecodeTaskManager = new ImageDecodeTaskManager(activity); this.mediaStoreStreamHandler = mediaStoreStreamHandler; } @@ -59,9 +57,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { case "getThumbnail": new Thread(() -> getThumbnail(call, new MethodResultWrapper(result))).start(); break; - case "cancelGetThumbnail": - new Thread(() -> cancelGetThumbnail(call, new MethodResultWrapper(result))).start(); - break; case "delete": new Thread(() -> delete(call, new MethodResultWrapper(result))).start(); break; @@ -145,13 +140,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { return; } ImageEntry entry = new ImageEntry(entryMap); - imageDecodeTaskManager.fetch(result, entry, width, height); - } - - private void cancelGetThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - String uri = call.argument("uri"); - imageDecodeTaskManager.cancel(uri); - result.success(null); + new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, result)); } private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { @@ -275,7 +264,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { int bufferSize = 1024; byte[] buffer = new byte[bufferSize]; - int len = 0; + int len; while ((len = inputStream.read(buffer)) != -1) { byteBuffer.write(buffer, 0, len); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index a0103767c..0b338f642 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -19,8 +19,11 @@ import com.drew.lang.GeoLocation; import com.drew.metadata.Directory; import com.drew.metadata.Metadata; import com.drew.metadata.Tag; +import com.drew.metadata.exif.ExifIFD0Directory; import com.drew.metadata.exif.ExifSubIFDDirectory; import com.drew.metadata.exif.GpsDirectory; +import com.drew.metadata.gif.GifAnimationDirectory; +import com.drew.metadata.webp.WebpDirectory; import com.drew.metadata.xmp.XmpDirectory; import java.io.FileInputStream; @@ -41,13 +44,30 @@ import io.flutter.plugin.common.MethodChannel; public class MetadataHandler implements MethodChannel.MethodCallHandler { public static final String CHANNEL = "deckers.thibault/aves/metadata"; + // catalog metadata + private static final String KEY_DATE_MILLIS = "dateMillis"; + private static final String KEY_IS_ANIMATED = "isAnimated"; + private static final String KEY_LATITUDE = "latitude"; + private static final String KEY_LONGITUDE = "longitude"; + private static final String KEY_VIDEO_ROTATION = "videoRotation"; + private static final String KEY_XMP_SUBJECTS = "xmpSubjects"; + private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"; + + // overlay metadata + private static final String KEY_APERTURE = "aperture"; + private static final String KEY_EXPOSURE_TIME = "exposureTime"; + private static final String KEY_FOCAL_LENGTH = "focalLength"; + private static final String KEY_ISO = "iso"; + + // XMP private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"; private static final String XMP_SUBJECT_PROP_NAME = "dc:subject"; private static final String XMP_TITLE_PROP_NAME = "dc:title"; private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description"; private static final String XMP_GENERIC_LANG = ""; private static final String XMP_SPECIFIC_LANG = "en-US"; - private static final Pattern videoLocationPattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+)/?"); + + private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+)/?"); private Context context; @@ -179,12 +199,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { if (!MimeTypes.MP2T.equals(mimeType)) { Metadata metadata = ImageMetadataReader.readMetadata(is); - // EXIF Sub-IFD - ExifSubIFDDirectory exifSubDir = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); - if (exifSubDir != null) { - if (exifSubDir.containsTag(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL)) { - metadataMap.put("dateMillis", exifSubDir.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, null, TimeZone.getDefault()).getTime()); - } + // EXIF + putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME); } // GPS @@ -192,8 +210,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { if (gpsDir != null) { GeoLocation geoLocation = gpsDir.getGeoLocation(); if (geoLocation != null) { - metadataMap.put("latitude", geoLocation.getLatitude()); - metadataMap.put("longitude", geoLocation.getLongitude()); + metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude()); + metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude()); } } @@ -209,30 +227,29 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i); sb.append(";").append(item.getValue()); } - metadataMap.put("xmpSubjects", sb.toString()); + metadataMap.put(KEY_XMP_SUBJECTS, sb.toString()); } - // double check retrieved items as the property sometimes is reported to exist but it is actually null - String titleDescription = null; - if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_TITLE_PROP_NAME)) { - XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, XMP_TITLE_PROP_NAME, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG); - if (item != null) { - titleDescription = item.getValue(); - } - } - if (titleDescription == null && xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_DESCRIPTION_PROP_NAME)) { - XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, XMP_DESCRIPTION_PROP_NAME, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG); - if (item != null) { - titleDescription = item.getValue(); - } - } - if (titleDescription != null) { - metadataMap.put("xmpTitleDescription", titleDescription); + putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME); + if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { + putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME); } } catch (XMPException e) { e.printStackTrace(); } } + + // Animated GIF & WEBP + if (MimeTypes.GIF.equals(mimeType)) { + metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class)); + } else if (MimeTypes.WEBP.equals(mimeType)) { + WebpDirectory webpDir = metadata.getFirstDirectoryOfType(WebpDirectory.class); + if (webpDir != null) { + if (webpDir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) { + metadataMap.put(KEY_IS_ANIMATED, webpDir.getBoolean(WebpDirectory.TAG_IS_ANIMATION)); + } + } + } } if (isVideo(mimeType)) { @@ -251,14 +268,14 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString); // some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time if (dateMillis > 0) { - metadataMap.put("dateMillis", dateMillis); + metadataMap.put(KEY_DATE_MILLIS, dateMillis); } } if (rotationString != null) { - metadataMap.put("videoRotation", Integer.parseInt(rotationString)); + metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString)); } if (locationString != null) { - Matcher locationMatcher = videoLocationPattern.matcher(locationString); + Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString); if (locationMatcher.find() && locationMatcher.groupCount() == 2) { String latitudeString = locationMatcher.group(1); String longitudeString = locationMatcher.group(2); @@ -267,8 +284,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { double latitude = Double.parseDouble(latitudeString); double longitude = Double.parseDouble(longitudeString); if (latitude != 0 && longitude != 0) { - metadataMap.put("latitude", latitude); - metadataMap.put("longitude", longitude); + metadataMap.put(KEY_LATITUDE, latitude); + metadataMap.put(KEY_LONGITUDE, longitude); } } catch (NumberFormatException ex) { // ignore @@ -297,7 +314,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { String path = call.argument("path"); String uri = call.argument("uri"); - Map metadataMap = new HashMap<>(); + Map metadataMap = new HashMap<>(); if (isVideo(mimeType)) { result.success(metadataMap); @@ -308,17 +325,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { Metadata metadata = ImageMetadataReader.readMetadata(is); ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); if (directory != null) { - if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { - metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { - metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); - } - if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { - metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); - } + putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER); + putStringFromTag(metadataMap, KEY_EXPOSURE_TIME, directory, ExifSubIFDDirectory.TAG_EXPOSURE_TIME); + putDescriptionFromTag(metadataMap, KEY_FOCAL_LENGTH, directory, ExifSubIFDDirectory.TAG_FOCAL_LENGTH); if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { - metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); + metadataMap.put(KEY_ISO, "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); } } result.success(metadataMap); @@ -330,4 +341,41 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { result.error("getOverlayMetadata-exception", "failed to get metadata for uri=" + uri + ", path=" + path, e.getMessage()); } } + + // convenience methods + + private static void putDateFromDirectoryTag(Map metadataMap, String key, Metadata metadata, Class dirClass, int tag) { + Directory dir = metadata.getFirstDirectoryOfType(dirClass); + if (dir != null) { + putDateFromTag(metadataMap, key, dir, tag); + } + } + + private static void putDateFromTag(Map metadataMap, String key, Directory dir, int tag) { + if (dir.containsTag(tag)) { + metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime()); + } + } + + private static void putDescriptionFromTag(Map metadataMap, String key, Directory dir, int tag) { + if (dir.containsTag(tag)) { + metadataMap.put(key, dir.getDescription(tag)); + } + } + + private static void putStringFromTag(Map metadataMap, String key, Directory dir, int tag) { + if (dir.containsTag(tag)) { + metadataMap.put(key, dir.getString(tag)); + } + } + + private static void putLocalizedTextFromXmp(Map metadataMap, String key, XMPMeta xmpMeta, String propName) throws XMPException { + if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, propName)) { + XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, propName, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG); + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) { + metadataMap.put(key, item.getValue()); + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java index 5285c967f..b2d4c1a46 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java @@ -8,6 +8,7 @@ public class MimeTypes { public static final String JPEG = "image/jpeg"; public static final String PNG = "image/png"; public static final String SVG = "image/svg+xml"; + public static final String WEBP = "image/webp"; public static final String VIDEO = "video"; public static final String AVI = "video/avi"; diff --git a/lib/main.dart b/lib/main.dart index 2f59bd02f..d1e119baa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,8 @@ import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/settings.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/utils/viewer_service.dart'; import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; diff --git a/lib/model/collection_lens.dart b/lib/model/collection_lens.dart index 32e9b1e48..cc500fb4a 100644 --- a/lib/model/collection_lens.dart +++ b/lib/model/collection_lens.dart @@ -162,10 +162,9 @@ class CollectionLens with ChangeNotifier { break; case SortFactor.name: final byAlbum = groupBy(_filteredEntries, (ImageEntry entry) => entry.directory); - final albums = byAlbum.keys.toSet(); final compare = (a, b) { - final ua = CollectionSource.getUniqueAlbumName(a, albums); - final ub = CollectionSource.getUniqueAlbumName(b, albums); + final ua = source.getUniqueAlbumName(a); + final ub = source.getUniqueAlbumName(b); return compareAsciiUpperCase(ua, ub); }; sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare)); diff --git a/lib/model/collection_source.dart b/lib/model/collection_source.dart index 50fcaa0f9..f5fa47e55 100644 --- a/lib/model/collection_source.dart +++ b/lib/model/collection_source.dart @@ -8,11 +8,12 @@ import 'package:path/path.dart'; class CollectionSource { final List _rawEntries; + final Set _folderPaths = {}; final EventBus _eventBus = EventBus(); List sortedAlbums = List.unmodifiable(const Iterable.empty()); - List sortedCities = List.unmodifiable(const Iterable.empty()); List sortedCountries = List.unmodifiable(const Iterable.empty()); + List sortedPlaces = List.unmodifiable(const Iterable.empty()); List sortedTags = List.unmodifiable(const Iterable.empty()); List get entries => List.unmodifiable(_rawEntries); @@ -106,11 +107,10 @@ class CollectionSource { } void updateAlbums() { - final albums = _rawEntries.map((entry) => entry.directory).toSet(); - final sorted = albums.toList() + final sorted = _folderPaths.toList() ..sort((a, b) { - final ua = getUniqueAlbumName(a, albums); - final ub = getUniqueAlbumName(b, albums); + final ua = getUniqueAlbumName(a); + final ub = getUniqueAlbumName(b); return compareAsciiUpperCase(ua, ub); }); sortedAlbums = List.unmodifiable(sorted); @@ -124,8 +124,8 @@ class CollectionSource { void updateLocations() { final locations = _rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails); final lister = (String Function(AddressDetails a) f) => List.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); - sortedCountries = lister((address) => address.countryName); - sortedCities = lister((address) => address.city); + sortedCountries = lister((address) => '${address.countryName};${address.countryCode}'); + sortedPlaces = lister((address) => address.place); } void addAll(Iterable entries) { @@ -134,6 +134,7 @@ class CollectionSource { entry.catalogDateMillis = savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis; }); _rawEntries.addAll(entries); + _folderPaths.addAll(_rawEntries.map((entry) => entry.directory).toSet()); eventBus.fire(const EntryAddedEvent()); } @@ -146,8 +147,8 @@ class CollectionSource { return success; } - static String getUniqueAlbumName(String album, Iterable albums) { - final otherAlbums = albums?.where((item) => item != album) ?? []; + String getUniqueAlbumName(String album) { + final otherAlbums = _folderPaths.where((item) => item != album); final parts = album.split(separator); var partCount = 0; String testName; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 1b4b17c62..454f11e64 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -1,8 +1,8 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; class FavouriteFilter extends CollectionFilter { static const type = 'favourite'; @@ -14,7 +14,7 @@ class FavouriteFilter extends CollectionFilter { String get label => 'Favourite'; @override - Widget iconBuilder(context, size) => Icon(OMIcons.favoriteBorder, size: size); + Widget iconBuilder(context, size) => Icon(AIcons.favourite, size: size); @override String get typeKey => type; diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 522d63b03..4bb063469 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -1,24 +1,33 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/widgets.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; class LocationFilter extends CollectionFilter { static const type = 'country'; final LocationLevel level; - final String location; + String _location; + String _countryCode; - const LocationFilter(this.level, this.location); + LocationFilter(this.level, this._location) { + final split = _location.split(';'); + if (split.isNotEmpty) _location = split[0]; + if (split.length > 1) _countryCode = split[1]; + } @override - bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == location) || (level == LocationLevel.city && entry.addressDetails.city == location)); + bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.place && entry.addressDetails.place == _location)); @override - String get label => location; + String get label => _location; @override - Widget iconBuilder(context, size) => Icon(OMIcons.place, size: size); + Widget iconBuilder(context, size) { + final flag = countryCodeToFlag(_countryCode); + if (flag != null) return Text(flag, style: TextStyle(fontSize: size)); + return Icon(AIcons.location, size: size); + } @override String get typeKey => type; @@ -26,11 +35,19 @@ class LocationFilter extends CollectionFilter { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is LocationFilter && other.location == location; + return other is LocationFilter && other._location == _location; } @override - int get hashCode => hashValues('LocationFilter', location); + int get hashCode => hashValues('LocationFilter', _location); + + // U+0041 Latin Capital letter A + // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A + static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041; + + static String countryCodeToFlag(String code) { + return code?.length == 2 ? String.fromCharCodes(code.codeUnits.map((letter) => letter += _countryCodeToFlagDiff)) : null; + } } -enum LocationLevel { city, country } +enum LocationLevel { place, country } diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index a94e0a797..8e8825f9a 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -1,12 +1,16 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/mime_types.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/widgets.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; class MimeFilter extends CollectionFilter { static const type = 'mime'; + // fake mime type + static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp` + final String mime; bool Function(ImageEntry) _filter; String _label; @@ -14,19 +18,21 @@ class MimeFilter extends CollectionFilter { MimeFilter(this.mime) { var lowMime = mime.toLowerCase(); - if (lowMime.endsWith('/*')) { + if (mime == animated) { + _filter = (entry) => entry.isAnimated; + _label = 'Animated'; + _icon = AIcons.animated; + } else if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); _filter = (entry) => entry.mimeType.startsWith(lowMime); if (lowMime == 'video') { _label = 'Video'; - _icon = OMIcons.movie; + _icon = AIcons.video; } _label ??= lowMime.split('/')[0].toUpperCase(); } else { _filter = (entry) => entry.mimeType == lowMime; - if (lowMime == MimeTypes.GIF) { - _icon = OMIcons.gif; - } else if (lowMime == MimeTypes.SVG) { + if (lowMime == MimeTypes.SVG) { _label = 'SVG'; } _label ??= lowMime.split('/')[1].toUpperCase(); diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index f9e5832cc..11a1ab9e4 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -6,13 +6,26 @@ import 'package:outline_material_icons/outline_material_icons.dart'; class QueryFilter extends CollectionFilter { static const type = 'query'; + static final exactRegex = RegExp('^"(.*)"\$'); + final String query; bool Function(ImageEntry) _filter; QueryFilter(this.query) { var upQuery = query.toUpperCase(); + + // allow NOT queries starting with `-` final not = upQuery.startsWith('-'); - if (not) upQuery = upQuery.substring(1); + if (not) { + upQuery = upQuery.substring(1); + } + + // allow untrimmed queries wrapped with `"..."` + final matches = exactRegex.allMatches(upQuery); + if (matches.length == 1) { + upQuery = matches.elementAt(0).group(1); + } + _filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index d3d473dcf..8e2bf02bf 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/widgets.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; class TagFilter extends CollectionFilter { static const type = 'tag'; @@ -20,7 +20,7 @@ class TagFilter extends CollectionFilter { String get label => tag; @override - Widget iconBuilder(context, size) => Icon(OMIcons.localOffer, size: size); + Widget iconBuilder(context, size) => Icon(AIcons.tag, size: size); @override String get typeKey => type; diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index d6c1b9393..ae1ad8b62 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -1,7 +1,8 @@ import 'package:aves/model/favourite_repo.dart'; -import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/image_metadata.dart'; -import 'package:aves/model/metadata_service.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/service_policy.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:flutter/foundation.dart'; @@ -106,14 +107,17 @@ class ImageEntry { bool get isFavourite => favourites.isFavourite(this); - bool get isGif => mimeType == MimeTypes.GIF; - bool get isSvg => mimeType == MimeTypes.SVG; + // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels) + bool get isPhoto => [MimeTypes.HEIC, MimeTypes.HEIF, MimeTypes.JPEG].contains(mimeType); + bool get isVideo => mimeType.startsWith('video'); bool get isCatalogued => _catalogMetadata != null; + bool get isAnimated => _catalogMetadata?.isAnimated ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; @@ -217,12 +221,17 @@ class ImageEntry { final coordinates = Coordinates(latitude, longitude); try { - final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates); + final addresses = await servicePolicy.call( + () => Geocoder.local.findAddressesFromCoordinates(coordinates), + priority: ServiceCallPriority.background, + debugLabel: 'findAddressesFromCoordinates-$path', + ); if (addresses != null && addresses.isNotEmpty) { final address = addresses.first; addressDetails = AddressDetails( contentId: contentId, addressLine: address.addressLine, + countryCode: address.countryCode, countryName: address.countryName, adminArea: address.adminArea, locality: address.locality, diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 81f7dd9c6..db9489ba7 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -29,6 +29,7 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis, videoRotation; + final bool isAnimated; final String xmpSubjects, xmpTitleDescription; final double latitude, longitude; Address address; @@ -36,6 +37,7 @@ class CatalogMetadata { CatalogMetadata({ this.contentId, this.dateMillis, + this.isAnimated, this.videoRotation, this.xmpSubjects, this.xmpTitleDescription, @@ -46,10 +48,12 @@ class CatalogMetadata { : latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; - factory CatalogMetadata.fromMap(Map map) { + factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { + final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); return CatalogMetadata( contentId: map['contentId'], dateMillis: map['dateMillis'] ?? 0, + isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, videoRotation: map['videoRotation'] ?? 0, xmpSubjects: map['xmpSubjects'] ?? '', xmpTitleDescription: map['xmpTitleDescription'] ?? '', @@ -58,9 +62,10 @@ class CatalogMetadata { ); } - Map toMap() => { + Map toMap({bool boolAsInteger = false}) => { 'contentId': contentId, 'dateMillis': dateMillis, + 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'videoRotation': videoRotation, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, @@ -70,7 +75,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } @@ -103,13 +108,14 @@ class OverlayMetadata { class AddressDetails { final int contentId; - final String addressLine, countryName, adminArea, locality; + final String addressLine, countryCode, countryName, adminArea, locality; - String get city => locality != null && locality.isNotEmpty ? locality : adminArea; + String get place => locality != null && locality.isNotEmpty ? locality : adminArea; AddressDetails({ this.contentId, this.addressLine, + this.countryCode, this.countryName, this.adminArea, this.locality, @@ -119,6 +125,7 @@ class AddressDetails { return AddressDetails( contentId: map['contentId'], addressLine: map['addressLine'] ?? '', + countryCode: map['countryCode'] ?? '', countryName: map['countryName'] ?? '', adminArea: map['adminArea'] ?? '', locality: map['locality'] ?? '', @@ -128,6 +135,7 @@ class AddressDetails { Map toMap() => { 'contentId': contentId, 'addressLine': addressLine, + 'countryCode': countryCode, 'countryName': countryName, 'adminArea': adminArea, 'locality': locality, @@ -135,7 +143,7 @@ class AddressDetails { @override String toString() { - return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; + return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index d65e6e845..92994fe76 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -24,10 +24,32 @@ class MetadataDb { _database = openDatabase( await path, onCreate: (db, version) async { - await db.execute('CREATE TABLE $dateTakenTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER)'); - await db.execute('CREATE TABLE $metadataTable(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, videoRotation INTEGER, xmpSubjects TEXT, xmpTitleDescription TEXT, latitude REAL, longitude REAL)'); - await db.execute('CREATE TABLE $addressTable(contentId INTEGER PRIMARY KEY, addressLine TEXT, countryName TEXT, adminArea TEXT, locality TEXT)'); - await db.execute('CREATE TABLE $favouriteTable(contentId INTEGER PRIMARY KEY, path TEXT)'); + await db.execute('CREATE TABLE $dateTakenTable(' + 'contentId INTEGER PRIMARY KEY' + ', dateMillis INTEGER' + ')'); + await db.execute('CREATE TABLE $metadataTable(' + 'contentId INTEGER PRIMARY KEY' + ', dateMillis INTEGER' + ', isAnimated INTEGER' + ', videoRotation INTEGER' + ', xmpSubjects TEXT' + ', xmpTitleDescription TEXT' + ', latitude REAL' + ', longitude REAL' + ')'); + await db.execute('CREATE TABLE $addressTable(' + 'contentId INTEGER PRIMARY KEY' + ', addressLine TEXT' + ', countryCode TEXT' + ', countryName TEXT' + ', adminArea TEXT' + ', locality TEXT' + ')'); + await db.execute('CREATE TABLE $favouriteTable(' + 'contentId INTEGER PRIMARY KEY' + ', path TEXT' + ')'); }, version: 1, ); @@ -74,7 +96,7 @@ class MetadataDb { // final stopwatch = Stopwatch()..start(); final db = await _database; final maps = await db.query(metadataTable); - final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); + final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map, boolAsInteger: true)).toList(); // debugPrint('$runtimeType loadMetadataEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); return metadataEntries; } @@ -94,7 +116,7 @@ class MetadataDb { } batch.insert( metadataTable, - metadata.toMap(), + metadata.toMap(boolAsInteger: true), conflictAlgorithm: ConflictAlgorithm.replace, ); }); diff --git a/lib/model/mime_types.dart b/lib/model/mime_types.dart index eb2d2ed7b..f84af8658 100644 --- a/lib/model/mime_types.dart +++ b/lib/model/mime_types.dart @@ -1,8 +1,12 @@ class MimeTypes { + static const String ANY_IMAGE = 'image/*'; static const String GIF = 'image/gif'; + static const String HEIC = 'image/heic'; + static const String HEIF = 'image/heif'; static const String JPEG = 'image/jpeg'; static const String PNG = 'image/png'; static const String SVG = 'image/svg+xml'; + static const String WEBP = 'image/webp'; static const String ANY_VIDEO = 'video/*'; static const String AVI = 'video/avi'; diff --git a/lib/utils/android_app_service.dart b/lib/services/android_app_service.dart similarity index 100% rename from lib/utils/android_app_service.dart rename to lib/services/android_app_service.dart diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart new file mode 100644 index 000000000..f43cc7e9b --- /dev/null +++ b/lib/services/android_file_service.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class AndroidFileService { + static const platform = MethodChannel('deckers.thibault/aves/file'); + + static Future> getStorageVolumes() async { + try { + final result = await platform.invokeMethod('getStorageVolumes'); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return []; + } +} diff --git a/lib/model/image_file_service.dart b/lib/services/image_file_service.dart similarity index 79% rename from lib/model/image_file_service.dart rename to lib/services/image_file_service.dart index 41f528b2b..d26610f5d 100644 --- a/lib/model/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -42,31 +43,28 @@ class ImageFileService { return Uint8List(0); } - static Future getThumbnail(ImageEntry entry, int width, int height) async { - if (width > 0 && height > 0) { + static Future getThumbnail(ImageEntry entry, int width, int height, {Object cancellationKey}) { + return servicePolicy.call( + () async { + if (width > 0 && height > 0) { // debugPrint('getThumbnail width=$width path=${entry.path}'); - try { - final result = await platform.invokeMethod('getThumbnail', { - 'entry': entry.toMap(), - 'width': width, - 'height': height, - }); - return result as Uint8List; - } on PlatformException catch (e) { - debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - } - return Uint8List(0); - } - - static Future cancelGetThumbnail(String uri) async { - try { - await platform.invokeMethod('cancelGetThumbnail', { - 'uri': uri, - }); - } on PlatformException catch (e) { - debugPrint('cancelGetThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } + try { + final result = await platform.invokeMethod('getThumbnail', { + 'entry': entry.toMap(), + 'width': width, + 'height': height, + }); + return result as Uint8List; + } on PlatformException catch (e) { + debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + } + return Uint8List(0); + }, + priority: ServiceCallPriority.asap, + debugLabel: 'getThumbnail-${entry.path}', + cancellationKey: cancellationKey, + ); } static Future delete(ImageEntry entry) async { diff --git a/lib/model/metadata_service.dart b/lib/services/metadata_service.dart similarity index 58% rename from lib/model/metadata_service.dart rename to lib/services/metadata_service.dart index c37c03447..f63b2b94e 100644 --- a/lib/model/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -1,5 +1,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; +import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -26,24 +27,32 @@ class MetadataService { static Future getCatalogMetadata(ImageEntry entry) async { if (entry.isSvg) return null; - try { - // return map with: - // 'dateMillis': date taken in milliseconds since Epoch (long) - // 'latitude': latitude (double) - // 'longitude': longitude (double) - // 'xmpSubjects': ';' separated XMP subjects (string) - // 'xmpTitleDescription': XMP title or XMP description (string) - final result = await platform.invokeMethod('getCatalogMetadata', { - 'mimeType': entry.mimeType, - 'path': entry.path, - 'uri': entry.uri, - }) as Map; - result['contentId'] = entry.contentId; - return CatalogMetadata.fromMap(result); - } on PlatformException catch (e) { - debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return null; + return servicePolicy.call( + () async { + try { + // return map with: + // 'dateMillis': date taken in milliseconds since Epoch (long) + // 'isAnimated': animated gif/webp (bool) + // 'latitude': latitude (double) + // 'longitude': longitude (double) + // 'videoRotation': video rotation degrees (int) + // 'xmpSubjects': ';' separated XMP subjects (string) + // 'xmpTitleDescription': XMP title or XMP description (string) + final result = await platform.invokeMethod('getCatalogMetadata', { + 'mimeType': entry.mimeType, + 'path': entry.path, + 'uri': entry.uri, + }) as Map; + result['contentId'] = entry.contentId; + return CatalogMetadata.fromMap(result); + } on PlatformException catch (e) { + debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + }, + priority: ServiceCallPriority.background, + debugLabel: 'getCatalogMetadata-${entry.path}', + ); } static Future getOverlayMetadata(ImageEntry entry) async { diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart new file mode 100644 index 000000000..fb851b44d --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +final ServicePolicy servicePolicy = ServicePolicy._private(); + +class ServicePolicy { + final Queue<_Task> _asapQueue, _normalQueue, _backgroundQueue; + List> _queues; + _Task _running; + + ServicePolicy._private() + : _asapQueue = Queue(), + _normalQueue = Queue(), + _backgroundQueue = Queue() { + _queues = [_asapQueue, _normalQueue, _backgroundQueue]; + } + + Future call( + Future Function() platformCall, { + ServiceCallPriority priority = ServiceCallPriority.normal, + String debugLabel, + Object cancellationKey, + }) { + Queue<_Task> queue; + switch (priority) { + case ServiceCallPriority.asap: + queue = _asapQueue; + break; + case ServiceCallPriority.background: + queue = _backgroundQueue; + break; + case ServiceCallPriority.normal: + default: + queue = _normalQueue; + break; + } + final completer = Completer(); + final wrapped = _Task( + () async { +// if (debugLabel != null) debugPrint('$runtimeType $debugLabel start'); + final result = await platformCall(); + completer.complete(result); +// if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed'); + _running = null; + _pickNext(); + }, + completer, + cancellationKey, + ); + queue.addLast(wrapped); + + _pickNext(); + return completer.future; + } + + void _pickNext() { + if (_running != null) return; + final queue = _queues.firstWhere((q) => q.isNotEmpty, orElse: () => null); + _running = queue?.removeFirst(); + _running?.callback?.call(); + } + + bool cancel(Object cancellationKey) { + var cancelled = false; + final tasks = _queues.expand((q) => q.where((task) => task.cancellationKey == cancellationKey)).toList(); + tasks.forEach((task) => _queues.forEach((q) { + if (q.remove(task)) { + cancelled = true; + task.completer.completeError(CancelledException()); + } + })); + return cancelled; + } +} + +class _Task { + final VoidCallback callback; + final Completer completer; + final Object cancellationKey; + + const _Task(this.callback, this.completer, this.cancellationKey); +} + +class CancelledException {} + +enum ServiceCallPriority { asap, normal, background } diff --git a/lib/utils/viewer_service.dart b/lib/services/viewer_service.dart similarity index 100% rename from lib/utils/viewer_service.dart rename to lib/services/viewer_service.dart diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 28871ddb9..c94cb7149 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,4 +1,5 @@ -import 'package:aves/utils/android_app_service.dart'; +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/android_file_service.dart'; import 'package:path/path.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); @@ -6,11 +7,13 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { String externalStorage, dcimPath, downloadPath, moviesPath, picturesPath; + static List storageVolumes = []; static Map appNameMap = {}; AndroidFileUtils._private(); Future init() async { + storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList(); // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' externalStorage = '/storage/emulated/0'; dcimPath = join(externalStorage, 'DCIM'); @@ -29,6 +32,10 @@ class AndroidFileUtils { bool isDownloadPath(String path) => path == downloadPath; + StorageVolume getStorageVolume(String path) => storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null); + + bool isOnSD(String path) => getStorageVolume(path).isRemovable; + AlbumType getAlbumType(String albumDirectory) { if (albumDirectory != null) { if (androidFileUtils.isCameraPath(albumDirectory)) return AlbumType.Camera; @@ -56,3 +63,28 @@ enum AlbumType { ScreenRecordings, Screenshots, } + +class StorageVolume { + final String description, path, state; + final bool isEmulated, isPrimary, isRemovable; + + const StorageVolume({ + this.description, + this.isEmulated, + this.isPrimary, + this.isRemovable, + this.path, + this.state, + }); + + factory StorageVolume.fromMap(Map map) { + return StorageVolume( + description: map['description'] ?? '', + isEmulated: map['isEmulated'] ?? false, + isPrimary: map['isPrimary'] ?? false, + isRemovable: map['isRemovable'] ?? false, + path: map['path'] ?? '', + state: map['string'] ?? '', + ); + } +} diff --git a/lib/widgets/album/collection_app_bar.dart b/lib/widgets/album/app_bar.dart similarity index 98% rename from lib/widgets/album/collection_app_bar.dart rename to lib/widgets/album/app_bar.dart index 8acd3f2e1..4edee321f 100644 --- a/lib/widgets/album/collection_app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -290,9 +290,9 @@ class SearchField extends StatelessWidget { ), autofocus: true, onSubmitted: (query) { - query = query.trim(); - if (query.isNotEmpty) { - collection.addFilter(QueryFilter(query)); + final cleanQuery = query.trim(); + if (cleanQuery.isNotEmpty) { + collection.addFilter(QueryFilter(cleanQuery)); } stateNotifier.value = PageState.browse; }, diff --git a/lib/widgets/album/collection_drawer.dart b/lib/widgets/album/collection_drawer.dart index f2ee703a6..b60dda7af 100644 --- a/lib/widgets/album/collection_drawer.dart +++ b/lib/widgets/album/collection_drawer.dart @@ -5,8 +5,8 @@ import 'package:aves/model/collection_source.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/mime_types.dart'; import 'package:aves/model/settings.dart'; @@ -31,7 +31,7 @@ class CollectionDrawer extends StatefulWidget { } class _CollectionDrawerState extends State { - bool _albumsExpanded = false, _citiesExpanded = false, _countriesExpanded = false, _tagsExpanded = false; + bool _albumsExpanded = false, _placesExpanded = false, _countriesExpanded = false, _tagsExpanded = false; CollectionSource get source => widget.source; @@ -77,51 +77,79 @@ class _CollectionDrawerState extends State { ); final videoEntry = _FilteredCollectionNavTile( source: source, - leading: const Icon(OMIcons.movie), + leading: const Icon(AIcons.video), title: 'Videos', filter: MimeFilter(MimeTypes.ANY_VIDEO), ); - final gifEntry = _FilteredCollectionNavTile( + final animatedEntry = _FilteredCollectionNavTile( source: source, - leading: const Icon(OMIcons.gif), - title: 'GIFs', - filter: MimeFilter(MimeTypes.GIF), + leading: const Icon(AIcons.animated), + title: 'Animated', + filter: MimeFilter(MimeFilter.animated), ); final favouriteEntry = _FilteredCollectionNavTile( source: source, - leading: const Icon(OMIcons.favoriteBorder), + leading: const Icon(AIcons.favourite), title: 'Favourites', filter: FavouriteFilter(), ); - final buildAlbumEntry = (album) => _FilteredCollectionNavTile( - source: source, - leading: IconUtils.getAlbumIcon(context: context, album: album), - title: CollectionSource.getUniqueAlbumName(album, source.sortedAlbums), - dense: true, - filter: AlbumFilter(album, CollectionSource.getUniqueAlbumName(album, source.sortedAlbums)), - ); - final buildTagEntry = (tag) => _FilteredCollectionNavTile( + final buildAlbumEntry = (String album) { + final uniqueName = source.getUniqueAlbumName(album); + return _FilteredCollectionNavTile( + source: source, + leading: IconUtils.getAlbumIcon(context: context, album: album), + title: uniqueName, + trailing: androidFileUtils.isOnSD(album) + ? const Icon( + OMIcons.sdStorage, + size: 16, + color: Colors.grey, + ) + : null, + dense: true, + filter: AlbumFilter(album, uniqueName), + ); + }; + final buildTagEntry = (String tag) => _FilteredCollectionNavTile( source: source, leading: Icon( - OMIcons.localOffer, + AIcons.tag, color: stringToColor(tag), ), title: tag, dense: true, filter: TagFilter(tag), ); - final buildLocationEntry = (level, location) => _FilteredCollectionNavTile( - source: source, - leading: Icon( - OMIcons.place, - color: stringToColor(location), - ), - title: location, - dense: true, - filter: LocationFilter(level, location), - ); + final buildLocationEntry = (LocationLevel level, String location) { + String title; + String flag; + if (level == LocationLevel.country) { + final split = location.split(';'); + String countryCode; + if (split.isNotEmpty) title = split[0]; + if (split.length > 1) countryCode = split[1]; + flag = LocationFilter.countryCodeToFlag(countryCode); + } else { + title = location; + } + return _FilteredCollectionNavTile( + source: source, + leading: flag != null + ? Text( + flag, + style: TextStyle(fontSize: IconTheme.of(context).size), + ) + : Icon( + AIcons.location, + color: stringToColor(title), + ), + title: title, + dense: true, + filter: LocationFilter(level, location), + ); + }; - final regularAlbums = [], appAlbums = [], specialAlbums = []; + final regularAlbums = [], appAlbums = [], specialAlbums = []; for (var album in source.sortedAlbums) { switch (androidFileUtils.getAlbumType(album)) { case AlbumType.Default: @@ -135,15 +163,15 @@ class _CollectionDrawerState extends State { break; } } - final cities = source.sortedCities; final countries = source.sortedCountries; + final places = source.sortedPlaces; final tags = source.sortedTags; final drawerItems = [ header, allMediaEntry, videoEntry, - gifEntry, + animatedEntry, favouriteEntry, if (specialAlbums.isNotEmpty) ...[ const Divider(), @@ -175,34 +203,12 @@ class _CollectionDrawerState extends State { ], ), ), - if (cities.isNotEmpty) - SafeArea( - top: false, - bottom: false, - child: ExpansionTile( - leading: const Icon(OMIcons.place), - title: Row( - children: [ - const Text('Cities'), - const Spacer(), - Text( - '${cities.length}', - style: TextStyle( - color: (_citiesExpanded ? Theme.of(context).accentColor : Colors.white).withOpacity(.6), - ), - ), - ], - ), - onExpansionChanged: (expanded) => setState(() => _citiesExpanded = expanded), - children: cities.map((s) => buildLocationEntry(LocationLevel.city, s)).toList(), - ), - ), if (countries.isNotEmpty) SafeArea( top: false, bottom: false, child: ExpansionTile( - leading: const Icon(OMIcons.place), + leading: const Icon(AIcons.location), title: Row( children: [ const Text('Countries'), @@ -219,12 +225,34 @@ class _CollectionDrawerState extends State { children: countries.map((s) => buildLocationEntry(LocationLevel.country, s)).toList(), ), ), + if (places.isNotEmpty) + SafeArea( + top: false, + bottom: false, + child: ExpansionTile( + leading: const Icon(AIcons.location), + title: Row( + children: [ + const Text('Places'), + const Spacer(), + Text( + '${places.length}', + style: TextStyle( + color: (_placesExpanded ? Theme.of(context).accentColor : Colors.white).withOpacity(.6), + ), + ), + ], + ), + onExpansionChanged: (expanded) => setState(() => _placesExpanded = expanded), + children: places.map((s) => buildLocationEntry(LocationLevel.place, s)).toList(), + ), + ), if (tags.isNotEmpty) SafeArea( top: false, bottom: false, child: ExpansionTile( - leading: const Icon(OMIcons.localOffer), + leading: const Icon(AIcons.tag), title: Row( children: [ const Text('Tags'), @@ -293,6 +321,7 @@ class _FilteredCollectionNavTile extends StatelessWidget { final CollectionSource source; final Widget leading; final String title; + final Widget trailing; final bool dense; final CollectionFilter filter; @@ -300,6 +329,7 @@ class _FilteredCollectionNavTile extends StatelessWidget { @required this.source, @required this.leading, @required this.title, + this.trailing, bool dense, @required this.filter, }) : dense = dense ?? false; @@ -312,6 +342,7 @@ class _FilteredCollectionNavTile extends StatelessWidget { child: ListTile( leading: leading, title: Text(title), + trailing: trailing, dense: dense, onTap: () => _goToCollection(context), ), diff --git a/lib/widgets/album/collection_section.dart b/lib/widgets/album/collection_section.dart deleted file mode 100644 index db079e153..000000000 --- a/lib/widgets/album/collection_section.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:aves/model/collection_lens.dart'; -import 'package:aves/model/collection_source.dart'; -import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/album/sections.dart'; -import 'package:aves/widgets/album/thumbnail.dart'; -import 'package:aves/widgets/album/transparent_material_page_route.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_sticky_header/flutter_sticky_header.dart'; - -class SectionSliver extends StatelessWidget { - final CollectionLens collection; - final dynamic sectionKey; - final double tileExtent; - final bool showHeader; - - const SectionSliver({ - Key key, - @required this.collection, - @required this.sectionKey, - @required this.tileExtent, - @required this.showHeader, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final sections = collection.sections; - final sectionEntries = sections[sectionKey]; - final childCount = sectionEntries.length; - - final sliver = SliverGrid( - delegate: SliverChildBuilderDelegate( - // TODO TLAD thumbnails at the beginning of each sections are built even though they are offscreen - // because of `RenderSliverMultiBoxAdaptor.addInitialChild` - // called by `RenderSliverGrid.performLayout` (line 547) - (context, index) => index < childCount - ? GridThumbnail( - collection: collection, - index: index, - entry: sectionEntries[index], - tileExtent: tileExtent, - ) - : null, - childCount: childCount, - addAutomaticKeepAlives: false, - ), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: tileExtent, - ), - ); - - return showHeader - ? SliverStickyHeader( - header: SectionHeader( - collection: collection, - sections: sections, - sectionKey: sectionKey, - ), - sliver: sliver, - overlapsContent: false, - ) - : sliver; - } -} - -class GridThumbnail extends StatelessWidget { - final CollectionLens collection; - final int index; - final ImageEntry entry; - final double tileExtent; - final GestureTapCallback onTap; - - const GridThumbnail({ - Key key, - this.collection, - this.index, - this.entry, - this.tileExtent, - this.onTap, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: ValueKey(entry.uri), - onTap: () => _goToFullscreen(context), - child: MetaData( - metaData: ThumbnailMetadata(index, entry), - child: Thumbnail( - entry: entry, - extent: tileExtent, - heroTag: collection.heroTag(entry), - ), - ), - ); - } - - void _goToFullscreen(BuildContext context) { - Navigator.push( - context, - TransparentMaterialPageRoute( - pageBuilder: (c, a, sa) => MultiFullscreenPage( - collection: collection, - initialEntry: entry, - ), - ), - ); - } -} - -// metadata to identify entry from RenderObject hit test during collection scaling -class ThumbnailMetadata { - final int index; - final ImageEntry entry; - - const ThumbnailMetadata(this.index, this.entry); -} - -class SectionHeader extends StatelessWidget { - final CollectionLens collection; - final Map> sections; - final dynamic sectionKey; - - const SectionHeader({ - Key key, - @required this.collection, - @required this.sections, - @required this.sectionKey, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - Widget header; - switch (collection.sortFactor) { - case SortFactor.date: - if (collection.sortFactor == SortFactor.date) { - switch (collection.groupFactor) { - case GroupFactor.album: - header = _buildAlbumSectionHeader(context); - break; - case GroupFactor.month: - header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime); - break; - case GroupFactor.day: - header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime); - break; - } - } - break; - case SortFactor.size: - break; - case SortFactor.name: - header = _buildAlbumSectionHeader(context); - break; - } - return header != null - ? IgnorePointer( - child: header, - ) - : const SizedBox.shrink(); - } - - Widget _buildAlbumSectionHeader(BuildContext context) { - var albumIcon = IconUtils.getAlbumIcon(context: context, album: sectionKey as String); - if (albumIcon != null) { - albumIcon = Material( - type: MaterialType.circle, - elevation: 3, - color: Colors.transparent, - shadowColor: Colors.black, - child: albumIcon, - ); - } - final title = CollectionSource.getUniqueAlbumName(sectionKey as String, sections.keys.cast()); - return TitleSectionHeader( - key: ValueKey(title), - leading: albumIcon, - title: title, - ); - } -} diff --git a/lib/widgets/album/grid/header_album.dart b/lib/widgets/album/grid/header_album.dart new file mode 100644 index 000000000..30879da43 --- /dev/null +++ b/lib/widgets/album/grid/header_album.dart @@ -0,0 +1,40 @@ +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/album/grid/header_generic.dart'; +import 'package:aves/widgets/common/icons.dart'; +import 'package:flutter/material.dart'; +import 'package:outline_material_icons/outline_material_icons.dart'; + +class AlbumSectionHeader extends StatelessWidget { + final String folderPath, albumName; + + const AlbumSectionHeader({ + Key key, + @required this.folderPath, + @required this.albumName, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var albumIcon = IconUtils.getAlbumIcon(context: context, album: folderPath); + if (albumIcon != null) { + albumIcon = Material( + type: MaterialType.circle, + elevation: 3, + color: Colors.transparent, + shadowColor: Colors.black, + child: albumIcon, + ); + } + return TitleSectionHeader( + leading: albumIcon, + title: albumName, + trailing: androidFileUtils.isOnSD(folderPath) + ? const Icon( + OMIcons.sdStorage, + size: 16, + color: Color(0xFF757575), + ) + : null, + ); + } +} diff --git a/lib/widgets/album/sections.dart b/lib/widgets/album/grid/header_date.dart similarity index 56% rename from lib/widgets/album/sections.dart rename to lib/widgets/album/grid/header_date.dart index d90234f88..6fcac92c4 100644 --- a/lib/widgets/album/sections.dart +++ b/lib/widgets/album/grid/header_date.dart @@ -1,6 +1,5 @@ -import 'package:aves/utils/constants.dart'; import 'package:aves/utils/time_utils.dart'; -import 'package:aves/widgets/common/fx/outlined_text.dart'; +import 'package:aves/widgets/album/grid/header_generic.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -48,33 +47,3 @@ class MonthSectionHeader extends StatelessWidget { return TitleSectionHeader(title: text); } } - -class TitleSectionHeader extends StatelessWidget { - final Widget leading; - final String title; - - const TitleSectionHeader({Key key, this.leading, this.title}) : super(key: key); - - static const leadingDimension = 32.0; - static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: OutlinedText( - leadingBuilder: leading != null - ? (context, isShadow) => Container( - padding: leadingPadding, - width: leadingDimension, - height: leadingDimension, - child: isShadow ? null : leading, - ) - : null, - text: title, - style: Constants.titleTextStyle, - outlineWidth: 2, - ), - ); - } -} diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/album/grid/header_generic.dart new file mode 100644 index 000000000..64f25ae0e --- /dev/null +++ b/lib/widgets/album/grid/header_generic.dart @@ -0,0 +1,140 @@ +import 'dart:math'; + +import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/collection_source.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/album/grid/header_album.dart'; +import 'package:aves/widgets/album/grid/header_date.dart'; +import 'package:aves/widgets/common/fx/outlined_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class SectionHeader extends StatelessWidget { + final CollectionLens collection; + final dynamic sectionKey; + + const SectionHeader({ + Key key, + @required this.collection, + @required this.sectionKey, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + Widget header; + switch (collection.sortFactor) { + case SortFactor.date: + if (collection.sortFactor == SortFactor.date) { + switch (collection.groupFactor) { + case GroupFactor.album: + header = _buildAlbumSectionHeader(); + break; + case GroupFactor.month: + header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime); + break; + case GroupFactor.day: + header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime); + break; + } + } + break; + case SortFactor.size: + break; + case SortFactor.name: + header = _buildAlbumSectionHeader(); + break; + } + return header != null + ? IgnorePointer( + child: header, + ) + : const SizedBox.shrink(); + } + + Widget _buildAlbumSectionHeader() { + final folderPath = sectionKey as String; + return AlbumSectionHeader( + key: ValueKey(folderPath), + folderPath: folderPath, + albumName: collection.source.getUniqueAlbumName(folderPath), + ); + } + + // TODO TLAD cache header extent computation? + static double computeHeaderHeight(CollectionSource source, dynamic sectionKey, double scrollableWidth) { + var headerExtent = 0.0; + if (sectionKey is String) { + // only compute height for album headers, as they're the only likely ones to split on multiple lines + final hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.Default; + final hasTrailing = androidFileUtils.isOnSD(sectionKey); + final text = source.getUniqueAlbumName(sectionKey); + final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal; + final para = RenderParagraph( + TextSpan( + children: [ + if (hasLeading) + // `RenderParagraph` fails to lay out `WidgetSpan` offscreen as of Flutter v1.17.0 + // so we use a hair space times a magic number to match leading width + TextSpan(text: '\u200A' * 23), // 23 hair spaces match a width of 40.0 + if (hasTrailing) + TextSpan(text: '\u200A' * 17), + TextSpan( + text: text, + style: Constants.titleTextStyle, + ), + ], + ), + textDirection: TextDirection.ltr, + )..layout(BoxConstraints(maxWidth: maxWidth), parentUsesSize: true); + headerExtent = para.getMaxIntrinsicHeight(maxWidth); + } + headerExtent = max(headerExtent, TitleSectionHeader.leadingDimension) + TitleSectionHeader.padding.vertical; + return headerExtent; + } +} + +class TitleSectionHeader extends StatelessWidget { + final Widget leading, trailing; + final String title; + + const TitleSectionHeader({ + Key key, + this.leading, + this.title, + this.trailing, + }) : super(key: key); + + static const leadingDimension = 32.0; + static const leadingPadding = EdgeInsets.only(right: 8, bottom: 4); + static const trailingPadding = EdgeInsets.only(left: 8, bottom: 4); + static const padding = EdgeInsets.all(16); + + @override + Widget build(BuildContext context) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: padding, + constraints: const BoxConstraints(minHeight: leadingDimension), + child: OutlinedText( + leadingBuilder: leading != null + ? (context, isShadow) => Container( + padding: leadingPadding, + width: leadingDimension, + height: leadingDimension, + child: isShadow ? null : leading, + ) + : null, + text: title, + trailingBuilder: trailing != null + ? (context, isShadow) => Container( + padding: trailingPadding, + child: isShadow ? null : trailing, + ) + : null, + style: Constants.titleTextStyle, + outlineWidth: 2, + ), + ); + } +} diff --git a/lib/widgets/album/grid/list_known_extent.dart b/lib/widgets/album/grid/list_known_extent.dart new file mode 100644 index 000000000..21b75b769 --- /dev/null +++ b/lib/widgets/album/grid/list_known_extent.dart @@ -0,0 +1,252 @@ +import 'dart:math' as math; + +import 'package:aves/widgets/album/grid/list_section_layout.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +class SliverKnownExtentList extends SliverMultiBoxAdaptorWidget { + final List sectionLayouts; + + const SliverKnownExtentList({ + Key key, + @required SliverChildDelegate delegate, + @required this.sectionLayouts, + }) : super(key: key, delegate: delegate); + + @override + RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) { + final element = context as SliverMultiBoxAdaptorElement; + return RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts); + } + + @override + void updateRenderObject(BuildContext context, RenderSliverKnownExtentBoxAdaptor renderObject) { + renderObject.sectionLayouts = sectionLayouts; + } +} + +class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { + List _sectionLayouts; + + List get sectionLayouts => _sectionLayouts; + + set sectionLayouts(List value) { + assert(value != null); + if (_sectionLayouts == value) return; + _sectionLayouts = value; + markNeedsLayout(); + } + + RenderSliverKnownExtentBoxAdaptor({ + @required RenderSliverBoxChildManager childManager, + @required List sectionLayouts, + }) : _sectionLayouts = sectionLayouts, + super(childManager: childManager); + + SectionLayout sectionAtIndex(int index) => sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null); + + SectionLayout sectionAtOffset(double scrollOffset) => sectionLayouts.firstWhere((section) => section.hasChildAtOffset(scrollOffset), orElse: () => null); + + double indexToLayoutOffset(int index) { + return (sectionAtIndex(index) ?? sectionLayouts.last).indexToLayoutOffset(index); + } + + int getMinChildIndexForScrollOffset(double scrollOffset) { + return sectionAtOffset(scrollOffset)?.getMinChildIndexForScrollOffset(scrollOffset) ?? 0; + } + + int getMaxChildIndexForScrollOffset(double scrollOffset) { + return (sectionAtOffset(scrollOffset) ?? sectionLayouts.last).getMaxChildIndexForScrollOffset(scrollOffset); + } + + double estimateMaxScrollOffset( + SliverConstraints constraints, { + int firstIndex, + int lastIndex, + double leadingScrollOffset, + double trailingScrollOffset, + }) { + return childManager.estimateMaxScrollOffset( + constraints, + firstIndex: firstIndex, + lastIndex: lastIndex, + leadingScrollOffset: leadingScrollOffset, + trailingScrollOffset: trailingScrollOffset, + ); + } + + double computeMaxScrollOffset(SliverConstraints constraints) { + return sectionLayouts.last.maxOffset; + } + + int _calculateLeadingGarbage(int firstIndex) { + var walker = firstChild; + var leadingGarbage = 0; + while (walker != null && indexOf(walker) < firstIndex) { + leadingGarbage += 1; + walker = childAfter(walker); + } + return leadingGarbage; + } + + int _calculateTrailingGarbage(int targetLastIndex) { + var walker = lastChild; + var trailingGarbage = 0; + while (walker != null && indexOf(walker) > targetLastIndex) { + trailingGarbage += 1; + walker = childBefore(walker); + } + return trailingGarbage; + } + + @override + void performLayout() { + final constraints = this.constraints; + childManager.didStartLayout(); + childManager.setDidUnderflow(false); + + final scrollOffset = constraints.scrollOffset + constraints.cacheOrigin; + assert(scrollOffset >= 0.0); + final remainingExtent = constraints.remainingCacheExtent; + assert(remainingExtent >= 0.0); + final targetEndScrollOffset = scrollOffset + remainingExtent; + + final childConstraints = constraints.asBoxConstraints(); + + final firstIndex = getMinChildIndexForScrollOffset(scrollOffset); + final targetLastIndex = targetEndScrollOffset.isFinite ? getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null; + + if (firstChild != null) { + final leadingGarbage = _calculateLeadingGarbage(firstIndex); + final trailingGarbage = _calculateTrailingGarbage(targetLastIndex); + collectGarbage(leadingGarbage, trailingGarbage); + } else { + collectGarbage(0, 0); + } + + if (firstChild == null) { + if (!addInitialChild(index: firstIndex, layoutOffset: indexToLayoutOffset(firstIndex))) { + // There are either no children, or we are past the end of all our children. + // If it is the latter, we will need to find the first available child. + double max; + if (childManager.childCount != null) { + max = computeMaxScrollOffset(constraints); + } else if (firstIndex <= 0) { + max = 0.0; + } else { + // We will have to find it manually. + var possibleFirstIndex = firstIndex - 1; + while (possibleFirstIndex > 0 && + !addInitialChild( + index: possibleFirstIndex, + layoutOffset: indexToLayoutOffset(possibleFirstIndex), + )) { + possibleFirstIndex -= 1; + } + max = sectionAtIndex(possibleFirstIndex).indexToLayoutOffset(possibleFirstIndex); + } + geometry = SliverGeometry( + scrollExtent: max, + maxPaintExtent: max, + ); + childManager.didFinishLayout(); + return; + } + } + + RenderBox trailingChildWithLayout; + + for (var index = indexOf(firstChild) - 1; index >= firstIndex; --index) { + final child = insertAndLayoutLeadingChild(childConstraints); + if (child == null) { + // Items before the previously first child are no longer present. + // Reset the scroll offset to offset all items prior and up to the + // missing item. Let parent re-layout everything. + final layout = sectionAtIndex(index) ?? sectionLayouts.first; + geometry = SliverGeometry(scrollOffsetCorrection: layout.indexToMaxScrollOffset(index)); + return; + } + final childParentData = child.parentData as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(index); + assert(childParentData.index == index); + trailingChildWithLayout ??= child; + } + + if (trailingChildWithLayout == null) { + firstChild.layout(childConstraints); + final childParentData = firstChild.parentData as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(firstIndex); + trailingChildWithLayout = firstChild; + } + + var estimatedMaxScrollOffset = double.infinity; + for (var index = indexOf(trailingChildWithLayout) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) { + var child = childAfter(trailingChildWithLayout); + if (child == null || indexOf(child) != index) { + child = insertAndLayoutChild(childConstraints, after: trailingChildWithLayout); + if (child == null) { + // We have run out of children. + final layout = sectionAtIndex(index) ?? sectionLayouts.last; + estimatedMaxScrollOffset = layout.indexToMaxScrollOffset(index); + break; + } + } else { + child.layout(childConstraints); + } + trailingChildWithLayout = child; + assert(child != null); + final childParentData = child.parentData as SliverMultiBoxAdaptorParentData; + assert(childParentData.index == index); + childParentData.layoutOffset = indexToLayoutOffset(childParentData.index); + } + + final lastIndex = indexOf(lastChild); + final leadingScrollOffset = indexToLayoutOffset(firstIndex); + final trailingScrollOffset = indexToLayoutOffset(lastIndex + 1); + + assert(firstIndex == 0 || childScrollOffset(firstChild) - scrollOffset <= precisionErrorTolerance); + assert(debugAssertChildListIsNonEmptyAndContiguous()); + assert(indexOf(firstChild) == firstIndex); + assert(targetLastIndex == null || lastIndex <= targetLastIndex); + + estimatedMaxScrollOffset = math.min( + estimatedMaxScrollOffset, + estimateMaxScrollOffset( + constraints, + firstIndex: firstIndex, + lastIndex: lastIndex, + leadingScrollOffset: leadingScrollOffset, + trailingScrollOffset: trailingScrollOffset, + ), + ); + + final paintExtent = calculatePaintOffset( + constraints, + from: leadingScrollOffset, + to: trailingScrollOffset, + ); + + final cacheExtent = calculateCacheOffset( + constraints, + from: leadingScrollOffset, + to: trailingScrollOffset, + ); + + final targetEndScrollOffsetForPaint = constraints.scrollOffset + constraints.remainingPaintExtent; + final targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite ? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint) : null; + geometry = SliverGeometry( + scrollExtent: estimatedMaxScrollOffset, + paintExtent: paintExtent, + cacheExtent: cacheExtent, + maxPaintExtent: estimatedMaxScrollOffset, + // Conservative to avoid flickering away the clip during scroll. + hasVisualOverflow: (targetLastIndexForPaint != null && lastIndex >= targetLastIndexForPaint) || constraints.scrollOffset > 0.0, + ); + + // We may have started the layout while scrolled to the end, which would not + // expose a new child. + if (estimatedMaxScrollOffset == trailingScrollOffset) childManager.setDidUnderflow(true); + childManager.didFinishLayout(); + } +} diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/album/grid/list_section_layout.dart new file mode 100644 index 000000000..de6a2ce04 --- /dev/null +++ b/lib/widgets/album/grid/list_section_layout.dart @@ -0,0 +1,183 @@ +import 'dart:math'; + +import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/album/grid/header_generic.dart'; +import 'package:aves/widgets/album/grid/list_sliver.dart'; +import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SectionedListLayoutProvider extends StatelessWidget { + final CollectionLens collection; + final int columnCount; + final double scrollableWidth; + final double tileExtent; + final Widget child; + + SectionedListLayoutProvider({ + @required this.collection, + @required this.scrollableWidth, + @required this.tileExtent, + @required this.child, + }) : columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin); + + @override + Widget build(BuildContext context) { + return ProxyProvider0( + update: (context, __) => _updateLayouts(context), + child: child, + ); + } + + SectionedListLayout _updateLayouts(BuildContext context) { + debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent'); + final sectionLayouts = []; + final showHeaders = collection.showHeaders; + final source = collection.source; + final sections = collection.sections; + final sectionKeys = sections.keys.toList(); + var currentIndex = 0, currentOffset = 0.0; + sectionKeys.forEach((sectionKey) { + final sectionEntryCount = sections[sectionKey].length; + final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil(); + + final headerExtent = showHeaders ? SectionHeader.computeHeaderHeight(source, sectionKey, scrollableWidth) : 0.0; + + final sectionFirstIndex = currentIndex; + currentIndex += sectionChildCount; + final sectionLastIndex = currentIndex - 1; + + final sectionMinOffset = currentOffset; + currentOffset += headerExtent + tileExtent * (sectionChildCount - 1); + final sectionMaxOffset = currentOffset; + + sectionLayouts.add( + SectionLayout( + sectionKey: sectionKey, + firstIndex: sectionFirstIndex, + lastIndex: sectionLastIndex, + minOffset: sectionMinOffset, + maxOffset: sectionMaxOffset, + headerExtent: headerExtent, + tileExtent: tileExtent, + builder: (context, listIndex) => _buildInSection(listIndex - sectionFirstIndex, collection, sectionKey), + ), + ); + }); + return SectionedListLayout( + collection: collection, + columnCount: columnCount, + tileExtent: tileExtent, + sectionLayouts: sectionLayouts, + ); + } + + Widget _buildInSection(int sectionChildIndex, CollectionLens collection, dynamic sectionKey) { + if (sectionChildIndex == 0) { + return collection.showHeaders + ? SectionHeader( + collection: collection, + sectionKey: sectionKey, + ) + : const SizedBox.shrink(); + } + sectionChildIndex--; + + final section = collection.sections[sectionKey]; + final sectionEntryCount = section.length; + + final minEntryIndex = sectionChildIndex * columnCount; + final maxEntryIndex = min(sectionEntryCount, minEntryIndex + columnCount); + final children = []; + for (var i = minEntryIndex; i < maxEntryIndex; i++) { + final entry = section[i]; + children.add(GridThumbnail( + key: ValueKey(entry.contentId), + collection: collection, + index: i, + entry: entry, + tileExtent: tileExtent, + )); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: children, + ); + } +} + +class SectionedListLayout { + final CollectionLens collection; + final int columnCount; + final double tileExtent; + final List sectionLayouts; + + const SectionedListLayout({ + @required this.collection, + @required this.columnCount, + @required this.tileExtent, + @required this.sectionLayouts, + }); + + Rect getTileRect(ImageEntry entry) { + final section = collection.sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null); + if (section == null) return null; + + final sectionKey = section.key; + final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null); + if (sectionLayout == null) return null; + + final showHeaders = collection.showHeaders; + final sectionEntryIndex = section.value.indexOf(entry); + final column = sectionEntryIndex % columnCount; + final row = (sectionEntryIndex / columnCount).floor(); + final listIndex = sectionLayout.firstIndex + (showHeaders ? 1 : 0) + row; + + final left = tileExtent * column; + final top = sectionLayout.indexToLayoutOffset(listIndex); + debugPrint('TLAD getTileRect sectionKey=$sectionKey sectionOffset=${sectionLayout.minOffset} top=$top row=$row column=$column for title=${entry.bestTitle}'); + return Rect.fromLTWH(left, top, tileExtent, tileExtent); + } +} + +class SectionLayout { + final dynamic sectionKey; + final int firstIndex, lastIndex; + final double minOffset, maxOffset; + final double headerExtent, tileExtent; + final IndexedWidgetBuilder builder; + + const SectionLayout({ + @required this.sectionKey, + @required this.firstIndex, + @required this.lastIndex, + @required this.minOffset, + @required this.maxOffset, + @required this.headerExtent, + @required this.tileExtent, + @required this.builder, + }); + + bool hasChild(int index) => firstIndex <= index && index <= lastIndex; + + bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset; + + double indexToLayoutOffset(int index) { + return minOffset + (index == firstIndex ? 0 : headerExtent + (index - firstIndex - 1) * tileExtent); + } + + double indexToMaxScrollOffset(int index) { + return minOffset + headerExtent + (index - firstIndex) * tileExtent; + } + + int getMinChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= minOffset + headerExtent; + return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).floor()); + } + + int getMaxChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= minOffset + headerExtent; + return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1); + } +} diff --git a/lib/widgets/album/grid/list_sliver.dart b/lib/widgets/album/grid/list_sliver.dart new file mode 100644 index 000000000..3aeaf654b --- /dev/null +++ b/lib/widgets/album/grid/list_sliver.dart @@ -0,0 +1,85 @@ +import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/album/grid/list_known_extent.dart'; +import 'package:aves/widgets/album/grid/list_section_layout.dart'; +import 'package:aves/widgets/album/thumbnail.dart'; +import 'package:aves/widgets/common/transparent_material_page_route.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +// use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up +// with the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen +// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0 +class CollectionListSliver extends StatelessWidget { + @override + Widget build(BuildContext context) { + final sectionLayouts = Provider.of(context).sectionLayouts; + final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1; + return SliverKnownExtentList( + sectionLayouts: sectionLayouts, + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= childCount) return null; + final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null); + return sectionLayout?.builder(context, index) ?? const SizedBox.shrink(); + }, + childCount: childCount, + addAutomaticKeepAlives: false, + ), + ); + } +} + +class GridThumbnail extends StatelessWidget { + final CollectionLens collection; + final int index; + final ImageEntry entry; + final double tileExtent; + final GestureTapCallback onTap; + + const GridThumbnail({ + Key key, + this.collection, + this.index, + this.entry, + this.tileExtent, + this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: ValueKey(entry.uri), + onTap: () => _goToFullscreen(context), + child: MetaData( + metaData: ThumbnailMetadata(index, entry), + child: DecoratedThumbnail( + entry: entry, + extent: tileExtent, + heroTag: collection.heroTag(entry), + ), + ), + ); + } + + void _goToFullscreen(BuildContext context) { + Navigator.push( + context, + TransparentMaterialPageRoute( + pageBuilder: (c, a, sa) => MultiFullscreenPage( + collection: collection, + initialEntry: entry, + ), + ), + ); + } +} + +// metadata to identify entry from RenderObject hit test during collection scaling +class ThumbnailMetadata { + final int index; + final ImageEntry entry; + + const ThumbnailMetadata(this.index, this.entry); +} diff --git a/lib/widgets/album/collection_scaling.dart b/lib/widgets/album/grid/scaling.dart similarity index 88% rename from lib/widgets/album/collection_scaling.dart rename to lib/widgets/album/grid/scaling.dart index 4b755b213..453f16103 100644 --- a/lib/widgets/album/collection_scaling.dart +++ b/lib/widgets/album/grid/scaling.dart @@ -2,26 +2,27 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/album/collection_section.dart'; +import 'package:aves/widgets/album/grid/list_section_layout.dart'; +import 'package:aves/widgets/album/grid/list_sliver.dart'; +import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; import 'package:aves/widgets/album/thumbnail.dart'; -import 'package:aves/widgets/album/tile_extent_manager.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:provider/provider.dart'; class GridScaleGestureDetector extends StatefulWidget { final GlobalKey scrollableKey; final ValueNotifier extentNotifier; final Size mqSize; - final EdgeInsets mqPadding; + final double mqHorizontalPadding; final Widget child; const GridScaleGestureDetector({ this.scrollableKey, @required this.extentNotifier, @required this.mqSize, - @required this.mqPadding, + @required this.mqHorizontalPadding, @required this.child, }); @@ -34,7 +35,6 @@ class _GridScaleGestureDetectorState extends State { ValueNotifier _scaledExtentNotifier; OverlayEntry _overlayEntry; ThumbnailMetadata _metadata; - RenderSliver _renderSliver; RenderViewport _renderViewport; ValueNotifier get tileExtentNotifier => widget.extentNotifier; @@ -42,6 +42,10 @@ class _GridScaleGestureDetectorState extends State { @override Widget build(BuildContext context) { return GestureDetector( + onHorizontalDragStart: (details) { + // if `onHorizontalDragStart` callback is not defined, + // horizontal drag gestures are interpreted as scaling + }, onScaleStart: (details) { final scrollableContext = widget.scrollableKey.currentContext; final RenderBox scrollableBox = scrollableContext.findRenderObject(); @@ -53,7 +57,6 @@ class _GridScaleGestureDetectorState extends State { final renderMetaData = firstOf(result); // abort if we cannot find an image to show on overlay if (renderMetaData == null) return; - _renderSliver = firstOf(result) ?? firstOf(result); _renderViewport = firstOf(result); _metadata = renderMetaData.metaData; _startExtent = tileExtentNotifier.value; @@ -93,26 +96,27 @@ class _GridScaleGestureDetectorState extends State { // sanitize and update grid layout if necessary final newExtent = TileExtentManager.applyTileExtent( widget.mqSize, - widget.mqPadding, + widget.mqHorizontalPadding, tileExtentNotifier, newExtent: _scaledExtentNotifier.value, ); _scaledExtentNotifier = null; if (newExtent == oldExtent) return; + // TODO TLAD fix scroll to specific thumbnail with custom SliverList // scroll to show the focal point thumbnail at its new position - final sliverClosure = _renderSliver; final viewportClosure = _renderViewport; - final index = _metadata.index; WidgetsBinding.instance.addPostFrameCallback((_) { final scrollableContext = widget.scrollableKey.currentContext; final gridSize = (scrollableContext.findRenderObject() as RenderBox).size; - final newColumnCount = gridSize.width / newExtent; - final row = index ~/ newColumnCount; + final sectionLayout = Provider.of(context, listen: false); + final tileRect = sectionLayout.getTileRect(_metadata.entry); + final scrollOffset = (tileRect?.top ?? 0) - gridSize.height / 2; + viewportClosure.offset.jumpTo(scrollOffset.clamp(.0, double.infinity)); + // about scrolling & offset retrieval: // `Scrollable.ensureVisible` only works on already rendered objects // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` - final scrollOffset = viewportClosure.scrollOffsetOf(sliverClosure, (row + 1) * newExtent - gridSize.height / 2); - viewportClosure.offset.jumpTo(scrollOffset.clamp(.0, double.infinity)); + // `RenderViewport.scrollOffsetOf` is a good alternative }); }, child: widget.child, @@ -200,7 +204,7 @@ class _ScaleOverlayState extends State { Positioned( left: clampedCenter.dx - extent / 2, top: clampedCenter.dy - extent / 2, - child: Thumbnail( + child: DecoratedThumbnail( entry: widget.imageEntry, extent: extent, ), @@ -228,12 +232,12 @@ class GridPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() - ..strokeWidth = Thumbnail.borderWidth + ..strokeWidth = DecoratedThumbnail.borderWidth ..shader = ui.Gradient.radial( center, size.width / 2, [ - Thumbnail.borderColor, + DecoratedThumbnail.borderColor, Colors.transparent, ], [ diff --git a/lib/widgets/album/tile_extent_manager.dart b/lib/widgets/album/grid/tile_extent_manager.dart similarity index 68% rename from lib/widgets/album/tile_extent_manager.dart rename to lib/widgets/album/grid/tile_extent_manager.dart index 3c78bdcd8..ff85ac095 100644 --- a/lib/widgets/album/tile_extent_manager.dart +++ b/lib/widgets/album/grid/tile_extent_manager.dart @@ -7,9 +7,13 @@ class TileExtentManager { static const int columnCountMin = 2; static const int columnCountDefault = 4; static const double tileExtentMin = 46.0; + static const screenDimensionMin = tileExtentMin * columnCountMin; - static double applyTileExtent(Size mqSize, EdgeInsets mqPadding, ValueNotifier extentNotifier, {double newExtent}) { - final availableWidth = mqSize.width - mqPadding.horizontal; + static double applyTileExtent(Size mqSize, double mqHorizontalPadding, ValueNotifier extentNotifier, {double newExtent}) { + // sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size) + mqSize = Size(max(mqSize.width, screenDimensionMin), max(mqSize.height, screenDimensionMin)); + + final availableWidth = mqSize.width - mqHorizontalPadding; var numColumns; if ((newExtent ?? 0) == 0) { newExtent = extentNotifier.value; diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/album/search/search_delegate.dart index b3ae3c208..0a02e4880 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/album/search/search_delegate.dart @@ -1,5 +1,4 @@ import 'package:aves/model/collection_lens.dart'; -import 'package:aves/model/collection_source.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; @@ -63,23 +62,23 @@ class ImageSearchDelegate extends SearchDelegate { children: [ _buildFilterRow( context: context, - filters: [FavouriteFilter(), MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeTypes.GIF), MimeFilter(MimeTypes.SVG)].where((f) => containQuery(f.label)), + filters: [FavouriteFilter(), MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeFilter.animated), MimeFilter(MimeTypes.SVG)].where((f) => containQuery(f.label)), ), _buildFilterRow( context: context, title: 'Albums', - filters: source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, CollectionSource.getUniqueAlbumName(s, source.sortedAlbums))).where((f) => containQuery(f.uniqueName)), - ), - _buildFilterRow( - context: context, - title: 'Cities', - filters: source.sortedCities.where(containQuery).map((s) => LocationFilter(LocationLevel.city, s)), + filters: source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)), ), _buildFilterRow( context: context, title: 'Countries', filters: source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)), ), + _buildFilterRow( + context: context, + title: 'Places', + filters: source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)), + ), _buildFilterRow( context: context, title: 'Tags', diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index de30930ba..0e9acf5fd 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -10,7 +10,7 @@ import 'package:aves/widgets/common/transition_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -class Thumbnail extends StatelessWidget { +class DecoratedThumbnail extends StatelessWidget { final ImageEntry entry; final double extent; final Object heroTag; @@ -18,7 +18,7 @@ class Thumbnail extends StatelessWidget { static final Color borderColor = Colors.grey.shade700; static const double borderWidth = .5; - const Thumbnail({ + const DecoratedThumbnail({ Key key, @required this.entry, @required this.extent, @@ -39,7 +39,13 @@ class Thumbnail extends StatelessWidget { child: Stack( alignment: AlignmentDirectional.bottomStart, children: [ - entry.isSvg ? _buildVectorImage() : _buildRasterImage(), + entry.isSvg + ? _buildVectorImage() + : ThumbnailRasterImage( + entry: entry, + extent: extent, + heroTag: heroTag, + ), _ThumbnailOverlay( entry: entry, extent: extent, @@ -49,38 +55,6 @@ class Thumbnail extends StatelessWidget { ); } - Widget _buildRasterImage() { - final thumbnailProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); - final image = Image( - image: thumbnailProvider, - width: extent, - height: extent, - fit: BoxFit.cover, - ); - return heroTag == null - ? image - : Hero( - tag: heroTag, - flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { - ImageProvider heroImageProvider = thumbnailProvider; - if (!entry.isVideo && !entry.isSvg) { - final imageProvider = UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - ); - if (imageCache.statusForKey(imageProvider).keepAlive) { - heroImageProvider = imageProvider; - } - } - return TransitionImage( - image: heroImageProvider, - animation: animation, - ); - }, - child: image, - ); - } - Widget _buildVectorImage() { final child = Container( // center `SvgPicture` inside `Container` with the thumbnail dimensions @@ -106,6 +80,89 @@ class Thumbnail extends StatelessWidget { } } +class ThumbnailRasterImage extends StatefulWidget { + final ImageEntry entry; + final double extent; + final Object heroTag; + + const ThumbnailRasterImage({ + Key key, + @required this.entry, + @required this.extent, + this.heroTag, + }) : super(key: key); + + @override + _ThumbnailRasterImageState createState() => _ThumbnailRasterImageState(); +} + +class _ThumbnailRasterImageState extends State { + ThumbnailProvider _imageProvider; + + ImageEntry get entry => widget.entry; + + double get extent => widget.extent; + + Object get heroTag => widget.heroTag; + + @override + void initState() { + super.initState(); + _initProvider(); + } + + @override + void didUpdateWidget(ThumbnailRasterImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entry != entry) { + _cancelProvider(); + _initProvider(); + } + } + + @override + void dispose() { + _cancelProvider(); + super.dispose(); + } + + void _initProvider() => _imageProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); + + void _cancelProvider() => _imageProvider?.cancel(); + + @override + Widget build(BuildContext context) { + final image = Image( + image: _imageProvider, + width: extent, + height: extent, + fit: BoxFit.cover, + ); + return heroTag == null + ? image + : Hero( + tag: heroTag, + flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { + ImageProvider heroImageProvider = _imageProvider; + if (!entry.isVideo && !entry.isSvg) { + final imageProvider = UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + ); + if (imageCache.statusForKey(imageProvider).keepAlive) { + heroImageProvider = imageProvider; + } + } + return TransitionImage( + image: heroImageProvider, + animation: animation, + ); + }, + child: image, + ); + } +} + class _ThumbnailOverlay extends StatelessWidget { final ImageEntry entry; final double extent; @@ -125,8 +182,8 @@ class _ThumbnailOverlay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (entry.hasGps) GpsIcon(iconSize: iconSize), - if (entry.isGif) - GifIcon(iconSize: iconSize) + if (entry.isAnimated) + AnimatedImageIcon(iconSize: iconSize) else if (entry.isVideo) DefaultTextStyle( style: TextStyle( diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index bc88cb0e3..4d2d4af88 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -2,16 +2,17 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/mime_types.dart'; -import 'package:aves/widgets/album/collection_app_bar.dart'; +import 'package:aves/widgets/album/app_bar.dart'; import 'package:aves/widgets/album/collection_page.dart'; -import 'package:aves/widgets/album/collection_scaling.dart'; -import 'package:aves/widgets/album/collection_section.dart'; import 'package:aves/widgets/album/empty.dart'; -import 'package:aves/widgets/album/tile_extent_manager.dart'; +import 'package:aves/widgets/album/grid/list_section_layout.dart'; +import 'package:aves/widgets/album/grid/list_sliver.dart'; +import 'package:aves/widgets/album/grid/scaling.dart'; +import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/scroll_thumb.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -30,85 +31,36 @@ class ThumbnailCollection extends StatelessWidget { @override Widget build(BuildContext context) { return SafeArea( - child: Selector>( - selector: (c, mq) => Tuple3(mq.size, mq.padding, mq.viewInsets.bottom), - builder: (c, mq, child) { + child: Selector>( + selector: (context, mq) => Tuple2(mq.size, mq.padding.horizontal), + builder: (context, mq, child) { final mqSize = mq.item1; - final mqPadding = mq.item2; - final mqViewInsetsBottom = mq.item3; - TileExtentManager.applyTileExtent(mqSize, mqPadding, _tileExtentNotifier); + final mqHorizontalPadding = mq.item2; + TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier); + + // do not replace by Provider.of + // so that view updates on collection filter changes return Consumer( builder: (context, collection, child) { -// debugPrint('$runtimeType collection builder entries=${collection.entryCount}'); - final sectionKeys = collection.sections.keys.toList(); - final showHeaders = collection.showHeaders; - return GridScaleGestureDetector( + final scrollView = _buildScrollView(collection); + final draggable = _buildDraggableScrollView(scrollView); + final scaler = GridScaleGestureDetector( scrollableKey: _scrollableKey, extentNotifier: _tileExtentNotifier, mqSize: mqSize, - mqPadding: mqPadding, - child: ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) { - debugPrint('$runtimeType tileExtent builder entries=${collection.entryCount} tileExtent=$tileExtent'); - final scrollView = CustomScrollView( - key: _scrollableKey, - primary: true, - // workaround to prevent scrolling the app bar away - // when there is no content and we use `SliverFillRemaining` - physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null, - slivers: [ - CollectionAppBar( - stateNotifier: stateNotifier, - appBarHeightNotifier: _appBarHeightNotifier, - collection: collection, - ), - if (collection.isEmpty) - SliverFillRemaining( - child: _buildEmptyCollectionPlaceholder(collection), - hasScrollBody: false, - ), - ...sectionKeys.map((sectionKey) => SectionSliver( - collection: collection, - sectionKey: sectionKey, - tileExtent: tileExtent, - showHeader: showHeaders, - )), - SliverToBoxAdapter( - child: Selector( - selector: (c, mq) => mq.viewInsets.bottom, - builder: (c, mqViewInsetsBottom, child) { - return SizedBox(height: mqViewInsetsBottom); - }, - ), - ), - ], - ); - - return ValueListenableBuilder( - valueListenable: _appBarHeightNotifier, - builder: (context, appBarHeight, child) { - return DraggableScrollbar( - heightScrollThumb: avesScrollThumbHeight, - backgroundColor: Colors.white, - scrollThumbBuilder: avesScrollThumbBuilder( - height: avesScrollThumbHeight, - backgroundColor: Colors.white, - ), - controller: PrimaryScrollController.of(context), - padding: EdgeInsets.only( - // padding to keep scroll thumb between app bar above and nav bar below - top: appBarHeight, - bottom: mqViewInsetsBottom, - ), - child: child, - ); - }, - child: scrollView, - ); - }, + mqHorizontalPadding: mqHorizontalPadding, + child: draggable, + ); + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: _tileExtentNotifier, + builder: (context, tileExtent, child) => SectionedListLayoutProvider( + collection: collection, + scrollableWidth: mqSize.width - mqHorizontalPadding, + tileExtent: tileExtent, + child: scaler, ), ); + return sectionedListLayoutProvider; }, ); }, @@ -116,15 +68,71 @@ class ThumbnailCollection extends StatelessWidget { ); } + ScrollView _buildScrollView(CollectionLens collection) { + return CustomScrollView( + key: _scrollableKey, + primary: true, + // workaround to prevent scrolling the app bar away + // when there is no content and we use `SliverFillRemaining` + physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null, + slivers: [ + CollectionAppBar( + stateNotifier: stateNotifier, + appBarHeightNotifier: _appBarHeightNotifier, + collection: collection, + ), + collection.isEmpty + ? SliverFillRemaining( + child: _buildEmptyCollectionPlaceholder(collection), + hasScrollBody: false, + ) + : CollectionListSliver(), + SliverToBoxAdapter( + child: Selector( + selector: (context, mq) => mq.viewInsets.bottom, + builder: (context, mqViewInsetsBottom, child) { + return SizedBox(height: mqViewInsetsBottom); + }, + ), + ), + ], + ); + } + + Widget _buildDraggableScrollView(ScrollView scrollView) { + return ValueListenableBuilder( + valueListenable: _appBarHeightNotifier, + builder: (context, appBarHeight, child) => Selector( + selector: (context, mq) => mq.viewInsets.bottom, + builder: (context, mqViewInsetsBottom, child) => DraggableScrollbar( + heightScrollThumb: avesScrollThumbHeight, + backgroundColor: Colors.white, + scrollThumbBuilder: avesScrollThumbBuilder( + height: avesScrollThumbHeight, + backgroundColor: Colors.white, + ), + controller: PrimaryScrollController.of(context), + padding: EdgeInsets.only( + // padding to keep scroll thumb between app bar above and nav bar below + top: appBarHeight, + bottom: mqViewInsetsBottom, + ), + child: scrollView, + ), + child: child, + ), + ); + } + Widget _buildEmptyCollectionPlaceholder(CollectionLens collection) { return collection.filters.any((filter) => filter is FavouriteFilter) ? const EmptyContent( - icon: OMIcons.favoriteBorder, + icon: AIcons.favourite, text: 'No favourites!', ) : collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.ANY_VIDEO) ? const EmptyContent( - icon: OMIcons.movie, + icon: AIcons.video, ) : const EmptyContent(); } diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index 8b13538fc..ca16b64a8 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -4,8 +4,6 @@ import 'package:outline_material_icons/outline_material_icons.dart'; typedef FilterCallback = void Function(CollectionFilter filter); -typedef FilterBuilder = CollectionFilter Function(String label); - class AvesFilterChip extends StatefulWidget { final CollectionFilter filter; final bool removable; diff --git a/lib/widgets/common/data_providers/media_store_collection_provider.dart b/lib/widgets/common/data_providers/media_store_collection_provider.dart index bc229e5c5..29d88fe1b 100644 --- a/lib/widgets/common/data_providers/media_store_collection_provider.dart +++ b/lib/widgets/common/data_providers/media_store_collection_provider.dart @@ -4,9 +4,9 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/collection_source.dart'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings.dart'; +import 'package:aves/services/image_file_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_native_timezone/flutter_native_timezone.dart'; diff --git a/lib/widgets/common/fx/outlined_text.dart b/lib/widgets/common/fx/outlined_text.dart index c03e36bd9..1c232d0eb 100644 --- a/lib/widgets/common/fx/outlined_text.dart +++ b/lib/widgets/common/fx/outlined_text.dart @@ -3,18 +3,19 @@ import 'package:flutter/material.dart'; typedef OutlinedWidgetBuilder = Widget Function(BuildContext context, bool isShadow); class OutlinedText extends StatelessWidget { - final OutlinedWidgetBuilder leadingBuilder; + final OutlinedWidgetBuilder leadingBuilder, trailingBuilder; final String text; final TextStyle style; final double outlineWidth; final Color outlineColor; - static const leadingAlignment = PlaceholderAlignment.middle; + static const widgetSpanAlignment = PlaceholderAlignment.middle; const OutlinedText({ Key key, this.leadingBuilder, @required this.text, + this.trailingBuilder, @required this.style, double outlineWidth, Color outlineColor, @@ -31,7 +32,7 @@ class OutlinedText extends StatelessWidget { children: [ if (leadingBuilder != null) WidgetSpan( - alignment: leadingAlignment, + alignment: widgetSpanAlignment, child: leadingBuilder(context, true), ), TextSpan( @@ -43,6 +44,11 @@ class OutlinedText extends StatelessWidget { ..color = outlineColor, ), ), + if (trailingBuilder != null) + WidgetSpan( + alignment: widgetSpanAlignment, + child: trailingBuilder(context, true), + ), ], ), ), @@ -51,13 +57,18 @@ class OutlinedText extends StatelessWidget { children: [ if (leadingBuilder != null) WidgetSpan( - alignment: leadingAlignment, + alignment: widgetSpanAlignment, child: leadingBuilder(context, false), ), TextSpan( text: text, style: style, ), + if (trailingBuilder != null) + WidgetSpan( + alignment: widgetSpanAlignment, + child: trailingBuilder(context, false), + ), ], ), ), diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 9ed6faf0c..e55548c92 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -6,6 +6,16 @@ import 'package:aves/widgets/common/image_providers/app_icon_image_provider.dart import 'package:flutter/material.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; +class AIcons { + static const IconData animated = Icons.slideshow; + static const IconData video = OMIcons.movie; + static const IconData favourite = OMIcons.favoriteBorder; + static const IconData favouriteActive = OMIcons.favorite; + static const IconData date = OMIcons.calendarToday; + static const IconData location = OMIcons.place; + static const IconData tag = OMIcons.localOffer; +} + class VideoIcon extends StatelessWidget { final ImageEntry entry; final double iconSize; @@ -16,22 +26,23 @@ class VideoIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: OMIcons.playCircleOutline, - iconSize: iconSize, + size: iconSize, text: entry.durationText, ); } } -class GifIcon extends StatelessWidget { +class AnimatedImageIcon extends StatelessWidget { final double iconSize; - const GifIcon({Key key, this.iconSize}) : super(key: key); + const AnimatedImageIcon({Key key, this.iconSize}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( - icon: OMIcons.gif, - iconSize: iconSize, + icon: AIcons.animated, + size: iconSize, + iconSize: iconSize * .8, ); } } @@ -44,44 +55,57 @@ class GpsIcon extends StatelessWidget { @override Widget build(BuildContext context) { return OverlayIcon( - icon: OMIcons.place, - iconSize: iconSize, + icon: AIcons.location, + size: iconSize, ); } } class OverlayIcon extends StatelessWidget { final IconData icon; - final double iconSize; + final double size, iconSize; final String text; - const OverlayIcon({Key key, this.icon, this.iconSize, this.text}) : super(key: key); + const OverlayIcon({ + Key key, + @required this.icon, + @required this.size, + double iconSize, + this.text, + }) : iconSize = iconSize ?? size, + super(key: key); @override Widget build(BuildContext context) { + final iconChild = SizedBox( + width: size, + height: size, + child: Icon( + icon, + size: iconSize, + ), + ); + return Container( margin: const EdgeInsets.all(1), - padding: text != null ? EdgeInsets.only(right: iconSize / 4) : null, + padding: text != null ? EdgeInsets.only(right: size / 4) : null, decoration: BoxDecoration( color: const Color(0xBB000000), borderRadius: BorderRadius.all( - Radius.circular(iconSize), + Radius.circular(size), ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - icon, - size: iconSize, - ), - if (text != null) ...[ - const SizedBox(width: 2), - Text(text), - ] - ], - ), + child: text == null + ? iconChild + : Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + iconChild, + const SizedBox(width: 2), + Text(text), + ], + ), ); } } diff --git a/lib/widgets/common/image_providers/app_icon_image_provider.dart b/lib/widgets/common/image_providers/app_icon_image_provider.dart index 0740e8855..6e0e77437 100644 --- a/lib/widgets/common/image_providers/app_icon_image_provider.dart +++ b/lib/widgets/common/image_providers/app_icon_image_provider.dart @@ -1,6 +1,7 @@ +import 'dart:typed_data'; import 'dart:ui' as ui show Codec; -import 'package:aves/utils/android_app_service.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -38,11 +39,7 @@ class AppIconImage extends ImageProvider { Future _loadAsync(AppIconImageKey key, DecoderCallback decode) async { final bytes = await AndroidAppService.getAppIcon(key.packageName, key.sizePixels); - if (bytes.lengthInBytes == 0) { - return null; - } - - return await decode(bytes); + return await decode(bytes ?? Uint8List(0)); } } diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart index dc57ef167..bd7dd3923 100644 --- a/lib/widgets/common/image_providers/thumbnail_provider.dart +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -1,12 +1,15 @@ +import 'dart:typed_data'; import 'dart:ui' as ui show Codec; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_file_service.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; class ThumbnailProvider extends ImageProvider { - const ThumbnailProvider({ + ThumbnailProvider({ @required this.entry, @required this.extent, this.scale = 1.0, @@ -18,6 +21,8 @@ class ThumbnailProvider extends ImageProvider { final double extent; final double scale; + final Object _cancellationKey = Uuid(); + @override Future obtainKey(ImageConfiguration configuration) { // configuration can be empty (e.g. when obtaining key for eviction) @@ -33,7 +38,7 @@ class ThumbnailProvider extends ImageProvider { @override ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) { - return MultiFrameImageStreamCompleter( + return CancellableMultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { @@ -44,12 +49,14 @@ class ThumbnailProvider extends ImageProvider { Future _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { final dimPixels = (extent * key.devicePixelRatio).round(); - final bytes = await ImageFileService.getThumbnail(key.entry, dimPixels, dimPixels); - if (bytes.lengthInBytes == 0) { - return null; - } + final bytes = await ImageFileService.getThumbnail(key.entry, dimPixels, dimPixels, cancellationKey: _cancellationKey); + return await decode(bytes ?? Uint8List(0)); + } - return await decode(bytes); + Future cancel() async { + if (servicePolicy.cancel(_cancellationKey)) { + await evict(); + } } } @@ -74,4 +81,30 @@ class ThumbnailProviderKey { @override int get hashCode => hashValues(entry.uri, extent, scale); + + @override + String toString() { + return 'ThumbnailProviderKey{uri=${entry.uri}, extent=$extent, scale=$scale}'; + } +} + +class CancellableMultiFrameImageStreamCompleter extends MultiFrameImageStreamCompleter { + CancellableMultiFrameImageStreamCompleter({ + @required Future codec, + @required double scale, + Stream chunkEvents, + InformationCollector informationCollector, + }) : super( + codec: codec, + scale: scale, + chunkEvents: chunkEvents, + informationCollector: informationCollector, + ); + + @override + void reportError({DiagnosticsNode context, dynamic exception, StackTrace stack, informationCollector, bool silent = false}) { + // prevent default error reporting in case of planned cancellation + if (exception is CancelledException) return; + super.reportError(context: context, exception: exception, stack: stack, informationCollector: informationCollector, silent: silent); + } } diff --git a/lib/widgets/common/image_providers/uri_image_provider.dart b/lib/widgets/common/image_providers/uri_image_provider.dart index c6c452ec7..7dc101f34 100644 --- a/lib/widgets/common/image_providers/uri_image_provider.dart +++ b/lib/widgets/common/image_providers/uri_image_provider.dart @@ -1,6 +1,7 @@ +import 'dart:typed_data'; import 'dart:ui' as ui show Codec; -import 'package:aves/model/image_file_service.dart'; +import 'package:aves/services/image_file_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -36,11 +37,7 @@ class UriImage extends ImageProvider { assert(key == this); final bytes = await ImageFileService.getImage(uri, mimeType); - if (bytes.lengthInBytes == 0) { - return null; - } - - return await decode(bytes); + return await decode(bytes ?? Uint8List(0)); } @override diff --git a/lib/widgets/common/image_providers/uri_picture_provider.dart b/lib/widgets/common/image_providers/uri_picture_provider.dart index 2b0197d2c..a222f2881 100644 --- a/lib/widgets/common/image_providers/uri_picture_provider.dart +++ b/lib/widgets/common/image_providers/uri_picture_provider.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/image_file_service.dart'; +import 'package:aves/services/image_file_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; diff --git a/lib/widgets/album/transparent_material_page_route.dart b/lib/widgets/common/transparent_material_page_route.dart similarity index 100% rename from lib/widgets/album/transparent_material_page_route.dart rename to lib/widgets/common/transparent_material_page_route.dart diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index 050de997d..7d3bdde33 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -4,6 +4,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; @@ -48,6 +49,9 @@ class DebugPageState extends State { child: ListView( padding: const EdgeInsets.all(8), children: [ + const Text('Storage'), + ...AndroidFileUtils.storageVolumes.map((v) => Text('${v.description}: ${v.path} (removable: ${v.isRemovable})')), + const Divider(), Row( children: [ const Text('Settings'), diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index c1380dc28..21ffad671 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -68,6 +68,7 @@ class _FullscreenDebugPageState extends State { if (data != null) InfoRowGroup({ 'dateMillis': '${data.dateMillis}', + 'isAnimated': '${data.isAnimated}', 'videoRotation': '${data.videoRotation}', 'latitude': '${data.latitude}', 'longitude': '${data.longitude}', @@ -91,7 +92,8 @@ class _FullscreenDebugPageState extends State { Text('DB address:${data == null ? ' no row' : ''}'), if (data != null) InfoRowGroup({ - 'dateMillis': '${data.addressLine}', + 'addressLine': '${data.addressLine}', + 'countryCode': '${data.countryCode}', 'countryName': '${data.countryName}', 'adminArea': '${data.adminArea}', 'locality': '${data.locality}', @@ -100,6 +102,8 @@ class _FullscreenDebugPageState extends State { ); }, ), + const Divider(), + Text('Catalog metadata: ${widget.entry.catalogMetadata}'), ], ), ), diff --git a/lib/widgets/fullscreen/fullscreen_action_delegate.dart b/lib/widgets/fullscreen/fullscreen_action_delegate.dart index 0722800b9..4176a2226 100644 --- a/lib/widgets/fullscreen/fullscreen_action_delegate.dart +++ b/lib/widgets/fullscreen/fullscreen_action_delegate.dart @@ -2,8 +2,8 @@ import 'dart:io'; import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_file_service.dart'; -import 'package:aves/utils/android_app_service.dart'; +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/image_file_service.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/debug.dart'; import 'package:aves/widgets/fullscreen/fullscreen_actions.dart'; diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index bd3c980c3..f7c4a80b3 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -2,9 +2,11 @@ import 'dart:io'; import 'dart:math'; import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart'; @@ -17,10 +19,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.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; @@ -48,7 +50,7 @@ class FullscreenBodyState extends State with SingleTickerProvide Animation _bottomOverlayOffset; EdgeInsets _frozenViewInsets, _frozenViewPadding; FullscreenActionDelegate _actionDelegate; - final List> _videoControllers = []; + final List> _videoControllers = []; CollectionLens get collection => widget.collection; @@ -112,6 +114,7 @@ class FullscreenBodyState extends State with SingleTickerProvide _overlayAnimationController.dispose(); _overlayVisible.removeListener(_onOverlayVisibleChange); _videoControllers.forEach((kv) => kv.item2.dispose()); + _videoControllers.clear(); _verticalPager.removeListener(_onVerticalPageControllerChange); _unregisterWidget(widget); super.dispose(); @@ -207,22 +210,28 @@ class FullscreenBodyState extends State with SingleTickerProvide _onLeave(); return SynchronousFuture(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), - ), - topOverlay, - bottomOverlay, - ], + child: NotificationListener( + onNotification: (notification) { + if (notification is FilterNotification) _goToCollection(notification.filter); + return false; + }, + 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), + ), + topOverlay, + bottomOverlay, + ], + ), ), ); } @@ -231,6 +240,17 @@ class FullscreenBodyState extends State with SingleTickerProvide _verticalScrollNotifier.notifyListeners(); } + void _goToCollection(CollectionFilter filter) { + _showSystemUI(); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => CollectionPage(collection.derive(filter)), + ), + (route) => false, + ); + } + Future _goToVerticalPage(int page) { return _verticalPager.animateToPage( page, @@ -275,9 +295,9 @@ class FullscreenBodyState extends State with SingleTickerProvide // system UI - void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); + static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); - void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); + static void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); // overlay @@ -311,7 +331,7 @@ class FullscreenBodyState extends State with SingleTickerProvide void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); - void _initVideoController() { + Future _initVideoController() async { if (_entry == null || !_entry.isVideo) return; final uri = _entry.uri; @@ -319,9 +339,8 @@ class FullscreenBodyState extends State with SingleTickerProvide if (controllerEntry != null) { _videoControllers.remove(controllerEntry); } else { - // unsupported by video_player 0.10.8+2 (backed by ExoPlayer): AVI - final controller = VideoPlayerController.uri(uri)..initialize(); - controllerEntry = Tuple2(uri, controller); + // do not set data source of IjkMediaController here + controllerEntry = Tuple2(uri, IjkMediaController()); } _videoControllers.insert(0, controllerEntry); while (_videoControllers.length > 3) { @@ -333,7 +352,7 @@ class FullscreenBodyState extends State with SingleTickerProvide class FullscreenVerticalPageView extends StatefulWidget { final CollectionLens collection; final ImageEntry entry; - final List> videoControllers; + final List> videoControllers; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final VoidCallback onImageTap, onImagePageRequested; @@ -380,8 +399,8 @@ class _FullscreenVerticalPageViewState extends State @override void dispose() { - super.dispose(); _unregisterWidget(widget); + super.dispose(); } void _registerWidget(FullscreenVerticalPageView widget) { diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 1ec790069..b59d2a07e 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -1,10 +1,10 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.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 MultiImagePage extends StatefulWidget { final CollectionLens collection; @@ -12,7 +12,7 @@ class MultiImagePage extends StatefulWidget { final ValueChanged onPageChanged; final ValueChanged onScaleChanged; final VoidCallback onTap; - final List> videoControllers; + final List> videoControllers; const MultiImagePage({ this.collection, @@ -65,7 +65,7 @@ class SingleImagePage extends StatefulWidget { final ImageEntry entry; final ValueChanged onScaleChanged; final VoidCallback onTap; - final List> videoControllers; + final List> videoControllers; const SingleImagePage({ this.entry, diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index c3be77fe6..3ab3d3ed6 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -4,18 +4,18 @@ import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/fullscreen/video_view.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_view/photo_view.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 onScaleChanged; final VoidCallback onTap; - final List> videoControllers; + final List> videoControllers; const ImageView({ this.entry, diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 8aad7f114..4b259d509 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -1,5 +1,4 @@ import 'package:aves/model/collection_lens.dart'; -import 'package:aves/model/collection_source.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; @@ -29,7 +28,7 @@ class BasicSection extends StatelessWidget { Widget build(BuildContext context) { final date = entry.bestDate; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : '?'; - final showMegaPixels = !entry.isVideo && !entry.isGif && entry.megaPixels != null && entry.megaPixels > 0; + final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); @@ -51,9 +50,9 @@ class BasicSection extends StatelessWidget { final album = entry.directory; final filters = [ if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO), - if (entry.isGif) MimeFilter(MimeTypes.GIF), + if (entry.isAnimated) MimeFilter(MimeFilter.animated), if (isFavourite) FavouriteFilter(), - if (album != null) AlbumFilter(album, CollectionSource.getUniqueAlbumName(album, collection?.source?.sortedAlbums)), + if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), ...tags.map((tag) => TagFilter(tag)), ]..sort(); if (filters.isEmpty) return const SizedBox.shrink(); diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index c425eecb5..189fefa2b 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -1,7 +1,6 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/fullscreen/info/basic_section.dart'; @@ -155,13 +154,7 @@ class InfoPageState extends State { void _goToCollection(CollectionFilter filter) { if (collection == null) return; - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => CollectionPage(collection.derive(filter)), - ), - (route) => false, - ); + FilterNotification(filter).dispatch(context); } } @@ -228,3 +221,9 @@ class InfoRowGroup extends StatelessWidget { } class BackUpNotification extends Notification {} + +class FilterNotification extends Notification { + final CollectionFilter filter; + + const FilterNotification(this.filter); +} diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 0fdb8b032..c0a452ab9 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -2,9 +2,10 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings.dart'; -import 'package:aves/utils/android_app_service.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/map_initializer.dart'; import 'package:flutter/material.dart'; @@ -80,9 +81,9 @@ class _LocationSectionState extends State { final address = entry.addressDetails; location = address.addressLine; final country = address.countryName; - if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, country)); - final city = address.city; - if (city != null && city.isNotEmpty) filters.add(LocationFilter(LocationLevel.city, city)); + if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country;${address.countryCode}')); + final place = address.place; + if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); } else if (entry.hasGps) { location = toDMS(entry.latLng).join(', '); } @@ -93,7 +94,7 @@ class _LocationSectionState extends State { if (widget.showTitle) const Padding( padding: EdgeInsets.only(bottom: 8), - child: SectionRow(OMIcons.place), + child: SectionRow(AIcons.location), ), ImageMap( markerId: entry.uri ?? entry.path, @@ -170,9 +171,10 @@ class ImageMapState extends State with AutomaticKeepAliveClientMixin { children: [ Expanded( child: GestureDetector( - // absorb scale gesture here to prevent scrolling - // and triggering by mistake a move to the image page above - onScaleStart: (d) {}, + onScaleStart: (details) { + // absorb scale gesture here to prevent scrolling + // and triggering by mistake a move to the image page above + }, child: ClipRRect( borderRadius: const BorderRadius.all( Radius.circular(16), diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index a4d0f9768..0279a98ea 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/metadata_service.dart'; +import 'package:aves/services/metadata_service.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; @@ -26,6 +26,7 @@ class MetadataSectionSliver extends StatefulWidget { class _MetadataSectionSliverState extends State with AutomaticKeepAliveClientMixin { List<_MetadataDirectory> _metadata = []; String _loadedMetadataUri; + final ValueNotifier _expandedDirectoryNotifier = ValueNotifier(null); bool get isVisible => widget.visibleNotifier.value; @@ -81,6 +82,8 @@ class _MetadataSectionSliverState extends State with Auto } final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount]; return ExpansionTileCard( + value: dir.name, + expandedNotifier: _expandedDirectoryNotifier, title: _DirectoryTitle(dir.name), children: [ const Divider(thickness: 1.0, height: 1.0), @@ -91,6 +94,7 @@ class _MetadataSectionSliverState extends State with Auto ), ], baseColor: Colors.grey[900], + expandedColor: Colors.grey[850], ); }, childCount: 1 + _metadata.length, @@ -119,6 +123,7 @@ class _MetadataSectionSliverState extends State with Auto _metadata = []; _loadedMetadataUri = null; } + _expandedDirectoryNotifier.value = null; if (mounted) setState(() {}); } diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 89de81bc5..2528b7b3e 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -3,10 +3,11 @@ import 'dart:ui'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; -import 'package:aves/model/metadata_service.dart'; +import 'package:aves/services/metadata_service.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; @@ -216,7 +217,7 @@ class _LocationRow extends AnimatedWidget { } return Row( children: [ - const Icon(OMIcons.place, size: _iconSize), + const Icon(AIcons.location, size: _iconSize), const SizedBox(width: _iconPadding), Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), ], @@ -236,7 +237,7 @@ class _DateRow extends StatelessWidget { final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}'; return Row( children: [ - const Icon(OMIcons.calendarToday, size: _iconSize), + const Icon(AIcons.date, size: _iconSize), const SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)), diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index 940c27d17..2059b3e8c 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -2,12 +2,12 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/fullscreen/fullscreen_actions.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:provider/provider.dart'; class FullscreenTopOverlay extends StatelessWidget { @@ -130,12 +130,12 @@ class FullscreenTopOverlay extends StatelessWidget { alignment: Alignment.center, children: [ IconButton( - icon: Icon(isFavourite ? OMIcons.favorite : OMIcons.favoriteBorder), + icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), onPressed: onPressed, tooltip: isFavourite ? 'Remove from favourites' : 'Add to favourites', ), Sweeper( - builder: (context) => Icon(OMIcons.favoriteBorder, color: Colors.redAccent), + builder: (context) => Icon(AIcons.favourite, color: Colors.redAccent), toggledNotifier: entry.isFavouriteNotifier, ), ], @@ -181,11 +181,11 @@ class FullscreenTopOverlay extends StatelessWidget { child = entry.isFavouriteNotifier.value ? const MenuRow( text: 'Remove from favourites', - icon: OMIcons.favorite, + icon: AIcons.favouriteActive, ) : const MenuRow( text: 'Add to favourites', - icon: OMIcons.favoriteBorder, + icon: AIcons.favourite, ); break; case FullscreenAction.info: diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index 9b7ad422a..047fa959c 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -1,18 +1,20 @@ +import 'dart:async'; + import 'package:aves/model/image_entry.dart'; -import 'package:aves/utils/android_app_service.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -import 'package:video_player/video_player.dart'; class VideoControlOverlay extends StatefulWidget { final ImageEntry entry; final Animation scale; - final VideoPlayerController controller; + final IjkMediaController controller; final EdgeInsets viewInsets, viewPadding; const VideoControlOverlay({ @@ -32,16 +34,28 @@ class VideoControlOverlayState extends State with SingleTic final GlobalKey _progressBarKey = GlobalKey(); bool _playingOnDragStart = false; AnimationController _playPauseAnimation; + final List _subscriptions = []; + double _seekTargetPercent; + + // video info is not refreshed by default, so we use a timer to do so + Timer _progressTimer; ImageEntry get entry => widget.entry; Animation get scale => widget.scale; - VideoPlayerController get controller => widget.controller; + IjkMediaController get controller => widget.controller; - VideoPlayerValue get value => widget.controller.value; + // `videoInfo` is never null (even if `toString` prints `null`) + // check presence with `hasData` instead + VideoInfo get videoInfo => controller.videoInfo; - double get progress => value.position != null && value.duration != null ? value.position.inMilliseconds / value.duration.inMilliseconds : 0; + // we check whether video info is ready instead of checking for `noDatasource` status, + // as the controller could also be uninitialized with the `pause` status + // (e.g. when switching between video entries without playing them the first time) + bool get isInitialized => videoInfo.hasData; + + bool get isPlaying => controller.ijkStatus == IjkStatus.playing; @override void initState() { @@ -51,7 +65,6 @@ class VideoControlOverlayState extends State with SingleTic vsync: this, ); _registerWidget(widget); - _onValueChange(); } @override @@ -69,11 +82,17 @@ class VideoControlOverlayState extends State with SingleTic } void _registerWidget(VideoControlOverlay widget) { - widget.controller.addListener(_onValueChange); + _subscriptions.add(widget.controller.ijkStatusStream.listen(_onStatusChange)); + _subscriptions.add(widget.controller.textureIdStream.listen(_onTextureIdChange)); + _onStatusChange(widget.controller.ijkStatus); + _onTextureIdChange(widget.controller.textureId); } void _unregisterWidget(VideoControlOverlay widget) { - widget.controller.removeListener(_onValueChange); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _stopTimer(); } @override @@ -93,37 +112,43 @@ class VideoControlOverlayState extends State with SingleTic padding: safePadding, child: SizedBox( width: mqWidth - safePadding.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: value.hasError - ? [ - OverlayButton( - scale: scale, - child: IconButton( - icon: Icon(OMIcons.openInNew), - onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), - tooltip: 'Open', - ), - ), - ] - : [ - Expanded( - child: _buildProgressBar(), - ), - const SizedBox(width: 8), - OverlayButton( - scale: scale, - child: IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.play_pause, - progress: _playPauseAnimation, - ), - onPressed: _playPause, - tooltip: value.isPlaying ? 'Pause' : 'Play', - ), - ), - ], - ), + child: StreamBuilder( + stream: controller.ijkStatusStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final status = controller.ijkStatus; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: status == IjkStatus.error + ? [ + OverlayButton( + scale: scale, + child: IconButton( + icon: Icon(OMIcons.openInNew), + onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), + tooltip: 'Open', + ), + ), + ] + : [ + Expanded( + child: _buildProgressBar(), + ), + const SizedBox(width: 8), + OverlayButton( + scale: scale, + child: IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _playPauseAnimation, + ), + onPressed: _playPause, + tooltip: isPlaying ? 'Pause' : 'Play', + ), + ), + ], + ); + }), ), ); }, @@ -138,14 +163,14 @@ class VideoControlOverlayState extends State with SingleTic borderRadius: progressBarBorderRadius, child: GestureDetector( onTapDown: (TapDownDetails details) { - _seek(details.globalPosition); + _seekFromTap(details.globalPosition); }, onHorizontalDragStart: (DragStartDetails details) { - _playingOnDragStart = controller.value.isPlaying; + _playingOnDragStart = isPlaying; if (_playingOnDragStart) controller.pause(); }, onHorizontalDragUpdate: (DragUpdateDetails details) { - _seek(details.globalPosition); + _seekFromTap(details.globalPosition); }, onHorizontalDragEnd: (DragEndDetails details) { if (_playingOnDragStart) controller.play(); @@ -164,12 +189,25 @@ class VideoControlOverlayState extends State with SingleTic children: [ Row( children: [ - Text(formatDuration(value.position ?? Duration.zero)), + StreamBuilder( + stream: controller.videoInfoStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + final position = videoInfo.currentPosition?.floor() ?? 0; + return Text(formatDuration(Duration(seconds: position))); + }), const Spacer(), - Text(formatDuration(value.duration ?? Duration.zero)), + Text(entry.durationText), ], ), - LinearProgressIndicator(value: progress), + StreamBuilder( + stream: controller.videoInfoStream, + builder: (context, snapshot) { + // do not use stream snapshot because it is obsolete when switching between videos + var progress = videoInfo.progress; + if (!progress.isFinite) progress = 0.0; + return LinearProgressIndicator(value: progress); + }), ], ), ), @@ -178,23 +216,44 @@ class VideoControlOverlayState extends State with SingleTic ); } - void _onValueChange() { - setState(() {}); - updatePlayPauseIcon(); + void _startTimer() { + if (controller.textureId == null) return; + _progressTimer?.cancel(); + _progressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) { + controller.refreshVideoInfo(); + }); + } + + void _stopTimer() { + _progressTimer?.cancel(); + } + + void _onTextureIdChange(int textureId) { + if (textureId != null) { + _startTimer(); + } else { + _stopTimer(); + } + } + + void _onStatusChange(IjkStatus status) { + if (status == IjkStatus.playing && _seekTargetPercent != null) { + _seekFromTarget(); + } + _updatePlayPauseIcon(); } Future _playPause() async { - if (value.isPlaying) { + if (isPlaying) { await controller.pause(); - } else { - if (!value.initialized) await controller.initialize(); + } else if (isInitialized) { await controller.play(); + } else { + await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true); } - setState(() {}); } - void updatePlayPauseIcon() { - final isPlaying = value.isPlaying; + void _updatePlayPauseIcon() { final status = _playPauseAnimation.status; if (isPlaying && status != AnimationStatus.forward && status != AnimationStatus.completed) { _playPauseAnimation.forward(); @@ -203,10 +262,29 @@ class VideoControlOverlayState extends State with SingleTic } } - void _seek(Offset globalPosition) { + void _seekFromTap(Offset globalPosition) async { final keyContext = _progressBarKey.currentContext; final RenderBox box = keyContext.findRenderObject(); final localPosition = box.globalToLocal(globalPosition); - controller.seekTo(value.duration * (localPosition.dx / box.size.width)); + _seekTargetPercent = (localPosition.dx / box.size.width); + + if (isInitialized) { + await _seekFromTarget(); + } else { + // autoplay when seeking on uninitialized player, otherwise the texture is not updated + // as a workaround, pausing after a brief duration is possible, but fiddly + await controller.setDataSource(DataSource.photoManagerUrl(entry.uri), autoPlay: true); + } + } + + Future _seekFromTarget() async { + // `seekToProgress` is not safe as it can be called when the `duration` is not set yet + // so we make sure the video info is up to date first + if (videoInfo.duration == null) { + await controller.refreshVideoInfo(); + } else { + await controller.seekToProgress(_seekTargetPercent); + _seekTargetPercent = null; + } } } diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 309cd3745..64d1c1c59 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -1,13 +1,14 @@ +import 'dart:async'; import 'dart:ui'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; class AvesVideo extends StatefulWidget { final ImageEntry entry; - final VideoPlayerController controller; + final IjkMediaController controller; const AvesVideo({ Key key, @@ -20,15 +21,16 @@ class AvesVideo extends StatefulWidget { } class AvesVideoState extends State { + final List _subscriptions = []; + ImageEntry get entry => widget.entry; - VideoPlayerValue get value => widget.controller.value; + IjkMediaController get controller => widget.controller; @override void initState() { super.initState(); _registerWidget(widget); - _onValueChange(); } @override @@ -45,38 +47,78 @@ class AvesVideoState extends State { } void _registerWidget(AvesVideo widget) { - widget.controller.addListener(_onValueChange); + _subscriptions.add(widget.controller.playFinishStream.listen(_onPlayFinish)); } void _unregisterWidget(AvesVideo widget) { - widget.controller.removeListener(_onValueChange); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); } + bool isPlayable(IjkStatus status) => [IjkStatus.prepared, IjkStatus.playing, IjkStatus.pause, IjkStatus.complete].contains(status); + @override Widget build(BuildContext context) { - if (value == null) return const SizedBox(); - if (value.hasError) { - return Image( - image: UriImage(uri: entry.uri, mimeType: entry.mimeType), - width: entry.width.toDouble(), - height: entry.height.toDouble(), - ); - } - return Center( - child: AspectRatio( - aspectRatio: entry.displayAspectRatio, - child: VideoPlayer(widget.controller), - ), - ); + if (controller == null) return const SizedBox(); + return StreamBuilder( + stream: widget.controller.ijkStatusStream, + builder: (context, snapshot) { + final status = snapshot.data; + return isPlayable(status) + ? IjkPlayer( + mediaController: controller, + controllerWidgetBuilder: (controller) => const SizedBox.shrink(), + statusWidgetBuilder: (context, controller, status) => const SizedBox.shrink(), + textureBuilder: (context, controller, info) { + var id = controller.textureId; + if (id == null) { + return AspectRatio( + aspectRatio: entry.displayAspectRatio, + child: Container( + color: Colors.green, + ), + ); + } + + Widget child = Container( + color: Colors.blue, + child: Texture( + textureId: id, + ), + ); + + if (!controller.autoRotate) { + return child; + } + + final degree = entry.catalogMetadata?.videoRotation ?? 0; + if (degree != 0) { + child = RotatedBox( + quarterTurns: degree ~/ 90, + child: child, + ); + } + + child = AspectRatio( + aspectRatio: entry.displayAspectRatio, + child: child, + ); + + return Container( + child: child, + alignment: Alignment.center, + color: Colors.transparent, + ); + }, + ) + : Image( + image: UriImage(uri: entry.uri, mimeType: entry.mimeType), + width: entry.width.toDouble(), + height: entry.height.toDouble(), + ); + }); } - void _onValueChange() { - if (!value.isPlaying && value.position == value.duration) _goToStart(); - setState(() {}); - } - - Future _goToStart() async { - await widget.controller.seekTo(Duration.zero); - await widget.controller.pause(); - } + void _onPlayFinish(IjkMediaController controller) => controller.seekTo(0); } diff --git a/lib/widgets/stats.dart b/lib/widgets/stats.dart index b960d9749..3dd4c7e8d 100644 --- a/lib/widgets/stats.dart +++ b/lib/widgets/stats.dart @@ -11,16 +11,16 @@ import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; class StatsPage extends StatelessWidget { final CollectionLens collection; - final Map entryCountPerCity = {}, entryCountPerCountry = {}, entryCountPerTag = {}; + final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; List get entries => collection.sortedEntries; @@ -30,14 +30,15 @@ class StatsPage extends StatelessWidget { entries.forEach((entry) { if (entry.isLocated) { final address = entry.addressDetails; - final city = address.city; - if (city != null && city.isNotEmpty) { - entryCountPerCity[city] = (entryCountPerCity[city] ?? 0) + 1; - } - final country = address.countryName; + var country = address.countryName; if (country != null && country.isNotEmpty) { + country += ';${address.countryCode}'; entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1; } + final place = address.place; + if (place != null && place.isNotEmpty) { + entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; + } } entry.xmpSubjects.forEach((tag) { entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; @@ -76,7 +77,7 @@ class StatsPage extends StatelessWidget { backgroundColor: Colors.white24, progressColor: Theme.of(context).accentColor, animation: true, - leading: const Icon(OMIcons.place), + leading: const Icon(AIcons.location), // right padding to match leading, so that inside label is aligned with outside label below padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(right: 24), center: Text(NumberFormat.percentPattern().format(withGpsPercent)), @@ -86,8 +87,8 @@ class StatsPage extends StatelessWidget { ], ), ), - ..._buildTopFilters(context, 'Top cities', entryCountPerCity, (s) => LocationFilter(LocationLevel.city, s)), ..._buildTopFilters(context, 'Top countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), + ..._buildTopFilters(context, 'Top places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), ..._buildTopFilters(context, 'Top tags', entryCountPerTag, (s) => TagFilter(s)), ], ); @@ -193,7 +194,12 @@ class StatsPage extends StatelessWidget { }); } - List _buildTopFilters(BuildContext context, String title, Map entryCountMap, FilterBuilder filterBuilder) { + List _buildTopFilters( + BuildContext context, + String title, + Map entryCountMap, + CollectionFilter Function(String key) filterBuilder, + ) { if (entryCountMap.isEmpty) return []; final maxCount = collection.entryCount; @@ -214,7 +220,8 @@ class StatsPage extends StatelessWidget { padding: const EdgeInsetsDirectional.only(start: AvesFilterChip.buttonBorderWidth / 2 + 6, end: 8), child: Table( children: sortedEntries.take(5).map((kv) { - final label = kv.key; + final filter = filterBuilder(kv.key); + final label = filter.label; final count = kv.value; final percent = count / maxCount; return TableRow( @@ -222,7 +229,7 @@ class StatsPage extends StatelessWidget { Align( alignment: AlignmentDirectional.centerStart, child: AvesFilterChip( - filter: filterBuilder(label), + filter: filter, onPressed: (filter) => _goToCollection(context, filter), ), ), diff --git a/pubspec.lock b/pubspec.lock index 6b0d20d2f..5284b4654 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -35,7 +35,7 @@ packages: name: barcode url: "https://pub.dartlang.org" source: hosted - version: "1.5.0" + version: "1.6.0" boolean_selector: dependency: transitive description: @@ -88,11 +88,9 @@ packages: draggable_scrollbar: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "3b823ae0a9def4edec62771f18e6348312bfce15" - url: "git://github.com/deckerst/flutter-draggable-scrollbar.git" - source: git + path: "../flutter-draggable-scrollbar" + relative: true + source: path version: "0.0.4" event_bus: dependency: "direct main" @@ -104,24 +102,29 @@ packages: expansion_tile_card: dependency: "direct main" description: - name: expansion_tile_card - url: "https://pub.dartlang.org" - source: hosted + path: "../expansion_tile_card" + relative: true + source: path version: "1.0.3" flushbar: dependency: "direct main" description: - path: "." - ref: "13c55a8" - resolved-ref: "13c55a888c1693f1c8269ea30d55c614a1bfee16" - url: "https://github.com/AndreHaueisen/flushbar.git" - source: git - version: "1.9.1" + name: flushbar + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_ijkplayer: + dependency: "direct main" + description: + path: "../flutter_ijkplayer" + relative: true + source: path + version: "0.3.6" flutter_native_timezone: dependency: "direct main" description: @@ -136,22 +139,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.6" - flutter_sticky_header: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "14be154f50f5d14e88cc05b93b12377012b8905a" - url: "git://github.com/deckerst/flutter_sticky_header.git" - source: git - version: "0.4.2" flutter_svg: dependency: "direct main" description: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "0.17.3+1" + version: "0.17.4" flutter_test: dependency: "direct dev" description: flutter @@ -175,7 +169,7 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "0.5.25+2" + version: "0.5.26" image: dependency: transitive description: @@ -196,7 +190,7 @@ packages: name: io url: "https://pub.dartlang.org" source: hosted - version: "0.3.3" + version: "0.3.4" logger: dependency: "direct main" description: @@ -273,7 +267,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "1.6.1" pedantic: dependency: "direct main" description: @@ -287,14 +281,14 @@ packages: name: percent_indicator url: "https://pub.dartlang.org" source: hosted - version: "2.1.1+1" + version: "2.1.3" permission_handler: dependency: "direct main" description: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "5.0.0+hotfix.2" + version: "5.0.0+hotfix.3" permission_handler_platform_interface: dependency: transitive description: @@ -329,14 +323,14 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "3.2.1" + version: "3.3.1" provider: dependency: "direct main" description: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.0.4" + version: "4.0.5" qr: dependency: transitive description: @@ -482,6 +476,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.9.0+5" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" vector_math: dependency: transitive description: @@ -489,27 +490,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" - video_player: - dependency: "direct main" - description: - path: "../plugins/packages/video_player/video_player" - relative: true - source: path - version: "0.10.8+2" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - video_player_web: - dependency: transitive - description: - name: video_player_web - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.2+1" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 204324c85..37893024c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,25 @@ description: A new Flutter application. # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html version: 1.0.0+1 +# video_player (as of v0.10.8+2, backed by ExoPlayer): +# - no URI handling by default but trivial by fork +# - support: AVI/XVID/MP3 nothing, MP2T nothing +# - cannot support more formats +# - playable only when both the video and audio streams are supported + +# fijkplayer (as of v0.7.1, backed by IJKPlayer & ffmpeg): +# - URI handling +# - support: AVI/XVID/MP3 audio only, MP2T video only +# - possible support for more formats by customizing ffmpeg build, +# - playable when only the video or audio stream is supported +# - crash when calling `seekTo` for some files (e.g. TED talk videos) + +# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg): +# - URI handling (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android