From b86faea0602b109157a73f4863da8bb4a43d3069 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 7 Nov 2020 19:48:46 +0900 Subject: [PATCH] tiling: task management debug: task queue overlay --- lib/services/image_file_service.dart | 4 +- lib/services/service_policy.dart | 17 +++ lib/widgets/collection/thumbnail/raster.dart | 2 - .../image_providers/region_provider.dart | 124 ++++++++++++++++++ .../image_providers/thumbnail_provider.dart | 16 ++- .../image_providers/uri_region_provider.dart | 81 ------------ lib/widgets/debug/app_debug_page.dart | 19 +++ lib/widgets/debug/overlay.dart | 39 ++++++ lib/widgets/fullscreen/tiled_view.dart | 88 +++++++++---- pubspec.yaml | 3 + 10 files changed, 281 insertions(+), 112 deletions(-) create mode 100644 lib/widgets/common/image_providers/region_provider.dart delete mode 100644 lib/widgets/common/image_providers/uri_region_provider.dart create mode 100644 lib/widgets/debug/overlay.dart diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 0b5b55b81..3b7d3cd03 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -193,9 +193,11 @@ class ImageFileService { } } + static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]); + static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); - static Future resumeThumbnail(Object taskKey) => servicePolicy.resume(taskKey); + static Future resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); static Stream delete(Iterable entries) { try { diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index b524b832c..6794a75e5 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -7,10 +7,13 @@ import 'package:tuple/tuple.dart'; final ServicePolicy servicePolicy = ServicePolicy._private(); class ServicePolicy { + final StreamController _queueStreamController = StreamController.broadcast(); final Map> _paused = {}; final SplayTreeMap> _queues = SplayTreeMap(); _Task _running; + Stream get queueStream => _queueStreamController.stream; + ServicePolicy._private(); Future call( @@ -60,6 +63,7 @@ class ServicePolicy { Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>()); void _pickNext() { + _notifyQueueState(); if (_running != null) return; final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value; _running = queue?.removeFirst(); @@ -90,6 +94,13 @@ class ServicePolicy { } bool isPaused(Object key) => _paused.containsKey(key); + + void _notifyQueueState() { + if (!_queueStreamController.hasListener) return; + + final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length))); + _queueStreamController.add(QueueState(queueByPriority)); + } } class _Task { @@ -110,3 +121,9 @@ class ServiceCallPriority { static const int getMetadata = 1000; static const int getLocation = 1000; } + +class QueueState { + final Map queueByPriority; + + const QueueState(this.queueByPriority); +} diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 78544997a..5a632ed35 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -71,8 +71,6 @@ class _ThumbnailRasterImageState extends State { _pauseProvider(); } - bool get isSupported => entry.canDecode; - void _initProvider() { if (!entry.canDecode) return; diff --git a/lib/widgets/common/image_providers/region_provider.dart b/lib/widgets/common/image_providers/region_provider.dart new file mode 100644 index 000000000..a6a8c86d7 --- /dev/null +++ b/lib/widgets/common/image_providers/region_provider.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:ui' as ui show Codec; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class RegionProvider extends ImageProvider { + final RegionProviderKey key; + + RegionProvider(this.key) : assert(key != null); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(key); + } + + @override + ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + informationCollector: () sync* { + yield ErrorDescription('uri=${key.uri}, rect=${key.rect}'); + }, + ); + } + + Future _loadAsync(RegionProviderKey key, DecoderCallback decode) async { + final uri = key.uri; + final mimeType = key.mimeType; + try { + final bytes = await ImageFileService.getRegion( + uri, + mimeType, + key.rotationDegrees, + key.isFlipped, + key.sampleSize, + key.rect, + taskKey: key, + ); + if (bytes == null) { + throw StateError('$uri ($mimeType) region loading failed'); + } + return await decode(bytes); + } catch (error) { + debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); + throw StateError('$mimeType region decoding failed'); + } + } + + @override + void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) { + ImageFileService.resumeLoading(key); + super.resolveStreamForKey(configuration, stream, key, handleError); + } + + void pause() => ImageFileService.cancelRegion(key); +} + +class RegionProviderKey { + final String uri, mimeType; + final int rotationDegrees, sampleSize; + final bool isFlipped; + final Rect rect; + final double scale; + + const RegionProviderKey({ + @required this.uri, + @required this.mimeType, + @required this.rotationDegrees, + @required this.isFlipped, + @required this.sampleSize, + @required this.rect, + this.scale = 1.0, + }) : assert(uri != null), + assert(mimeType != null), + assert(rotationDegrees != null), + assert(isFlipped != null), + assert(sampleSize != null), + assert(rect != null), + assert(scale != null); + + // do not store the entry as it is, because the key should be constant + // but the entry attributes may change over time + factory RegionProviderKey.fromEntry( + ImageEntry entry, { + @required int sampleSize, + @required Rect rect, + }) { + return RegionProviderKey( + uri: entry.uri, + mimeType: entry.mimeType, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + sampleSize: sampleSize, + rect: rect, + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.rect == rect && other.scale == scale; + } + + @override + int get hashCode => hashValues( + uri, + mimeType, + rotationDegrees, + isFlipped, + mimeType, + sampleSize, + rect, + scale, + ); + + @override + String toString() { + return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, rect=$rect, scale=$scale)'; + } +} diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart index 0a7f6784f..e726ad0b0 100644 --- a/lib/widgets/common/image_providers/thumbnail_provider.dart +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -30,8 +30,8 @@ class ThumbnailProvider extends ImageProvider { } Future _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { - var uri = key.uri; - var mimeType = key.mimeType; + final uri = key.uri; + final mimeType = key.mimeType; try { final bytes = await ImageFileService.getThumbnail( uri, @@ -55,7 +55,7 @@ class ThumbnailProvider extends ImageProvider { @override void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { - ImageFileService.resumeThumbnail(key); + ImageFileService.resumeLoading(key); super.resolveStreamForKey(configuration, stream, key, handleError); } @@ -105,7 +105,15 @@ class ThumbnailProviderKey { } @override - int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale); + int get hashCode => hashValues( + uri, + mimeType, + dateModifiedSecs, + rotationDegrees, + isFlipped, + extent, + scale, + ); @override String toString() { diff --git a/lib/widgets/common/image_providers/uri_region_provider.dart b/lib/widgets/common/image_providers/uri_region_provider.dart deleted file mode 100644 index e9795bb5f..000000000 --- a/lib/widgets/common/image_providers/uri_region_provider.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui show Codec; - -import 'package:aves/services/image_file_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:pedantic/pedantic.dart'; - -class UriRegion extends ImageProvider { - const UriRegion({ - @required this.uri, - @required this.mimeType, - @required this.rotationDegrees, - @required this.isFlipped, - @required this.sampleSize, - @required this.rect, - this.scale = 1.0, - }) : assert(uri != null), - assert(scale != null); - - final String uri, mimeType; - final int rotationDegrees, sampleSize; - final bool isFlipped; - final Rect rect; - final double scale; - - @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(this); - } - - @override - ImageStreamCompleter load(UriRegion key, DecoderCallback decode) { - final chunkEvents = StreamController(); - - return MultiFrameImageStreamCompleter( - codec: _loadAsync(key, decode, chunkEvents), - scale: key.scale, - chunkEvents: chunkEvents.stream, - informationCollector: () sync* { - yield ErrorDescription('uri=$uri, mimeType=$mimeType'); - }, - ); - } - - Future _loadAsync(UriRegion key, DecoderCallback decode, StreamController chunkEvents) async { - assert(key == this); - - try { - final bytes = await ImageFileService.getRegion( - uri, - mimeType, - rotationDegrees, - isFlipped, - sampleSize, - rect, - ); - if (bytes == null) { - throw StateError('$uri ($mimeType) loading failed'); - } - return await decode(bytes); - } catch (error) { - debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); - throw StateError('$mimeType decoding failed'); - } finally { - unawaited(chunkEvents.close()); - } - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is UriRegion && other.uri == uri && other.sampleSize == sampleSize && other.rect == rect && other.scale == scale; - } - - @override - int get hashCode => hashValues(uri, sampleSize, rect, scale); - - @override - String toString() => '${objectRuntimeType(this, 'UriRegion')}(uri=$uri, mimeType=$mimeType, scale=$scale)'; -} diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index e08623b5d..2ae163bf1 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -6,6 +6,7 @@ import 'package:aves/widgets/debug/android_env.dart'; import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/database.dart'; import 'package:aves/widgets/debug/firebase.dart'; +import 'package:aves/widgets/debug/overlay.dart'; import 'package:aves/widgets/debug/settings.dart'; import 'package:aves/widgets/debug/storage.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; @@ -26,6 +27,8 @@ class AppDebugPage extends StatefulWidget { class AppDebugPageState extends State { List get entries => widget.source.rawEntries; + static OverlayEntry _taskQueueOverlayEntry; + @override Widget build(BuildContext context) { return MediaQueryDataProvider( @@ -70,6 +73,22 @@ class AppDebugPageState extends State { divisions: 9, label: '$timeDilation', ), + SwitchListTile( + value: _taskQueueOverlayEntry != null, + onChanged: (v) { + _taskQueueOverlayEntry?.remove(); + if (v) { + _taskQueueOverlayEntry = OverlayEntry( + builder: (context) => DebugTaskQueueOverlay(), + ); + Overlay.of(context).insert(_taskQueueOverlayEntry); + } else { + _taskQueueOverlayEntry = null; + } + setState(() {}); + }, + title: Text('Show tasks overlay'), + ), Divider(), Padding( padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart new file mode 100644 index 000000000..6d90d9038 --- /dev/null +++ b/lib/widgets/debug/overlay.dart @@ -0,0 +1,39 @@ +import 'package:aves/services/service_policy.dart'; +import 'package:flutter/material.dart'; + +class DebugTaskQueueOverlay extends StatelessWidget { + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: DefaultTextStyle( + style: TextStyle(), + child: Align( + alignment: AlignmentDirectional.bottomStart, + child: SafeArea( + child: Container( + color: Colors.indigo[900].withAlpha(0xCC), + margin: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + padding: EdgeInsets.all(8), + child: StreamBuilder( + stream: servicePolicy.queueStream, + builder: (context, snapshot) { + if (snapshot.hasError) return SizedBox.shrink(); + final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []); + queuedEntries.sort((a, b) => a.key.compareTo(b.key)); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(queuedEntries.map((kv) => '${kv.key}: ${kv.value}').join(', ')), + ], + ); + }), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index 732f9c8ad..f4ae2e24b 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/math_utils.dart'; -import 'package:aves/widgets/common/image_providers/uri_region_provider.dart'; +import 'package:aves/widgets/common/image_providers/region_provider.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -99,7 +99,7 @@ class _TiledImageViewState extends State { ); final viewRect = (viewOrigin & viewportSize).inflate(preFetchMargin); - final tiles = []; + final tiles = []; var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { final layerRegionSize = Size.square(_tileSide * sampleSize); @@ -159,7 +159,7 @@ class _TiledImageViewState extends State { } } -class RegionTile extends StatelessWidget { +class RegionTile extends StatefulWidget { final ImageEntry entry; // `tileRect` uses Flutter view coordinates @@ -174,35 +174,67 @@ class RegionTile extends StatelessWidget { @required this.sampleSize, }); + @override + _RegionTileState createState() => _RegionTileState(); +} + +class _RegionTileState extends State { + RegionProvider _provider; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(RegionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(RegionTile widget) { + _initProvider(); + } + + void _unregisterWidget(RegionTile widget) { + _pauseProvider(); + } + + void _initProvider() { + if (!entry.canDecode) return; + + _provider = RegionProvider(RegionProviderKey.fromEntry( + entry, + sampleSize: widget.sampleSize, + rect: widget.regionRect, + )); + } + + void _pauseProvider() => _provider?.pause(); + @override Widget build(BuildContext context) { + final tileRect = widget.tileRect; + Widget child = Image( - image: UriRegion( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, - isFlipped: entry.isFlipped, - sampleSize: sampleSize, - rect: regionRect, - ), + image: _provider, width: tileRect.width, height: tileRect.height, fit: BoxFit.fill, - // TODO TLAD remove when done with tiling - // color: Color.fromARGB((0xff / sampleSize).floor(), 0, 0, 0xff), - // colorBlendMode: BlendMode.color, ); - // child = Container( - // foregroundDecoration: BoxDecoration( - // border: Border.all( - // color: Colors.cyan, - // ), - // ), - // // child: Text('$sampleSize'), - // child: child, - // ); - // apply EXIF orientation final quarterTurns = entry.rotationDegrees ~/ 90; if (entry.isFlipped) { @@ -230,4 +262,12 @@ class RegionTile extends StatelessWidget { child: child, ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('contentId', widget.entry.contentId)); + properties.add(IntProperty('sampleSize', widget.sampleSize)); + properties.add(DiagnosticsProperty('regionRect', widget.regionRect)); + } } diff --git a/pubspec.yaml b/pubspec.yaml index be465a894..232c4e1b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,9 @@ version: 1.2.5+31 # - does not support AC3 (by default, but possible by custom build) # - can play if only the video or audio stream is supported +environment: + sdk: ">=2.7.0 <3.0.0" + dependencies: flutter: sdk: flutter