diff --git a/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart b/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart index 007a5b8242..3d523ad2ca 100644 --- a/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart +++ b/mobile/apps/photos/lib/ui/settings/debug/debug_section_widget.dart @@ -9,12 +9,20 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; +import 'package:photos/ui/components/toggle_switch_widget.dart'; import 'package:photos/ui/notification/toast.dart'; import 'package:photos/ui/settings/common_settings.dart'; +import 'package:photos/ui/settings/debug/local_thumbnail_config_screen.dart'; +import 'package:photos/utils/navigation_util.dart'; -class DebugSectionWidget extends StatelessWidget { +class DebugSectionWidget extends StatefulWidget { const DebugSectionWidget({super.key}); + @override + State createState() => _DebugSectionWidgetState(); +} + +class _DebugSectionWidgetState extends State { @override Widget build(BuildContext context) { return ExpandableMenuItemWidget( @@ -27,6 +35,43 @@ class DebugSectionWidget extends StatelessWidget { Widget _getSectionOptions(BuildContext context) { return Column( children: [ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Show local ID over thumbnails", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingWidget: ToggleSwitchWidget( + value: () => localSettings.showLocalIDOverThumbnails, + onChanged: () async { + await localSettings.setShowLocalIDOverThumbnails( + !localSettings.showLocalIDOverThumbnails, + ); + setState(() {}); + showShortToast( + context, + localSettings.showLocalIDOverThumbnails + ? "Local IDs will be shown. Restart app." + : "Local IDs hidden. Restart app.", + ); + }, + ), + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Local thumbnail queue config", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + await routeToPage( + context, + const LocalThumbnailConfigScreen(), + ); + }, + ), sectionOptionSpacing, MenuItemWidget( captionedTextWidget: const CaptionedTextWidget( diff --git a/mobile/apps/photos/lib/ui/settings/debug/local_thumbnail_config_screen.dart b/mobile/apps/photos/lib/ui/settings/debug/local_thumbnail_config_screen.dart new file mode 100644 index 0000000000..470bff8ef1 --- /dev/null +++ b/mobile/apps/photos/lib/ui/settings/debug/local_thumbnail_config_screen.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:photos/service_locator.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/notification/toast.dart'; + +class LocalThumbnailConfigScreen extends StatefulWidget { + const LocalThumbnailConfigScreen({super.key}); + + @override + State createState() => + _LocalThumbnailConfigScreenState(); +} + +class _LocalThumbnailConfigScreenState + extends State { + static final Logger _logger = Logger("LocalThumbnailConfigScreen"); + + late TextEditingController _smallMaxConcurrentController; + late TextEditingController _smallTimeoutController; + late TextEditingController _smallMaxSizeController; + late TextEditingController _largeMaxConcurrentController; + late TextEditingController _largeTimeoutController; + late TextEditingController _largeMaxSizeController; + + @override + void initState() { + super.initState(); + _initControllers(); + } + + void _initControllers() { + _smallMaxConcurrentController = TextEditingController( + text: localSettings.smallQueueMaxConcurrent.toString(), + ); + _smallTimeoutController = TextEditingController( + text: localSettings.smallQueueTimeoutSeconds.toString(), + ); + _smallMaxSizeController = TextEditingController( + text: localSettings.smallQueueMaxSize.toString(), + ); + _largeMaxConcurrentController = TextEditingController( + text: localSettings.largeQueueMaxConcurrent.toString(), + ); + _largeTimeoutController = TextEditingController( + text: localSettings.largeQueueTimeoutSeconds.toString(), + ); + _largeMaxSizeController = TextEditingController( + text: localSettings.largeQueueMaxSize.toString(), + ); + } + + @override + void dispose() { + _smallMaxConcurrentController.dispose(); + _smallTimeoutController.dispose(); + _smallMaxSizeController.dispose(); + _largeMaxConcurrentController.dispose(); + _largeTimeoutController.dispose(); + _largeMaxSizeController.dispose(); + super.dispose(); + } + + Future _saveSettings() async { + try { + // Validate and save small queue settings + final smallMaxConcurrent = + int.tryParse(_smallMaxConcurrentController.text); + final smallTimeout = int.tryParse(_smallTimeoutController.text); + final smallMaxSize = int.tryParse(_smallMaxSizeController.text); + + // Validate and save large queue settings + final largeMaxConcurrent = + int.tryParse(_largeMaxConcurrentController.text); + final largeTimeout = int.tryParse(_largeTimeoutController.text); + final largeMaxSize = int.tryParse(_largeMaxSizeController.text); + + if (smallMaxConcurrent == null || + smallTimeout == null || + smallMaxSize == null || + largeMaxConcurrent == null || + largeTimeout == null || + largeMaxSize == null) { + showShortToast(context, "Please enter valid numbers"); + return; + } + + // Basic validation - just ensure positive numbers + if (smallMaxConcurrent < 1 || + largeMaxConcurrent < 1 || + smallTimeout < 1 || + largeTimeout < 1 || + smallMaxSize < 1 || + largeMaxSize < 1) { + showShortToast( + context, + "All values must be positive numbers", + ); + return; + } + + await localSettings.setSmallQueueMaxConcurrent(smallMaxConcurrent); + await localSettings.setSmallQueueTimeout(smallTimeout); + await localSettings.setSmallQueueMaxSize(smallMaxSize); + await localSettings.setLargeQueueMaxConcurrent(largeMaxConcurrent); + await localSettings.setLargeQueueTimeout(largeTimeout); + await localSettings.setLargeQueueMaxSize(largeMaxSize); + + _logger.info( + "Local thumbnail queue settings updated:\n" + "Small Queue - MaxConcurrent: $smallMaxConcurrent, Timeout: ${smallTimeout}s, MaxSize: $smallMaxSize\n" + "Large Queue - MaxConcurrent: $largeMaxConcurrent, Timeout: ${largeTimeout}s, MaxSize: $largeMaxSize", + ); + + if (mounted) { + showShortToast( + context, + "Settings saved. Restart app to apply changes.", + ); + } + } catch (e) { + showShortToast(context, "Error saving settings"); + } + } + + Future _resetToDefaults() async { + await localSettings.resetThumbnailQueueSettings(); + setState(() { + _smallMaxConcurrentController.text = "15"; + _smallTimeoutController.text = "60"; + _smallMaxSizeController.text = "200"; + _largeMaxConcurrentController.text = "5"; + _largeTimeoutController.text = "60"; + _largeMaxSizeController.text = "200"; + }); + + _logger.info( + "Local thumbnail queue settings reset to defaults:\n" + "Small Queue - MaxConcurrent: 15, Timeout: 60s, MaxSize: 200\n" + "Large Queue - MaxConcurrent: 5, Timeout: 60s, MaxSize: 200", + ); + + if (mounted) { + showShortToast( + context, + "Reset to defaults. Restart app to apply changes.", + ); + } + } + + Widget _buildNumberField({ + required String label, + required String hint, + required TextEditingController controller, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: getEnteTextTheme(context).body.copyWith( + color: getEnteColorScheme(context).textMuted, + ), + ), + const SizedBox(height: 4), + TextField( + controller: controller, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: hint, + hintStyle: getEnteTextTheme(context).body.copyWith( + color: getEnteColorScheme(context).textFaint, + ), + filled: true, + fillColor: getEnteColorScheme(context).fillFaint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + style: getEnteTextTheme(context).body, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Scaffold( + body: Container( + color: colorScheme.backdropBase, + child: SafeArea( + child: Column( + children: [ + const TitleBarTitleWidget( + title: "Local Thumbnail Queue Config", + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Small Local Thumbnail Queue", + style: getEnteTextTheme(context).largeBold, + ), + const SizedBox(height: 8), + Text( + "Used when gallery grid has 4 or more columns", + style: getEnteTextTheme(context).small.copyWith( + color: colorScheme.textMuted, + ), + ), + const SizedBox(height: 16), + _buildNumberField( + label: "Max Concurrent Tasks", + hint: "Default: 15", + controller: _smallMaxConcurrentController, + ), + _buildNumberField( + label: "Timeout (seconds)", + hint: "Default: 60", + controller: _smallTimeoutController, + ), + _buildNumberField( + label: "Max Queue Size", + hint: "Default: 200", + controller: _smallMaxSizeController, + ), + const SizedBox(height: 32), + Text( + "Large Local Thumbnail Queue", + style: getEnteTextTheme(context).largeBold, + ), + const SizedBox(height: 8), + Text( + "Used when gallery grid has less than 4 columns", + style: getEnteTextTheme(context).small.copyWith( + color: colorScheme.textMuted, + ), + ), + const SizedBox(height: 16), + _buildNumberField( + label: "Max Concurrent Tasks", + hint: "Default: 5", + controller: _largeMaxConcurrentController, + ), + _buildNumberField( + label: "Timeout (seconds)", + hint: "Default: 60", + controller: _largeTimeoutController, + ), + _buildNumberField( + label: "Max Queue Size", + hint: "Default: 200", + controller: _largeMaxSizeController, + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saveSettings, + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary700, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + "Save Settings", + style: getEnteTextTheme(context).bodyBold.copyWith( + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _resetToDefaults, + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary700, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: colorScheme.strokeMuted, + ), + ), + ), + child: const Text( + "Reset to Defaults", + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.fillFaint, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.textMuted, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Changes require app restart to take effect", + style: getEnteTextTheme(context).small.copyWith( + color: colorScheme.textMuted, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart b/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart index d17880623c..05bf9a6e1b 100644 --- a/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/file/thumbnail_widget.dart @@ -18,6 +18,7 @@ import 'package:photos/models/file/extensions/file_props.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import 'package:photos/models/file/trash_file.dart'; +import 'package:photos/service_locator.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/services/favorites_service.dart'; import 'package:photos/ui/viewer/file/file_icons_widget.dart'; @@ -98,13 +99,20 @@ class _ThumbnailWidgetState extends State { if (!mounted && _localThumbnailQueueTaskId != null) { if (widget.thumbnailSize == thumbnailLargeSize) { largeLocalThumbnailQueue.removeTask(_localThumbnailQueueTaskId!); + _logger.info( + "Cancelled large thumbnail task: $_localThumbnailQueueTaskId", + ); } else if (widget.thumbnailSize == thumbnailSmallSize) { smallLocalThumbnailQueue.removeTask(_localThumbnailQueueTaskId!); + _logger.info( + "Cancelled small thumbnail task: $_localThumbnailQueueTaskId", + ); } } // Cancel request only if the widget has been unmounted if (!mounted && widget.file.isRemoteFile && !_hasLoadedThumbnail) { removePendingGetThumbnailRequestIfAny(widget.file); + _logger.info("Cancelled thumbnail request for " + widget.file.tag); } }); } @@ -117,17 +125,42 @@ class _ThumbnailWidgetState extends State { } } - static final smallLocalThumbnailQueue = TaskQueue( - maxConcurrentTasks: 15, - taskTimeout: const Duration(minutes: 1), - maxQueueSize: 200, - ); + static final TaskQueue smallLocalThumbnailQueue = _initSmallQueue(); + static final TaskQueue largeLocalThumbnailQueue = _initLargeQueue(); - static final largeLocalThumbnailQueue = TaskQueue( - maxConcurrentTasks: 5, - taskTimeout: const Duration(minutes: 1), - maxQueueSize: 200, - ); + static TaskQueue _initSmallQueue() { + final maxConcurrent = localSettings.smallQueueMaxConcurrent; + final timeoutSeconds = localSettings.smallQueueTimeoutSeconds; + final maxSize = localSettings.smallQueueMaxSize; + + _logger.info( + "Initializing Small Local Thumbnail Queue - " + "MaxConcurrent: $maxConcurrent, Timeout: ${timeoutSeconds}s, MaxSize: $maxSize", + ); + + return TaskQueue( + maxConcurrentTasks: maxConcurrent, + taskTimeout: Duration(seconds: timeoutSeconds), + maxQueueSize: maxSize, + ); + } + + static TaskQueue _initLargeQueue() { + final maxConcurrent = localSettings.largeQueueMaxConcurrent; + final timeoutSeconds = localSettings.largeQueueTimeoutSeconds; + final maxSize = localSettings.largeQueueMaxSize; + + _logger.info( + "Initializing Large Local Thumbnail Queue - " + "MaxConcurrent: $maxConcurrent, Timeout: ${timeoutSeconds}s, MaxSize: $maxSize", + ); + + return TaskQueue( + maxConcurrentTasks: maxConcurrent, + taskTimeout: Duration(seconds: timeoutSeconds), + maxQueueSize: maxSize, + ); + } ///Assigned dimension will be the size of a grid item. The size will be ///assigned to the side which is smaller in dimension. @@ -230,6 +263,32 @@ class _ThumbnailWidgetState extends State { if (widget.shouldShowPinIcon) { viewChildren.add(const PinOverlayIcon()); } + if (localSettings.showLocalIDOverThumbnails && + widget.file.localID != null) { + viewChildren.add( + Positioned( + bottom: 4, + left: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: const Color.fromRGBO(0, 0, 0, 0.8), + borderRadius: BorderRadius.circular(4), + ), + child: FittedBox( + child: Text( + "${widget.file.localID}", + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } return Stack( clipBehavior: Clip.none, @@ -311,9 +370,14 @@ class _ThumbnailWidgetState extends State { _errorLoadingLocalThumbnail = true; if (e is WidgetUnmountedException) { // Widget was unmounted - this is expected behavior - _logger.fine("Thumbnail loading cancelled: widget unmounted"); + _logger.fine( + "Thumbnail loading cancelled: widget unmounted for localID: ${widget.file.localID}", + ); } else { - _logger.warning("Could not load thumbnail from disk: ", e); + _logger.warning( + "Could not load thumbnail from disk for localID: ${widget.file.localID}", + e, + ); } }); } @@ -343,7 +407,7 @@ class _ThumbnailWidgetState extends State { ); if (retryAttempts <= _maxLocalThumbnailRetries) { _logger.warning( - "Error getting local thumbnail for ${widget.file.displayName}, retrying (attempt $retryAttempts) in ${backoff.inMilliseconds} ms", + "Error getting local thumbnail for ${widget.file.displayName} (localID: ${widget.file.localID}) due to ${e.runtimeType}, retrying (attempt $retryAttempts) in ${backoff.inMilliseconds} ms", e, ); await Future.delayed(backoff); // Exponential backoff diff --git a/mobile/apps/photos/lib/utils/local_settings.dart b/mobile/apps/photos/lib/utils/local_settings.dart index 72d9a789dd..4eef3a32c5 100644 --- a/mobile/apps/photos/lib/utils/local_settings.dart +++ b/mobile/apps/photos/lib/utils/local_settings.dart @@ -41,6 +41,15 @@ class LocalSettings { "hide_shared_items_from_home_gallery"; static const kCollectionViewType = "collection_view_type"; static const kCollectionSortDirection = "collection_sort_direction"; + static const kShowLocalIDOverThumbnails = "show_local_id_over_thumbnails"; + + // Thumbnail queue configuration keys + static const kSmallQueueMaxConcurrent = "small_queue_max_concurrent"; + static const kSmallQueueTimeout = "small_queue_timeout_seconds"; + static const kSmallQueueMaxSize = "small_queue_max_size"; + static const kLargeQueueMaxConcurrent = "large_queue_max_concurrent"; + static const kLargeQueueTimeout = "large_queue_timeout_seconds"; + static const kLargeQueueMaxSize = "large_queue_max_size"; final SharedPreferences _prefs; @@ -217,4 +226,59 @@ class LocalSettings { bool get hideSharedItemsFromHomeGallery => _prefs.getBool(_hideSharedItemsFromHomeGalleryTag) ?? false; + + bool get showLocalIDOverThumbnails => + _prefs.getBool(kShowLocalIDOverThumbnails) ?? false; + + Future setShowLocalIDOverThumbnails(bool value) async { + await _prefs.setBool(kShowLocalIDOverThumbnails, value); + } + + // Thumbnail queue configuration - Small queue + int get smallQueueMaxConcurrent => _prefs.getInt(kSmallQueueMaxConcurrent) ?? 15; + + int get smallQueueTimeoutSeconds => _prefs.getInt(kSmallQueueTimeout) ?? 60; + + int get smallQueueMaxSize => _prefs.getInt(kSmallQueueMaxSize) ?? 200; + + Future setSmallQueueMaxConcurrent(int value) async { + await _prefs.setInt(kSmallQueueMaxConcurrent, value); + } + + Future setSmallQueueTimeout(int seconds) async { + await _prefs.setInt(kSmallQueueTimeout, seconds); + } + + Future setSmallQueueMaxSize(int value) async { + await _prefs.setInt(kSmallQueueMaxSize, value); + } + + // Thumbnail queue configuration - Large queue + int get largeQueueMaxConcurrent => _prefs.getInt(kLargeQueueMaxConcurrent) ?? 5; + + int get largeQueueTimeoutSeconds => _prefs.getInt(kLargeQueueTimeout) ?? 60; + + int get largeQueueMaxSize => _prefs.getInt(kLargeQueueMaxSize) ?? 200; + + Future setLargeQueueMaxConcurrent(int value) async { + await _prefs.setInt(kLargeQueueMaxConcurrent, value); + } + + Future setLargeQueueTimeout(int seconds) async { + await _prefs.setInt(kLargeQueueTimeout, seconds); + } + + Future setLargeQueueMaxSize(int value) async { + await _prefs.setInt(kLargeQueueMaxSize, value); + } + + // Reset thumbnail queue settings to defaults + Future resetThumbnailQueueSettings() async { + await _prefs.remove(kSmallQueueMaxConcurrent); + await _prefs.remove(kSmallQueueTimeout); + await _prefs.remove(kSmallQueueMaxSize); + await _prefs.remove(kLargeQueueMaxConcurrent); + await _prefs.remove(kLargeQueueTimeout); + await _prefs.remove(kLargeQueueMaxSize); + } } diff --git a/mobile/apps/photos/scripts/internal_changes.txt b/mobile/apps/photos/scripts/internal_changes.txt index af999e1b86..06205f7d9f 100644 --- a/mobile/apps/photos/scripts/internal_changes.txt +++ b/mobile/apps/photos/scripts/internal_changes.txt @@ -1,3 +1,4 @@ +- Ashil: Add new section (show local ID & config local thumb queue) in debug section in settings to help in debugging thumbnail not loading issue. - Ashil: Revert diskLoadDeferDuration to 80ms (Fixes local thumbnails taking ~1 sec to load on scrolling gallery) - Ashil: Revert diskLoadDeferDuration to 500ms (Was 80ms before but fixes local thumbnail taking very long to load or never loading) - Ashil: Revert increase in cache extent for gallery - to check if thumbnail not loading regression resolves