diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index e9d65d3113..3c14efac56 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -146,7 +146,7 @@ class URLSessionManager: NSObject { private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { let config = URLSessionConfiguration.default - config.urlCache = urlCache + // config.urlCache = urlCache config.httpCookieStorage = cookieStorage config.httpMaximumConnectionsPerHost = 64 config.timeoutIntervalForRequest = 60 diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 3308ae8295..300cd2c236 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -147,7 +147,7 @@ class _AssetViewerState extends ConsumerState { } void _onAssetInit(Duration timeStamp) { - _preloader.preload(widget.initialIndex, context.sizeData); + // _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); } @@ -158,7 +158,7 @@ class _AssetViewerState extends ConsumerState { if (asset == null) return; AssetViewer._setAsset(ref, asset); - _preloader.preload(index, context.sizeData); + // _preloader.preload(index, context.sizeData); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 70a9057e12..299208b9c4 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -8,11 +8,16 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/utils/image_load_histogram.dart'; import 'package:logging/logging.dart'; final log = Logger('ThumbnailWidget'); -enum ThumbhashMode { enabled, disabled, only } +enum ImageType { thumbnail } + +final remoteImageHistogram = Histogram(maxSamples: 8192, values: ImageType.values); + +int thumbnailId = 0; class Thumbnail extends StatefulWidget { final ImageProvider? imageProvider; @@ -111,8 +116,11 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix if (imageProvider == null) return; final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty); + final stopwatch = Stopwatch(); + final curThumbnailId = thumbnailId++; final imageStreamListener = _imageStreamListener = ImageStreamListener( (ImageInfo imageInfo, bool synchronousCall) { + stopwatch.stop(); _stopListeningToThumbhashStream(); if (!mounted) { imageInfo.dispose(); @@ -123,7 +131,27 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix return; } - if ((synchronousCall && _providerImage == null) || !_isVisible()) { + final renderObject = context.findRenderObject() as RenderBox?; + final double topLeft; + final double bottomRight; + final double contextHeight = context.height; + if (renderObject == null || !renderObject.attached) { + topLeft = double.maxFinite; + bottomRight = double.maxFinite; + } else { + topLeft = renderObject.localToGlobal(Offset.zero).dy; + bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height)).dy; + } + remoteImageHistogram.record( + ImageType.thumbnail, + stopwatch.elapsedMicroseconds, + topLeft.toInt(), + bottomRight.toInt(), + contextHeight.toInt(), + curThumbnailId, + ); + + if ((synchronousCall && _providerImage == null) || !(topLeft < contextHeight && bottomRight > 0)) { _fadeController.value = 1.0; } else if (_fadeController.isAnimating) { _fadeController.forward(); @@ -146,6 +174,7 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix _stopListeningToImageStream(); }, ); + stopwatch.start(); imageStream.addListener(imageStreamListener); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 8d494a8452..04fec11533 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; @@ -140,10 +142,14 @@ class _SliverTimeline extends ConsumerStatefulWidget { ConsumerState createState() => _SliverTimelineState(); } -class _SliverTimelineState extends ConsumerState<_SliverTimeline> { +class _SliverTimelineState extends ConsumerState<_SliverTimeline> with SingleTickerProviderStateMixin { late final ScrollController _scrollController; StreamSubscription? _eventSubscription; + Ticker? _autoScrollTicker; + Duration _lastTickTime = Duration.zero; + static const _autoScrollVelocity = 4800.0; // pixels per second + // Drag selection state bool _dragging = false; TimelineAssetIndex? _dragAnchorIndex; @@ -246,11 +252,52 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void dispose() { + _stopAutoScroll(); _scrollController.dispose(); _eventSubscription?.cancel(); super.dispose(); } + void _toggleAutoScroll() { + if (_autoScrollTicker?.isActive ?? false) { + _stopAutoScroll(); + } else { + _startAutoScroll(); + } + } + + void _startAutoScroll() { + _lastTickTime = Duration.zero; + _autoScrollTicker = createTicker(_onAutoScrollTick)..start(); + } + + void _stopAutoScroll() { + _autoScrollTicker?.stop(); + _autoScrollTicker?.dispose(); + _autoScrollTicker = null; + } + + void _onAutoScrollTick(Duration elapsed) { + if (_lastTickTime == Duration.zero) { + _lastTickTime = elapsed; + return; + } + + final deltaSeconds = (elapsed - _lastTickTime).inMicroseconds / 1000000.0; + _lastTickTime = elapsed; + + final newOffset = _scrollController.offset + (_autoScrollVelocity * deltaSeconds); + final maxOffset = _scrollController.position.maxScrollExtent; + if (newOffset >= maxOffset || remoteImageHistogram.count(ImageType.thumbnail) >= remoteImageHistogram.maxSamples) { + _scrollController.jumpTo(newOffset.clamp(0, maxOffset)); + _stopAutoScroll(); + remoteImageHistogram.logAll(); + remoteImageHistogram.save(); + } else { + _scrollController.jumpTo(newOffset); + } + } + void _scrollToDate(DateTime date) { final asyncSegments = ref.read(timelineSegmentProvider); asyncSegments.whenData((segments) { @@ -434,6 +481,16 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { controller: _scrollController, child: RawGestureDetector( gestures: { + SerialTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => SerialTapGestureRecognizer(), + (SerialTapGestureRecognizer tap) { + tap.onSerialTapDown = (details) { + if (details.count == 3) { + _toggleAutoScroll(); + } + }; + }, + ), CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => CustomScaleGestureRecognizer(), (CustomScaleGestureRecognizer scale) { diff --git a/mobile/lib/utils/image_load_histogram.dart b/mobile/lib/utils/image_load_histogram.dart new file mode 100644 index 0000000000..e2236223c7 --- /dev/null +++ b/mobile/lib/utils/image_load_histogram.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Ring buffer histogram for performance profiling. +class Histogram { + final int _stride; + final int _strideMask; + final List _values; + final Int64List _counts; + final Int64List _data; + final Stopwatch _clock; + static final _log = Logger('Histogram'); + + Histogram({required int maxSamples, required List values}) + : assert(maxSamples & (maxSamples - 1) == 0, 'maxSamples must be power of 2'), + _stride = maxSamples, + _strideMask = maxSamples - 1, + _values = values, + _counts = Int64List(values.length), + _data = Int64List(maxSamples * values.length * 6), + _clock = Stopwatch()..start(); + + @pragma("vm:prefer-inline") + @pragma("vm:unsafe:no-bounds-checks") + void record(T type, int microseconds, int topLeft, int bottomRight, int contextHeight, int id) { + final i = type.index; + final count = _counts[i]; + final slot = count & _strideMask; + + final offset = (i * _stride + slot) * 6; + _data[offset] = microseconds; + _data[offset + 1] = _clock.elapsedMicroseconds; + _data[offset + 2] = topLeft; + _data[offset + 3] = bottomRight; + _data[offset + 4] = contextHeight; + _data[offset + 5] = id; + _counts[i] = count + 1; + } + + int count(T type) => _counts[type.index].clamp(0, _stride); + + int get maxSamples => _stride; + + @pragma("vm:unsafe:no-bounds-checks") + void log(T type) { + final index = type.index; + final total = _counts[index]; + final count = min(total, _stride); + if (count == 0) return; + + final baseOffset = index * _stride * 6; + final scratch = Int64List(count); + + for (int i = 0; i < count; i++) { + scratch[i] = _data[baseOffset + i * 6]; + } + scratch.sort(); + + int sum = 0; + for (int i = 0; i < count; i++) { + sum += scratch[i]; + } + + _log.info( + '${type.name} (n=$total, sampled=$count) - ' + 'Avg: ${(sum / count / 1000.0).toStringAsFixed(2)}ms, ' + 'Min: ${(scratch[0] / 1000.0).toStringAsFixed(2)}ms, ' + 'Max: ${(scratch[count - 1] / 1000.0).toStringAsFixed(2)}ms, ' + 'P25: ${(_percentile(scratch, count, 0.25) / 1000.0).toStringAsFixed(2)}ms, ' + 'P50: ${(_percentile(scratch, count, 0.50) / 1000.0).toStringAsFixed(2)}ms, ' + 'P75: ${(_percentile(scratch, count, 0.75) / 1000.0).toStringAsFixed(2)}ms, ' + 'P90: ${(_percentile(scratch, count, 0.90) / 1000.0).toStringAsFixed(2)}ms, ' + 'P95: ${(_percentile(scratch, count, 0.95) / 1000.0).toStringAsFixed(2)}ms, ' + 'P99: ${(_percentile(scratch, count, 0.99) / 1000.0).toStringAsFixed(2)}ms', + ); + } + + void logAll() { + for (final value in _values) { + log(value); + } + } + + @pragma("vm:unsafe:no-bounds-checks") + (Int64List, Int64List, Int64List, Int64List, Int64List, Int64List) getSamples(T type) { + final index = type.index; + final count = min(_counts[index], _stride); + final samples = Int64List(count); + final timestamps = Int64List(count); + final topLeft = Int64List(count); + final bottomRight = Int64List(count); + final contextHeight = Int64List(count); + final id = Int64List(count); + + final baseOffset = index * _stride * 6; + for (int i = 0; i < count; i++) { + samples[i] = _data[baseOffset + i * 6]; + timestamps[i] = _data[baseOffset + i * 6 + 1]; + topLeft[i] = _data[baseOffset + i * 6 + 2]; + bottomRight[i] = _data[baseOffset + i * 6 + 3]; + contextHeight[i] = _data[baseOffset + i * 6 + 4]; + id[i] = _data[baseOffset + i * 6 + 5]; + } + return (samples, timestamps, topLeft, bottomRight, contextHeight, id); + } + + @pragma("vm:unsafe:no-bounds-checks") + Future save({bool share = true}) async { + final dir = await getApplicationDocumentsDirectory(); + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final file = File('${dir.path}/samples_$timestamp.json'); + + final data = {}; + for (int i = 0; i < _counts.length; i++) { + final name = _values[i].name; + final (samples, timestamps, topLeft, bottomRight, contextHeight, id) = getSamples(_values[i]); + data['${name}_us'] = samples; + data['${name}_ts'] = timestamps; + data['${name}_top_left'] = topLeft; + data['${name}_bottom_right'] = bottomRight; + data['${name}_context_height'] = contextHeight; + data['${name}_id'] = id; + } + data['timestamp'] = DateTime.now().toIso8601String(); + await file.writeAsString(jsonEncode(data)); + _log.info('Saved samples to ${file.path}'); + + if (share) { + await Share.shareXFiles([XFile(file.path)]); + } + + return file; + } + + void reset(T type) { + _counts[type.index] = 0; + } + + void resetAll() { + _counts.fillRange(0, _counts.length, 0); + } + + @pragma("vm:prefer-inline") + int _percentile(Int64List sorted, int count, double p) { + final idx = ((count - 1) * p).round(); + return sorted[idx]; + } +}