#6 burst support (samsung camera naming pattern)

This commit is contained in:
Thibault Deckers 2021-07-22 16:55:16 +09:00
parent 4d7b9e9065
commit 6c2dc791d5
30 changed files with 504 additions and 310 deletions

View file

@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart';
@ -39,6 +40,8 @@ class AvesEntry {
CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails;
List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions
@ -64,6 +67,7 @@ class AvesEntry {
required int? dateModifiedSecs,
required this.sourceDateTakenMillis,
required int? durationMillis,
this.burstEntries,
}) {
this.path = path;
this.sourceTitle = sourceTitle;
@ -80,6 +84,7 @@ class AvesEntry {
String? path,
int? contentId,
int? dateModifiedSecs,
List<AvesEntry>? burstEntries,
}) {
final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry(
@ -96,6 +101,7 @@ class AvesEntry {
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
burstEntries: burstEntries ?? this.burstEntries,
)
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
@ -228,10 +234,6 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false;
bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg;
bool get canEdit => path != null;
bool get canRotateAndFlip => canEdit && canEditExif;
@ -652,6 +654,51 @@ class AvesEntry {
}
}
// multipage
static final _burstFilenamePattern = RegExp(r'^(\d{8}_\d{6})_(\d+)$');
bool get isMultiPage => (_catalogMetadata?.isMultiPage ?? false) || isBurst;
bool get isBurst => burstEntries?.isNotEmpty == true;
bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
String? get burstKey {
if (filenameWithoutExtension != null) {
final match = _burstFilenamePattern.firstMatch(filenameWithoutExtension!);
if (match != null) {
return '$directory/${match.group(1)}';
}
}
return null;
}
Future<MultiPageInfo?> getMultiPageInfo() async {
if (isBurst) {
return MultiPageInfo(
mainEntry: this,
pages: burstEntries!
.mapIndexed((index, entry) => SinglePageInfo(
index: index,
pageId: entry.contentId!,
isDefault: index == 0,
uri: entry.uri,
mimeType: entry.mimeType,
width: entry.width,
height: entry.height,
rotationDegrees: entry.rotationDegrees,
durationMillis: entry.durationMillis,
))
.toList(),
);
} else {
return await metadataService.getMultiPageInfo(this);
}
}
// sort
// compare by:
// 1) title ascending
// 2) extension ascending

View file

@ -22,6 +22,14 @@ class MultiPageInfo {
final firstPage = _pages.removeAt(0);
_pages.insert(0, firstPage.copyWith(isDefault: true));
}
final burstEntries = mainEntry.burstEntries;
if (burstEntries != null) {
_pageEntries.addEntries(pages.map((pageInfo) {
final pageEntry = burstEntries.firstWhere((entry) => entry.uri == pageInfo.uri);
return MapEntry(pageInfo, pageEntry);
}));
}
}
}

View file

@ -212,9 +212,9 @@ class Settings extends ChangeNotifier {
// collection
EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
set collectionGroupFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
set collectionSectionFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values);

View file

