Compare commits

...

2 Commits

Author SHA1 Message Date
mertalev
e176917e8d add file sharing permission 2026-04-08 17:47:36 -04:00
mertalev
edbae35ea0 thumbnail bench 2026-04-08 17:45:02 -04:00
6 changed files with 249 additions and 7 deletions

View File

@@ -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

View File

@@ -119,7 +119,9 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@@ -147,7 +147,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
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<AssetViewer> {
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();

View File

@@ -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<ImageType>(maxSamples: 8192, values: ImageType.values);
int thumbnailId = 0;
class Thumbnail extends StatefulWidget {
final ImageProvider? imageProvider;
@@ -111,8 +116,11 @@ class _ThumbnailState extends State<Thumbnail> 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<Thumbnail> 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<Thumbnail> with SingleTickerProviderStateMix
_stopListeningToImageStream();
},
);
stopwatch.start();
imageStream.addListener(imageStreamListener);
}

View File

@@ -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(),
(SerialTapGestureRecognizer tap) {
tap.onSerialTapDown = (details) {
if (details.count == 3) {
_toggleAutoScroll();
}
};
},
),
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {

View File

@@ -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<T extends Enum> {
final int _stride;
final int _strideMask;
final List<T> _values;
final Int64List _counts;
final Int64List _data;
final Stopwatch _clock;
static final _log = Logger('Histogram');
Histogram({required int maxSamples, required List<T> 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<File> 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];
}
}