@ -13,6 +13,7 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
@ -21,9 +22,9 @@ import 'enums.dart';
class CollectionLens with ChangeNotifier {
final CollectionSource source;
final Set<CollectionFilter> filters;
EntryGroupFactor groupFactor;
EntryGroupFactor sectionFactor;
EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier();
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
final List<StreamSubscription> _subscriptions = [];
int? id;
bool listenToSource;
@ -38,7 +39,7 @@ class CollectionLens with ChangeNotifier {
this.id,
this.listenToSource = true,
}) : filters = (filters ?? {}).whereNotNull().toSet(),
groupFactor = settings.collectionGroupFactor,
sectionFactor = settings.collectionSectionFactor,
sortFactor = settings.collectionSortFactor {
id ??= hashCode;
if (listenToSource) {
@ -73,7 +74,7 @@ class CollectionLens with ChangeNotifier {
int get entryCount => _filteredSortedEntries.length;
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
// sorted as displayed to the user, i.e. sorted then sectioned, not an absolute order on all entries
List<AvesEntry>? _sortedEntries;
List<AvesEntry> get sortedEntries {
@ -84,9 +85,9 @@ class CollectionLens with ChangeNotifier {
bool get showHeaders {
if (sortFactor == EntrySortFactor.size) return false;
if (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.none) return false;
if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false;
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.album);
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album);
final filterByAlbum = filters.any((f) => f is AlbumFilter);
if (albumSections && filterByAlbum) return false;
@ -113,9 +114,33 @@ class CollectionLens with ChangeNotifier {
filterChangeNotifier.notifyListeners();
}
final bool groupBursts = true;
void _applyFilters() {
final entries = source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) {
_groupBursts();
}
}
void _groupBursts() {
final byBurstKey = groupBy<AvesEntry, String?>(_filteredSortedEntries, (entry) => entry.burstKey).whereNotNullKey();
byBurstKey.forEach((burstKey, entries) {
if (entries.length > 1) {
entries.sort(AvesEntry.compareByName);
final mainEntry = entries.first;
final burstEntry = mainEntry.copyWith(burstEntries: entries);
entries.skip(1).toList().forEach((subEntry) {
_filteredSortedEntries.remove(subEntry);
});
final index = _filteredSortedEntries.indexOf(mainEntry);
_filteredSortedEntries.removeAt(index);
_filteredSortedEntries.insert(index, burstEntry);
}
});
}
void _applySort() {
@ -132,10 +157,10 @@ class CollectionLens with ChangeNotifier {
}
}
void _applyGroup() {
void _applySection() {
switch (sortFactor) {
case EntrySortFactor.date:
switch (groupFactor) {
switch (sectionFactor) {
case EntryGroupFactor.album:
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break;
@ -168,11 +193,11 @@ class CollectionLens with ChangeNotifier {
}
// metadata change should also trigger a full refresh
// as dates impact sorting and grouping
// as dates impact sorting and sectioning
void _refresh() {
_applyFilters();
_applySort();
_applyGroup();
_applySection();
}
void _onFavouritesChanged() {
@ -183,21 +208,21 @@ class CollectionLens with ChangeNotifier {
void _onSettingsChanged() {
final newSortFactor = settings.collectionSortFactor;
final newGroupFactor = settings.collectionGroupFactor;
final newSectionFactor = settings.collectionSectionFactor;
final needSort = sortFactor != newSortFactor;
final needGroup = needSort || groupFactor != newGroupFactor;
final needSection = needSort || sectionFactor != newSectionFactor;
if (needSort) {
sortFactor = newSortFactor;
_applySort();
}
if (needGroup) {
groupFactor = newGroupFactor;
_applyGroup();
if (needSection) {
sectionFactor = newSectionFactor;
_applySection();
}
if (needSort || needGroup) {
sortGroupChangeNotifier.notifyListeners();
if (needSort || needSection) {
sortSectionChangeNotifier.notifyListeners();
}
}
@ -206,8 +231,27 @@ class CollectionLens with ChangeNotifier {
}
void onEntryRemoved(Set<AvesEntry> entries) {
if (groupBursts) {
// find impacted burst groups
final obsoleteBurstEntries = <AvesEntry>{};
final burstKeys = entries.map((entry) => entry.burstKey).whereNotNull().toSet();
if (burstKeys.isNotEmpty) {
_filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.burstKey)).forEach((mainEntry) {
final subEntries = mainEntry.burstEntries!;
// remove the deleted sub-entries
subEntries.removeWhere(entries.contains);
if (subEntries.isEmpty) {
// remove the burst entry itself
obsoleteBurstEntries.add(mainEntry);
}
// TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted
});
entries.addAll(obsoleteBurstEntries);
}
}
// we should remove obsolete entries and sections
// but do not apply sort/group
// but do not apply sort/section
// as section order change would surprise the user while browsing
_filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains);

View file

@ -105,7 +105,7 @@ class PlatformMetadataService implements MetadataService {
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
final pageMaps = (result as List).cast<Map>();
final pageMaps = ((result as List?) ?? []).cast<Map>();
if (entry.isMotionPhoto && pageMaps.isNotEmpty) {
final imagePage = pageMaps[0];
imagePage['width'] = entry.width;

View file

@ -302,7 +302,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final value = await showDialog<EntryGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
initialValue: settings.collectionGroupFactor,
initialValue: settings.collectionSectionFactor,
options: {
EntryGroupFactor.album: context.l10n.collectionGroupAlbum,
EntryGroupFactor.month: context.l10n.collectionGroupMonth,
@ -315,7 +315,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
settings.collectionGroupFactor = value;
settings.collectionSectionFactor = value;
}
break;
case CollectionAction.sort:

View file

@ -267,13 +267,13 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
void _registerWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop);
widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop);
widget.scrollController.addListener(_onScrollChange);
}
void _unregisterWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop);
widget.scrollController.removeListener(_onScrollChange);
}

View file

@ -25,7 +25,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
lineBuilder: (context, entry) {
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.groupFactor) {
switch (collection.sectionFactor) {
case EntryGroupFactor.album:
return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),

View file

@ -8,6 +8,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
@ -33,10 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_showDeleteDialog(context);
break;
case EntryAction.share:
final selection = context.read<Selection<AvesEntry>>().selection;
AndroidAppService.shareEntries(selection).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
_share(context);
break;
default:
break;
@ -59,18 +57,31 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
}
void _refreshMetadata(BuildContext context) {
final collection = context.read<CollectionLens>();
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet();
}
void _share(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
collection.source.refreshMetadata(selection.selection);
final selectedItems = _getExpandedSelectedItems(selection);
AndroidAppService.shareEntries(selectedItems).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
}
void _refreshMetadata(BuildContext context) {
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
source.refreshMetadata(selectedItems);
selection.browse();
}
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final collection = context.read<CollectionLens>();
final source = collection.source;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = selection.selection;
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
if (moveType == MoveType.move) {
@ -144,6 +155,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
label: context.l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>();
var targetCollection = collection;
if (collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
@ -179,10 +191,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
Future<void> _showDeleteDialog(BuildContext context) async {
final collection = context.read<CollectionLens>();
final source = collection.source;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = selection.selection;
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length;

View file

@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget {
Widget? _buildHeader(BuildContext context) {
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.groupFactor) {
switch (collection.sectionFactor) {
case EntryGroupFactor.album:
return _buildAlbumHeader(context);
case EntryGroupFactor.month:

View file

@ -76,7 +76,7 @@ class InteractiveThumbnail extends StatelessWidget {
id: collection.id,
listenToSource: false,
);
assert(viewerCollection.sortedEntries.contains(entry));
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
return EntryViewerPage(
collection: viewerCollection,
initialEntry: entry,

View file

@ -11,7 +11,7 @@ class DecoratedThumbnail extends StatelessWidget {
final double tileExtent;
final CollectionLens? collection;
final ValueNotifier<bool>? cancellableNotifier;
final bool selectable, highlightable;
final bool selectable, highlightable, hero;
static final Color borderColor = Colors.grey.shade700;
static final double borderWidth = AvesBorder.borderWidth;
@ -24,6 +24,7 @@ class DecoratedThumbnail extends StatelessWidget {
this.cancellableNotifier,
this.selectable = true,
this.highlightable = true,
this.hero = true,
}) : super(key: key);
@override
@ -33,7 +34,7 @@ class DecoratedThumbnail extends StatelessWidget {
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
final heroTag = hashValues(collection?.id, entry);
final heroTag = hero ? hashValues(collection?.id, entry.uri) : null;
final isSvg = entry.isSvg;
Widget child = ThumbnailImage(
entry: entry,

View file

@ -28,10 +28,10 @@ class ThumbnailEntryOverlay extends StatelessWidget {
const AnimatedImageIcon()
else ...[
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(),
]
],
if (entry.isMultiPage) MultiPageIcon(entry: entry),
];
if (children.isEmpty) return const SizedBox.shrink();
if (children.length == 1) return children.first;

View file

@ -112,10 +112,21 @@ class MultiPageIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
IconData icon;
String? text;
if (entry.isMotionPhoto) {
icon = AIcons.motionPhoto;
} else {
if(entry.isBurst) {
text = '${entry.burstEntries?.length}';
}
icon = AIcons.multiPage;
}
return OverlayIcon(
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
icon: icon,
size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8,
text: text,
);
}
}

View file

@ -28,10 +28,9 @@ class StatsPage extends StatelessWidget {
final CollectionSource source;
final CollectionLens? parentCollection;
late final Set<AvesEntry> entries;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
Set<AvesEntry> get entries => parentCollection?.sortedEntries.toSet() ?? source.visibleEntries;
static const mimeDonutMinWidth = 124.0;
StatsPage({
@ -39,6 +38,7 @@ class StatsPage extends StatelessWidget {
required this.source,
this.parentCollection,
}) : super(key: key) {
entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries;
entries.forEach((entry) {
if (entry.hasAddress) {
final address = entry.addressDetails!;

View file

@ -182,7 +182,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selection = <AvesEntry>{};
if (entry.isMultiPage) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
final multiPageInfo = await entry.getMultiPageInfo();
if (multiPageInfo != null) {
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -12,7 +12,7 @@ class MultiEntryScroller extends StatefulWidget {
final CollectionLens collection;
final PageController pageController;
final ValueChanged<int> onPageChanged;
final void Function(String uri) onViewDisposed;
final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const MultiEntryScroller({
Key? key,
@ -44,27 +44,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
physics: const MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
onPageChanged: widget.onPageChanged,
itemBuilder: (context, index) {
final entry = entries[index];
final mainEntry = entries[index];
Widget? child;
if (entry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
child ??= _buildViewer(entry);
var child = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry),
)
: _buildViewer(mainEntry);
child = AnimatedBuilder(
animation: pageController,
@ -93,17 +80,11 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
}
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size,
builder: (c, mqSize, child) {
return EntryPageView(
key: const Key('imageview'),
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
onDisposed: () => widget.onViewDisposed(mainEntry.uri),
);
},
return EntryPageView(
key: const Key('imageview'),
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry),
);
}
@ -130,25 +111,12 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
Widget build(BuildContext context) {
super.build(context);
Widget? child;
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
child ??= _buildViewer();
var child = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildViewer(pageEntry: pageEntry),
)
: _buildViewer();
return MagnifierGestureDetectorScope(
axis: const [Axis.vertical],
@ -157,15 +125,9 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
}
Widget _buildViewer({AvesEntry? pageEntry}) {
return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size,
builder: (c, mqSize, child) {
return EntryPageView(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
);
},
return EntryPageView(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
);
}

View file

@ -18,7 +18,7 @@ class ViewerVerticalPageView extends StatefulWidget {
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImagePageRequested;
final void Function(String uri) onViewDisposed;
final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const ViewerVerticalPageView({
Key? key,

View file

@ -4,6 +4,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -23,15 +24,13 @@ class EntryViewerPage extends StatelessWidget {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: Provider<VideoConductor>(
create: (context) => VideoConductor(),
dispose: (context, value) => value.dispose(),
child: Provider<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: EntryViewerStack(
collection: collection,
initialEntry: initialEntry,
body: ViewStateConductorProvider(
child: VideoConductorProvider(
child: MultiPageConductorProvider(
child: EntryViewerStack(
collection: collection,
initialEntry: initialEntry,
),
),
),
),
@ -41,3 +40,61 @@ class EntryViewerPage extends StatelessWidget {
);
}
}
class ViewStateConductorProvider extends StatelessWidget {
final Widget? child;
const ViewStateConductorProvider({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProxyProvider<MediaQueryData, ViewStateConductor>(
create: (context) => ViewStateConductor(),
update: (context, mq, value) {
value!.viewportSize = mq.size;
return value;
},
dispose: (context, value) => value.dispose(),
child: child,
);
}
}
class VideoConductorProvider extends StatelessWidget {
final Widget? child;
const VideoConductorProvider({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Provider<VideoConductor>(
create: (context) => VideoConductor(),
dispose: (context, value) => value.dispose(),
child: child,
);
}
}
class MultiPageConductorProvider extends StatelessWidget {
final Widget? child;
const MultiPageConductorProvider({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Provider<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: child,
);
}
}

View file

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
@ -24,17 +23,17 @@ import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryViewerStack extends StatefulWidget {
final CollectionLens? collection;
@ -62,7 +61,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
late Animation<Offset> _bottomOverlayOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late VideoActionDelegate _videoActionDelegate;
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true;
@ -90,7 +88,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
}
// make sure initial entry is actually among the filtered collection entries
final entry = entries.contains(widget.initialEntry) ? widget.initialEntry : entries.firstOrNull;
// `initialEntry` may be a dynamic burst entry from another collection lens
// so it is, strictly speaking, not contained in the lens used by the viewer,
// but it can be found by content ID
final initialEntry = widget.initialEntry;
final entry = entries.firstWhereOrNull((v) => v.contentId == initialEntry.contentId) ?? entries.firstOrNull;
// opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry;
@ -169,6 +171,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
@override
Widget build(BuildContext context) {
final viewStateConductor = context.read<ViewStateConductor>();
return WillPopScope(
onWillPop: () {
if (_currentVerticalPage.value == infoPage) {
@ -186,8 +189,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
} else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry);
}
@ -208,7 +209,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null),
onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
),
_buildTopOverlay(),
_buildBottomOverlay(),
@ -221,23 +222,14 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
);
}
void _updateViewState(String uri, ViewState? viewState) {
final viewStateNotifier = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri)?.item2;
viewStateNotifier?.value = viewState ?? ViewState.zero;
}
Widget _buildTopOverlay() {
Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: _entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink();
return NotificationListener<ShowInfoNotification>(
onNotification: (notification) {
_goToVerticalPage(infoPage);
return true;
},
child: EmbeddedDataOpener(
Widget _buildContent({AvesEntry? pageEntry}) {
return EmbeddedDataOpener(
entry: mainEntry,
child: ViewerTopOverlay(
mainEntry: mainEntry,
@ -245,9 +237,21 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
),
),
);
}
return NotificationListener<ShowInfoNotification>(
onNotification: (notification) {
_goToVerticalPage(infoPage);
return true;
},
child: mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: _buildContent(),
);
},
);
@ -282,14 +286,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
valueListenable: _entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink();
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) {
Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) {
final targetEntry = pageEntry ?? mainEntry;
Widget? child;
// a 360 video is both a video and a panorama but only the video controls are displayed
if (pageEntry.isVideo) {
return Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry),
if (targetEntry.isVideo) {
child = Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay(
entry: pageEntry,
entry: targetEntry,
controller: videoController,
scale: _bottomOverlayScale,
onActionSelected: (action) {
@ -299,40 +306,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
},
),
);
} else if (pageEntry.is360) {
return PanoramaOverlay(
entry: pageEntry,
} else if (targetEntry.is360) {
child = PanoramaOverlay(
entry: targetEntry,
scale: _bottomOverlayScale,
);
}
return null;
return child != null
? ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: child,
)
: null;
}
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
final extraBottomOverlay = multiPageController != null
? StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
if (multiPageInfo == null) return const SizedBox.shrink();
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return _buildExtraBottomOverlay(pageEntry) ?? const SizedBox();
},
);
})
: _buildExtraBottomOverlay(mainEntry);
final extraBottomOverlay = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: multiPageController,
builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(),
)
: _buildExtraBottomOverlay();
return Column(
children: [
if (extraBottomOverlay != null)
ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: extraBottomOverlay,
),
if (extraBottomOverlay != null) extraBottomOverlay,
SlideTransition(
position: _bottomOverlayOffset,
child: ViewerBottomOverlay(
@ -564,7 +562,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
Future<void> _initEntryControllers(AvesEntry? entry) async {
if (entry == null) return;
_initViewStateController(entry);
if (entry.isVideo) {
await _initVideoController(entry);
}
@ -581,20 +578,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
}
}
void _initViewStateController(AvesEntry entry) {
final uri = entry.uri;
var controller = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri);
if (controller != null) {
_viewStateNotifiers.remove(controller);
} else {
controller = Tuple2(uri, ValueNotifier<ViewState>(ViewState.zero));
}
_viewStateNotifiers.insert(0, controller);
while (_viewStateNotifiers.length > 3) {
_viewStateNotifiers.removeLast().item2.dispose();
}
}
Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {});

View file

@ -10,6 +10,8 @@ import 'package:aves/widgets/viewer/info/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -33,9 +35,7 @@ class _InfoPageState extends State<InfoPage> {
final ScrollController _scrollController = ScrollController();
bool _scrollStartFromTop = false;
CollectionLens? get collection => widget.collection;
AvesEntry? get entry => widget.entryNotifier.value;
static const splitScreenWidthThreshold = 600;
@override
Widget build(BuildContext context) {
@ -51,20 +51,31 @@ class _InfoPageState extends State<InfoPage> {
builder: (c, mqWidth, child) {
return ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.entryNotifier,
builder: (context, entry, child) {
return entry != null
? EmbeddedDataOpener(
entry: entry,
child: _InfoPageContent(
collection: collection,
entry: entry,
isScrollingNotifier: widget.isScrollingNotifier,
scrollController: _scrollController,
split: mqWidth > 600,
goToViewer: _goToViewer,
),
)
: const SizedBox.shrink();
builder: (context, mainEntry, child) {
if (mainEntry != null) {
Widget _buildContent({AvesEntry? pageEntry}) {
final targetEntry = pageEntry ?? mainEntry;
return EmbeddedDataOpener(
entry: targetEntry,
child: _InfoPageContent(
collection: widget.collection,
entry: targetEntry,
isScrollingNotifier: widget.isScrollingNotifier,
scrollController: _scrollController,
split: mqWidth > splitScreenWidthThreshold,
goToViewer: _goToViewer,
),
);
}
return mainEntry.isBurst
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: _buildContent();
}
return const SizedBox();
},
);
},

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -24,9 +23,9 @@ class MultiPageController {
set page(int? page) => pageNotifier.value = page;
MultiPageController(this.entry) {
metadataService.getMultiPageInfo(entry).then((value) {
entry.getMultiPageInfo().then((value) {
if (value == null || _disposed) return;
pageNotifier.value = value.defaultPage!.index;
pageNotifier.value = value.defaultPage?.index ?? 0;
_info = value;
_infoStreamController.add(_info);
});

View file

@ -14,6 +14,7 @@ import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -107,30 +108,23 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
_lastEntry = entry;
}
if (_lastEntry == null) return const SizedBox.shrink();
final mainEntry = _lastEntry!;
Widget _buildContent({MultiPageInfo? multiPageInfo, int? page}) => _BottomOverlayContent(
mainEntry: _lastEntry!,
pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry!,
Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
availableWidth: availableWidth,
multiPageController: multiPageController,
);
if (multiPageController == null) return _buildContent();
return StreamBuilder<MultiPageInfo?>(
stream: multiPageController!.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController!.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController!.pageNotifier,
builder: (context, page, child) {
return _buildContent(multiPageInfo: multiPageInfo, page: page);
},
);
},
);
return multiPageController != null
? PageEntryBuilder(
multiPageController: multiPageController!,
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: _buildContent();
},
),
);
@ -370,7 +364,7 @@ class _PositionTitleRow extends StatelessWidget {
// but fail to get information about these pages
final pageCount = multiPageInfo.pageCount;
if (pageCount > 0) {
final page = multiPageInfo.getById(entry.pageId) ?? multiPageInfo.defaultPage;
final page = multiPageInfo.getById(entry.pageId ?? entry.contentId) ?? multiPageInfo.defaultPage;
pagePosition = '${(page?.index ?? 0) + 1}/$pageCount';
}
}

View file

@ -5,8 +5,10 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MultiPageOverlay extends StatefulWidget {
final MultiPageController controller;
@ -126,6 +128,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
cancellableNotifier: _cancellableNotifier,
selectable: false,
highlightable: false,
hero: false,
),
),
IgnorePointer(
@ -148,9 +151,18 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
);
}
void _setPage(int newPage) {
final oldPage = controller.page;
if (oldPage == newPage) return;
final oldPageEntry = controller.info!.getPageEntryByIndex(oldPage);
controller.page = newPage;
context.read<ViewStateConductor>().reset(oldPageEntry);
}
Future<void> _goToPage(int page) async {
_syncScroll = false;
controller.page = page;
_setPage(page);
await _scrollController.animateTo(
pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation,
@ -161,7 +173,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
void _onScrollChange() {
if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset);
_setPage(scrollOffsetToPage(_scrollController.offset));
}
}

View file

@ -1,7 +1,6 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -12,7 +11,8 @@ import 'package:aves/widgets/viewer/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/minimap.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -23,7 +23,6 @@ class ViewerTopOverlay extends StatelessWidget {
final Animation<double> scale;
final EdgeInsets? viewInsets, viewPadding;
final bool canToggleFavourite;
final ValueNotifier<ViewState>? viewStateNotifier;
static const double outerPadding = 8;
static const double innerPadding = 8;
@ -35,7 +34,6 @@ class ViewerTopOverlay extends StatelessWidget {
required this.canToggleFavourite,
required this.viewInsets,
required this.viewPadding,
required this.viewStateNotifier,
}) : super(key: key);
@override
@ -50,33 +48,19 @@ class ViewerTopOverlay extends StatelessWidget {
final buttonWidth = OverlayButton.getSize(context);
final availableCount = ((mqWidth - outerPadding * 2 - buttonWidth) / (buttonWidth + innerPadding)).floor();
Widget? child;
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
return child ??= _buildOverlay(availableCount, mainEntry);
return mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildOverlay(context, availableCount, mainEntry, pageEntry: pageEntry),
)
: _buildOverlay(context, availableCount, mainEntry);
},
),
),
);
}
Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
pageEntry ??= mainEntry;
bool _canDo(EntryAction action) {
@ -130,22 +114,25 @@ class ViewerTopOverlay extends StatelessWidget {
},
);
return settings.showOverlayMinimap && viewStateNotifier != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buttonRow,
const SizedBox(height: 8),
FadeTransition(
opacity: scale,
child: Minimap(
entry: pageEntry,
viewStateNotifier: viewStateNotifier!,
),
)
],
if (settings.showOverlayMinimap) {
final viewStateConductor = context.read<ViewStateConductor>();
final viewStateNotifier = viewStateConductor.getOrCreateController(pageEntry);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buttonRow,
const SizedBox(height: 8),
FadeTransition(
opacity: scale,
child: Minimap(
entry: pageEntry,
viewStateNotifier: viewStateNotifier,
),
)
: buttonRow;
],
);
}
return buttonRow;
}
}
@ -154,6 +141,8 @@ class _TopOverlayRow extends StatelessWidget {
final Animation<double> scale;
final AvesEntry mainEntry, pageEntry;
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
const _TopOverlayRow({
Key? key,
required this.quickActions,
@ -204,7 +193,7 @@ class _TopOverlayRow extends StatelessWidget {
switch (action) {
case EntryAction.toggleFavourite:
child = _FavouriteToggler(
entry: mainEntry,
entry: favouriteTargetEntry,
onPressed: onPressed,
);
break;
@ -250,7 +239,7 @@ class _TopOverlayRow extends StatelessWidget {
// in app actions
case EntryAction.toggleFavourite:
child = _FavouriteToggler(
entry: mainEntry,
entry: favouriteTargetEntry,
isMenuItem: true,
);
break;
@ -300,7 +289,7 @@ class _TopOverlayRow extends StatelessWidget {
void _onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry;
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;

View file

@ -0,0 +1,35 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:flutter/widgets.dart';
class PageEntryBuilder extends StatelessWidget {
final MultiPageController? multiPageController;
final Widget Function(AvesEntry? pageEntry) builder;
const PageEntryBuilder({
Key? key,
required this.multiPageController,
required this.builder,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = multiPageController;
return controller != null
? StreamBuilder<MultiPageInfo?>(
stream: controller.infoStream,
builder: (context, snapshot) {
final multiPageInfo = controller.info;
return ValueListenableBuilder<int?>(
valueListenable: controller.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo?.getPageEntryByIndex(page);
return builder(pageEntry);
},
);
},
)
: builder(null);
}
}

View file

@ -48,7 +48,7 @@ class EntryPrinter with FeedbackMixin {
}
if (entry.isMultiPage && !entry.isMotionPhoto) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
final multiPageInfo = await entry.getMultiPageInfo();
if (multiPageInfo != null) {
final pageCount = multiPageInfo.pageCount;
if (pageCount > 1) {

View file

@ -0,0 +1,59 @@
import 'package:aves/model/entry.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:tuple/tuple.dart';
class ViewStateConductor {
final List<Tuple2<String, ValueNotifier<ViewState>>> _controllers = [];
Size _viewportSize = Size.zero;
static const maxControllerCount = 3;
Future<void> dispose() async {
_controllers.clear();
}
set viewportSize(Size size) => _viewportSize = size;
ValueNotifier<ViewState> getOrCreateController(AvesEntry entry) {
var controller = _controllers.firstOrNull;
if (controller == null || controller.item1 != entry.uri) {
controller = _controllers.firstWhereOrNull((kv) => kv.item1 == entry.uri);
if (controller != null) {
_controllers.remove(controller);
} else {
// try to initialize the view state to match magnifier initial state
const initialScale = ScaleLevel(ref: ScaleReference.contained);
final initialValue = ViewState(
Offset.zero,
ScaleBoundaries(
minScale: initialScale,
maxScale: initialScale,
initialScale: initialScale,
viewportSize: _viewportSize,
childSize: entry.displaySize,
).initialScale,
_viewportSize,
);
controller = Tuple2(entry.uri, ValueNotifier<ViewState>(initialValue));
}
_controllers.insert(0, controller);
while (_controllers.length > maxControllerCount) {
_controllers.removeLast().item2.dispose();
}
}
return controller.item2;
}
void reset(AvesEntry entry) {
final uris = <AvesEntry>{
entry,
...?entry.burstEntries,
}.map((v) => v.uri).toSet();
_controllers.removeWhere((kv) => uris.contains(kv.item1));
}
}

View file

@ -14,6 +14,7 @@ import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:aves/widgets/viewer/visual/error.dart';
import 'package:aves/widgets/viewer/visual/raster.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
@ -26,7 +27,6 @@ import 'package:provider/provider.dart';
class EntryPageView extends StatefulWidget {
final AvesEntry mainEntry, pageEntry;
final Size viewportSize;
final VoidCallback? onDisposed;
static const decorationCheckSize = 20.0;
@ -35,7 +35,6 @@ class EntryPageView extends StatefulWidget {
Key? key,
required this.mainEntry,
required this.pageEntry,
required this.viewportSize,
this.onDisposed,
}) : super(key: key);
@ -44,16 +43,14 @@ class EntryPageView extends StatefulWidget {
}
class _EntryPageViewState extends State<EntryPageView> {
late ValueNotifier<ViewState> _viewStateNotifier;
late MagnifierController _magnifierController;
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
final List<StreamSubscription> _subscriptions = [];
AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get entry => widget.pageEntry;
Size get viewportSize => widget.viewportSize;
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
static const minScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = ScaleLevel(factor: 2.0);
@ -68,9 +65,7 @@ class _EntryPageViewState extends State<EntryPageView> {
void didUpdateWidget(covariant EntryPageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pageEntry.uri != widget.pageEntry.uri || oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) {
// do not reset the magnifier view state unless main entry or page entry dimensions change,
// in effect locking the zoom & position when browsing entry pages of the same size
if (oldWidget.pageEntry != widget.pageEntry) {
_unregisterWidget();
_registerWidget();
}
@ -84,19 +79,7 @@ class _EntryPageViewState extends State<EntryPageView> {
}
void _registerWidget() {
// try to initialize the view state to match magnifier initial state
_viewStateNotifier.value = ViewState(
Offset.zero,
ScaleBoundaries(
minScale: minScale,
maxScale: maxScale,
initialScale: initialScale,
viewportSize: viewportSize,
childSize: entry.displaySize,
).initialScale,
viewportSize,
);
_viewStateNotifier = context.read<ViewStateConductor>().getOrCreateController(entry);
_magnifierController = MagnifierController();
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
@ -134,7 +117,7 @@ class _EntryPageViewState extends State<EntryPageView> {
return Consumer<HeroInfo?>(
builder: (context, info, child) => Hero(
tag: info != null && info.entry == mainEntry ? hashValues(info.collectionId, mainEntry) : hashCode,
tag: info != null && info.entry == mainEntry ? hashValues(info.collectionId, mainEntry.uri) : hashCode,
transitionOnUserGestures: true,
child: child!,
),
@ -241,7 +224,7 @@ class _EntryPageViewState extends State<EntryPageView> {
}) {
return Magnifier(
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated)
key: ValueKey('${entry.pageId}_${entry.dateModifiedSecs}'),
key: ValueKey('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'),
controller: _magnifierController,
childSize: displaySize ?? entry.displaySize,
minScale: minScale,
@ -260,14 +243,12 @@ class _EntryPageViewState extends State<EntryPageView> {
final current = _viewStateNotifier.value;
final viewState = ViewState(v.position, v.scale, current.viewportSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
final current = _viewStateNotifier.value;
final viewState = ViewState(current.position, current.scale, v.viewportSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
static ScaleState _vectorScaleStateCycle(ScaleState actual) {

View file

@ -13,13 +13,3 @@ class ViewState {
@override
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}';
}
class ViewStateNotification extends Notification {
final String uri;
final ViewState viewState;
const ViewStateNotification(this.uri, this.viewState);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
}