From 955bc9548c754538f5a314c5af21acb1cc75fdac Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 5 May 2026 03:01:14 +0200 Subject: [PATCH] refactor: adopt shared mixins, helpers, and sliver widgets --- lib/main.dart | 5 +- lib/mpv/player/platform/player_android.dart | 6 +- lib/mpv/player/player_base.dart | 14 ++--- lib/providers/shader_provider.dart | 4 +- .../base_media_list_detail_screen.dart | 3 +- lib/screens/discover_screen.dart | 14 ++--- lib/screens/hub_detail_screen.dart | 13 ++--- .../libraries/content_state_builder.dart | 33 +++++++++++ lib/screens/libraries/folder_tree_view.dart | 8 +-- .../libraries/tabs/library_browse_tab.dart | 22 ++------ .../livetv/live_tv_show_schedule_screen.dart | 8 ++- lib/screens/livetv/program_details_sheet.dart | 7 ++- lib/screens/livetv/record_options_sheet.dart | 21 ++----- lib/screens/livetv/tabs/guide_tab.dart | 7 ++- lib/screens/livetv/tabs/whats_on_tab.dart | 11 ++-- lib/screens/main_screen.dart | 6 +- lib/screens/media_detail_screen.dart | 55 +++++++------------ lib/screens/plex_metadata_edit_screen.dart | 9 +-- .../profile/borrow_connection_screen.dart | 3 +- .../profile/profile_switch_screen.dart | 7 ++- lib/screens/search_screen.dart | 3 +- lib/screens/settings/licenses_screen.dart | 6 +- lib/screens/settings/logs_screen.dart | 14 +++-- lib/screens/settings/settings_screen.dart | 7 ++- lib/services/jellyfin_client.dart | 3 +- lib/services/jellyfin_media_info_mapper.dart | 4 +- lib/services/plex_client.dart | 3 +- lib/services/settings_export_service.dart | 7 ++- lib/services/shader_asset_loader.dart | 3 +- lib/utils/log_redaction_manager.dart | 8 +++ .../screens/watch_together_screen.dart | 9 +-- .../companion_remote/discovery_view.dart | 5 +- .../remote_session_dialog.dart | 5 +- lib/widgets/hub_section.dart | 7 ++- lib/widgets/loading_indicator_box.dart | 5 ++ lib/widgets/side_navigation_rail.dart | 5 +- lib/widgets/tv_virtual_keyboard.dart | 5 +- 37 files changed, 181 insertions(+), 174 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 594ca3fe..9ce43a59 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'profiles/active_profile_provider.dart'; import 'profiles/profile.dart'; import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; +import 'mixins/mounted_set_state_mixin.dart'; import 'profiles/plex_home_service.dart'; import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; @@ -991,7 +992,7 @@ class SetupScreen extends StatefulWidget { State createState() => _SetupScreenState(); } -class _SetupScreenState extends State { +class _SetupScreenState extends State with MountedSetStateMixin { String _statusMessage = ''; // Per-server connection status: serverId -> (name, connected?) @@ -1004,7 +1005,7 @@ class _SetupScreenState extends State { } void _setStatus(String message) { - if (mounted) setState(() => _statusMessage = message); + setStateIfMounted(() => _statusMessage = message); } Future _loadSavedCredentials() async { diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 19670a5d..8b351ad4 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import '../../models.dart'; @@ -200,10 +201,7 @@ class PlayerAndroid extends PlayerBase { final storedId = _hiddenSubtitleTrackId; if (storedId != null) { _hiddenSubtitleTrackId = null; - final track = state.tracks.subtitle.cast().firstWhere( - (t) => t?.id == storedId, - orElse: () => null, - ); + final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId); if (track != null) { await selectSubtitleTrack(track); } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 032d1a72..2c9f88e7 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart' show protected; import 'package:flutter/services.dart'; @@ -267,12 +268,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'audio-device': if (value is String && value.isNotEmpty) { - final device = - _state.audioDevices.cast().firstWhere( - (d) => d?.name == value, - orElse: () => AudioDevice(name: value), - ) ?? - AudioDevice(name: value); + final device = _state.audioDevices.firstWhereOrNull((d) => d.name == value) ?? AudioDevice(name: value); _state = _state.copyWith(audioDevice: device); audioDeviceController.add(device); } @@ -441,7 +437,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { AudioTrack? selectedTrack; if (id != null && id != 'no') { - selectedTrack = _state.tracks.audio.cast().firstWhere((t) => t?.id == id, orElse: () => null); + selectedTrack = _state.tracks.audio.firstWhereOrNull((t) => t.id == id); } _state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack)); @@ -454,7 +450,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { selectedTrack = (id == null || id == 'no') ? SubtitleTrack.off - : _state.tracks.subtitle.cast().firstWhere((t) => t?.id == id, orElse: () => null); + : _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id); _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); trackController.add(_state.track); @@ -467,7 +463,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (id == null || id == 'no') { selectedTrack = null; } else { - selectedTrack = _state.tracks.subtitle.cast().firstWhere((t) => t?.id == id, orElse: () => null); + selectedTrack = _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id); } _state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack)); diff --git a/lib/providers/shader_provider.dart b/lib/providers/shader_provider.dart index 8d6cb56c..6b25f895 100644 --- a/lib/providers/shader_provider.dart +++ b/lib/providers/shader_provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; @@ -67,8 +68,7 @@ class ShaderProvider extends ChangeNotifier with DisposableChangeNotifierMixin { bool get isShaderEnabled => _currentPreset.type != ShaderPresetType.none; ShaderPreset? findPresetById(String id) { - return ShaderPreset.fromId(id) ?? - _customPresets.cast().firstWhere((p) => p!.id == id, orElse: () => null); + return ShaderPreset.fromId(id) ?? _customPresets.firstWhereOrNull((p) => p.id == id); } Future setPreset(ShaderPreset preset) async { diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 592fb8ab..66595225 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -8,6 +8,7 @@ import '../media/media_server_client.dart'; import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../services/media_list_playback_launcher.dart'; +import '../widgets/loading_indicator_box.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import '../mixins/refreshable.dart'; @@ -124,7 +125,7 @@ abstract class BaseMediaListDetailScreen extends State } if (items.isEmpty && isLoading) { - return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))]; + return [LoadingIndicatorBox.sliver]; } if (items.isEmpty) { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 61d46c20..49bb0d8a 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -25,6 +25,7 @@ import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../widgets/hub_section.dart'; +import '../widgets/loading_indicator_box.dart'; import 'profile/profile_switch_screen.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; @@ -53,7 +54,7 @@ import '../utils/platform_detector.dart'; import '../theme/mono_tokens.dart'; import '../services/watch_next_service.dart'; import 'auth_screen.dart'; -import 'libraries/state_messages.dart'; +import 'libraries/content_state_builder.dart'; import 'main_screen.dart'; import '../watch_together/watch_together.dart'; import '../providers/companion_remote_provider.dart'; @@ -1173,15 +1174,8 @@ class _DiscoverScreenState extends State ); }, ), - if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())), - if (_errorMessage != null) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: _loadContent, - ), - ), + if (_isLoading) LoadingIndicatorBox.sliver, + if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _loadContent), if (!_isLoading && _errorMessage == null) ...[ // On Deck / Continue Watching if (_onDeck.isNotEmpty) diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 43e9ae22..bc07e8ac 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -11,12 +11,13 @@ import '../utils/provider_extensions.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; import '../widgets/desktop_app_bar.dart'; +import '../widgets/loading_indicator_box.dart'; import '../widgets/overlay_sheet.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import 'libraries/sort_bottom_sheet.dart'; -import 'libraries/state_messages.dart'; +import 'libraries/content_state_builder.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; import 'focusable_detail_screen_mixin.dart'; @@ -290,15 +291,9 @@ class _HubDetailScreenState extends State slivers: [ CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), if (_errorMessage != null) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: _loadMoreItems, - ), - ) + SliverErrorState(message: _errorMessage!, onRetry: _loadMoreItems) else if (_filteredItems.isEmpty && _isLoading) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + LoadingIndicatorBox.sliver else if (_filteredItems.isEmpty) SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) else diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index afb27847..dd9fea82 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -3,6 +3,39 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; import 'state_messages.dart'; +/// Sliver wrapper around [ErrorStateWidget] for use in `CustomScrollView.slivers`. +class SliverErrorState extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final String? retryLabel; + + const SliverErrorState({super.key, required this.message, this.onRetry, this.retryLabel}); + + @override + Widget build(BuildContext context) => SliverFillRemaining( + child: ErrorStateWidget( + message: message, + icon: Symbols.error_outline_rounded, + onRetry: onRetry, + retryLabel: retryLabel ?? t.common.retry, + ), + ); +} + +/// Sliver wrapper around [EmptyStateWidget] for use in `CustomScrollView.slivers`. +class SliverEmptyState extends StatelessWidget { + final String message; + final IconData icon; + final String? subtitle; + + const SliverEmptyState({super.key, required this.message, required this.icon, this.subtitle}); + + @override + Widget build(BuildContext context) => SliverFillRemaining( + child: EmptyStateWidget(message: message, icon: icon, subtitle: subtitle), + ); +} + /// A widget that handles loading, error, empty, and content states /// Provides a consistent UI pattern across the app for data-driven screens class ContentStateBuilder extends StatelessWidget { diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index a3ba00cc..f0790d6f 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -4,6 +4,7 @@ import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../services/play_queue_launcher.dart'; import '../../utils/app_logger.dart'; +import '../../utils/error_message_utils.dart'; import '../../utils/media_navigation_helper.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; @@ -85,9 +86,8 @@ class FolderTreeViewState extends State { } catch (e) { if (!mounted) return; - appLogger.e('Failed to load root folders', error: e); setState(() { - _errorMessage = t.errors.failedToLoad(context: t.libraries.folders, error: e.toString()); + _errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); _isLoadingRoot = false; }); } @@ -130,13 +130,13 @@ class FolderTreeViewState extends State { } catch (e) { if (!mounted) return; - appLogger.e('Failed to load folder children', error: e); + final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); setState(() { _loadingFolders.remove(folderKey); }); if (mounted) { - showErrorSnackBar(context, t.errors.failedToLoad(context: t.libraries.folders, error: e.toString())); + showErrorSnackBar(context, message); } } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 0a57d82e..3180c30b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -30,6 +30,7 @@ import '../library_alpha_bar_strategy.dart'; import '../library_filter_sort_loader.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; +import '../../../widgets/loading_indicator_box.dart'; import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; @@ -38,7 +39,7 @@ import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; -import '../state_messages.dart'; +import '../content_state_builder.dart'; import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart'; import '../../../mixins/grid_focus_node_mixin.dart'; @@ -1384,28 +1385,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState - with LiveTvActionsMixin { + with LiveTvActionsMixin, MountedSetStateMixin { List _programs = []; bool _isLoading = true; @@ -51,7 +53,7 @@ class _LiveTvShowScheduleScreenState extends State final multiServer = context.read(); final genericClient = multiServer.getClientForServer(widget.serverId); if (genericClient == null) { - if (mounted) setState(() => _isLoading = false); + setStateIfMounted(() => _isLoading = false); return; } @@ -128,7 +130,7 @@ class _LiveTvShowScheduleScreenState extends State : null, slivers: [ if (_isLoading) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + LoadingIndicatorBox.sliver else if (_programs.isEmpty) SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) else diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 218161d8..3267e527 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../media/media_server_client.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; import '../../models/media_subscription.dart'; @@ -79,7 +80,7 @@ class _ProgramDetailsSheetContent extends StatefulWidget { State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState(); } -class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> { +class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> with MountedSetStateMixin { final List _focusNodes = []; MediaSubscription? _existingSubscription; bool _checkedMapping = false; @@ -115,7 +116,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent final providerId = widget.program.providerIdentifier; final ratingKey = widget.program.ratingKey; if (client == null || providerId == null || providerId.isEmpty || ratingKey == null || ratingKey.isEmpty) { - if (mounted) setState(() => _checkedMapping = true); + setStateIfMounted(() => _checkedMapping = true); return; } try { @@ -129,7 +130,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent } catch (e) { // 403 (no DVR access) or transient — treat as unscheduled. appLogger.d('Subscription mapping check failed: $e'); - if (mounted) setState(() => _checkedMapping = true); + setStateIfMounted(() => _checkedMapping = true); } } diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 43e77f97..fefbed21 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -8,6 +8,7 @@ import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../media/media_server_client.dart'; +import '../../mixins/controller_disposer_mixin.dart'; import '../../models/livetv_program.dart'; import '../../models/media_subscription.dart'; import '../../utils/app_logger.dart'; @@ -472,13 +473,13 @@ class _IntSettingRow extends StatefulWidget { State<_IntSettingRow> createState() => _IntSettingRowState(); } -class _IntSettingRowState extends State<_IntSettingRow> { +class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerMixin { late final TextEditingController _controller; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.currentValue?.toString() ?? ''); + _controller = createTextEditingController(text: widget.currentValue?.toString() ?? ''); } @override @@ -488,12 +489,6 @@ class _IntSettingRowState extends State<_IntSettingRow> { if (next != _controller.text) _controller.text = next; } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -542,13 +537,13 @@ class _TextSettingRow extends StatefulWidget { State<_TextSettingRow> createState() => _TextSettingRowState(); } -class _TextSettingRowState extends State<_TextSettingRow> { +class _TextSettingRowState extends State<_TextSettingRow> with ControllerDisposerMixin { late final TextEditingController _controller; @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.currentValue?.toString() ?? ''); + _controller = createTextEditingController(text: widget.currentValue?.toString() ?? ''); } @override @@ -558,12 +553,6 @@ class _TextSettingRowState extends State<_TextSettingRow> { if (next != _controller.text) _controller.text = next; } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index ca07ccec..8910682c 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -10,6 +10,7 @@ import '../../../focus/dpad_navigator.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../focus/key_event_utils.dart'; import '../../../i18n/strings.g.dart'; +import '../../../mixins/mounted_set_state_mixin.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; import '../../../providers/multi_server_provider.dart'; @@ -45,7 +46,7 @@ class GuideTab extends StatefulWidget { enum _GuideZone { timeNav, grid } -class GuideTabState extends State { +class GuideTabState extends State with MountedSetStateMixin { static const _slotWidth = 180.0; static const _channelColumnWidth = 100.0; static const _rowHeight = 64.0; @@ -111,7 +112,7 @@ class GuideTabState extends State { _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { // ignore: no-empty-block - setState triggers rebuild to update time indicator - if (mounted) setState(() {}); + setStateIfMounted(() {}); }); } @@ -121,7 +122,7 @@ class GuideTabState extends State { _timeIndicatorTimer?.cancel(); _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { // ignore: no-empty-block - setState triggers rebuild to update time indicator - if (mounted) setState(() {}); + setStateIfMounted(() {}); }); } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 1fb7e3d9..5f58d9fe 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -10,6 +10,7 @@ import '../../../focus/key_event_utils.dart'; import '../../../focus/locked_hub_controller.dart'; import '../../../i18n/strings.g.dart'; import '../../../media/media_item_types.dart'; +import '../../../mixins/mounted_set_state_mixin.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_hub_result.dart'; import '../../../providers/multi_server_provider.dart'; @@ -39,7 +40,7 @@ class WhatsOnTab extends StatefulWidget { State createState() => WhatsOnTabState(); } -class WhatsOnTabState extends State with LiveTvActionsMixin { +class WhatsOnTabState extends State with LiveTvActionsMixin, MountedSetStateMixin { List _hubs = []; bool _isLoading = true; Timer? _refreshTimer; @@ -104,7 +105,7 @@ class WhatsOnTabState extends State with LiveTvActionsMixin _isLoading = false); + setStateIfMounted(() => _isLoading = false); } } @@ -223,7 +224,7 @@ class _LiveTvHubSection extends StatefulWidget { State<_LiveTvHubSection> createState() => _LiveTvHubSectionState(); } -class _LiveTvHubSectionState extends State<_LiveTvHubSection> { +class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetStateMixin { static const _longPressDuration = Duration(milliseconds: 500); late FocusNode _hubFocusNode; @@ -271,7 +272,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> { _longPressTriggered = false; } // ignore: no-empty-block - setState triggers rebuild to update focus styling - if (mounted) setState(() {}); + setStateIfMounted(() {}); } void requestFocusAt(int index) { @@ -283,7 +284,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> { _scrollToIndex(clamped); _hubFocusNode.requestFocus(); // ignore: no-empty-block - setState triggers rebuild to update focus styling - if (mounted) setState(() {}); + setStateIfMounted(() {}); _scrollHubIntoView(); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 1330497a..97949c2f 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -18,6 +18,7 @@ import '../utils/snackbar_helper.dart'; import '../utils/update_dialog.dart'; import '../utils/video_player_navigation.dart'; import '../main.dart'; +import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/refreshable.dart'; import '../widgets/overlay_sheet.dart'; import '../mixins/tab_visibility_aware.dart'; @@ -96,7 +97,8 @@ class MainScreen extends StatefulWidget { State createState() => _MainScreenState(); } -class _MainScreenState extends State with RouteAware, WindowListener, WidgetsBindingObserver { +class _MainScreenState extends State + with RouteAware, WindowListener, WidgetsBindingObserver, MountedSetStateMixin { NavigationTabId _currentTab = NavigationTabId.discover; String? _selectedLibraryGlobalKey; @@ -797,7 +799,7 @@ class _MainScreenState extends State with RouteAware, WindowListener await serverManager.checkServerHealth(); await serverManager.reconnectOfflineServers(forceRediscovery: true); } finally { - if (mounted) setState(() => _isReconnecting = false); + setStateIfMounted(() => _isReconnecting = false); } }()); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index abe74aca..3883f058 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1848,6 +1848,19 @@ class _MediaDetailScreenState extends State return Symbols.recommend_rounded; } + static const Widget _sectionLoading = Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), + ); + + Widget _sectionEmpty(BuildContext context, String message) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey)), + ), + ); + } + /// Build episode list directly when the library hides seasons for single-season shows Widget _buildEpisodesList() { final client = _getMediaClientForMetadata(context); @@ -2217,19 +2230,9 @@ class _MediaDetailScreenState extends State if (isShow && !_showEpisodesDirectly) ...[ // Season tabs + inline episodes if (_isLoadingSeasons) - const Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), - ) + _sectionLoading else if (_seasons.isEmpty) - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noSeasonsFound, - style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey), - ), - ), - ) + _sectionEmpty(context, t.messages.noSeasonsFound) else ...[ Text( key: _seasonsSectionKey, @@ -2240,21 +2243,11 @@ class _MediaDetailScreenState extends State _buildSeasonTabs(), const SizedBox(height: 16), if (_isLoadingSeasonEpisodes) - const Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), - ) + _sectionLoading else if (_episodes.isNotEmpty) _buildEpisodesList() else - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noEpisodesFoundGeneral, - style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey), - ), - ), - ), + _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), ], const SizedBox(height: 24), ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ @@ -2266,21 +2259,11 @@ class _MediaDetailScreenState extends State ), const SizedBox(height: 12), if (_isLoadingSeasons || _isLoadingEpisodes) - const Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), - ) + _sectionLoading else if (_episodes.isNotEmpty) _buildEpisodesList() else - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noEpisodesFoundGeneral, - style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey), - ), - ), - ), + _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), const SizedBox(height: 24), ], diff --git a/lib/screens/plex_metadata_edit_screen.dart b/lib/screens/plex_metadata_edit_screen.dart index e23d042b..1f903454 100644 --- a/lib/screens/plex_metadata_edit_screen.dart +++ b/lib/screens/plex_metadata_edit_screen.dart @@ -7,6 +7,7 @@ import '../media/media_kind.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/formatters.dart'; import '../utils/language_codes.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; @@ -269,8 +270,7 @@ class _PlexMetadataEditScreenState extends State { if (picked != null && mounted) { setState(() { - _originallyAvailableAt = - '${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}'; + _originallyAvailableAt = '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}'; }); } } @@ -456,10 +456,7 @@ class _PlexMetadataEditScreenState extends State { @override Widget build(BuildContext context) { if (_isLoading) { - return FocusedScrollScaffold( - title: Text(t.metadataEdit.screenTitle), - slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); + return FocusedScrollScaffold(title: Text(t.metadataEdit.screenTitle), slivers: [LoadingIndicatorBox.sliver]); } return FocusedScrollScaffold( diff --git a/lib/screens/profile/borrow_connection_screen.dart b/lib/screens/profile/borrow_connection_screen.dart index d9998861..8c39a2c3 100644 --- a/lib/screens/profile/borrow_connection_screen.dart +++ b/lib/screens/profile/borrow_connection_screen.dart @@ -23,6 +23,7 @@ import '../../utils/app_logger.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/backend_badge.dart'; +import '../../widgets/loading_indicator_box.dart'; import '../../widgets/desktop_app_bar.dart'; import '../libraries/state_messages.dart'; import 'pin_entry_dialog.dart'; @@ -175,7 +176,7 @@ class _BorrowConnectionScreenState extends State { ), ), if (snapshot.connectionState != ConnectionState.done) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + LoadingIndicatorBox.sliver else if (candidates.isEmpty) SliverFillRemaining( child: EmptyStateWidget( diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 7c2693c5..fbb06114 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -9,6 +9,7 @@ import '../../connection/connection_registry.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../media/media_backend.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; @@ -47,7 +48,7 @@ class ProfileSwitchScreen extends StatefulWidget { State createState() => _ProfileSwitchScreenState(); } -class _ProfileSwitchScreenState extends State { +class _ProfileSwitchScreenState extends State with MountedSetStateMixin { bool _allowPop = false; final FocusNode _firstSelectableFocusNode = FocusNode(); bool _focusRequested = false; @@ -68,7 +69,7 @@ class _ProfileSwitchScreenState extends State { if (_storage == null) { unawaited( StorageService.getInstance().then((s) { - if (mounted) setState(() => _storage = s); + setStateIfMounted(() => _storage = s); }), ); } @@ -351,7 +352,7 @@ class _ProfileSwitchScreenState extends State { } navigator.pop(true); } finally { - if (mounted) setState(() => _switching = false); + setStateIfMounted(() => _switching = false); } } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index babbea29..d8fb7b46 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -13,6 +13,7 @@ import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; +import '../widgets/loading_indicator_box.dart'; import '../widgets/pill_input_decoration.dart'; import '../widgets/focusable_media_card.dart'; import '../utils/focus_utils.dart'; @@ -232,7 +233,7 @@ class _SearchScreenState extends State ), ), if (_isSearching) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + LoadingIndicatorBox.sliver else if (!_hasSearched) SliverFillRemaining( child: StateMessageWidget( diff --git a/lib/screens/settings/licenses_screen.dart b/lib/screens/settings/licenses_screen.dart index e42dcd3f..5f06f9d5 100644 --- a/lib/screens/settings/licenses_screen.dart +++ b/lib/screens/settings/licenses_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/loading_indicator_box.dart'; import '../../i18n/strings.g.dart'; class MergedLicenseEntry { @@ -52,10 +53,7 @@ class LicensesScreen extends StatelessWidget { builder: (context, snapshot) { final mergedLicenses = snapshot.data; if (mergedLicenses == null) { - return FocusedScrollScaffold( - title: Text(t.screens.licenses), - slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))], - ); + return FocusedScrollScaffold(title: Text(t.screens.licenses), slivers: [LoadingIndicatorBox.sliver]); } return FocusedScrollScaffold( diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index dda9fc11..b92be01b 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -13,9 +13,11 @@ import '../../focus/focusable_action_bar.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../utils/dialogs.dart'; import '../../main.dart' show gitCommit; import '../../utils/app_logger.dart'; +import '../../utils/formatters.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/desktop_app_bar.dart'; @@ -27,7 +29,7 @@ class LogsScreen extends StatefulWidget { State createState() => _LogsScreenState(); } -class _LogsScreenState extends State { +class _LogsScreenState extends State with MountedSetStateMixin { List _logs = []; String _deviceInfo = ''; final ScrollController _scrollController = ScrollController(); @@ -64,7 +66,7 @@ class _LogsScreenState extends State { buffer.writeln('Linux ${info.versionId ?? info.id}'); } - if (mounted) setState(() => _deviceInfo = buffer.toString().trimRight()); + setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight()); } @override @@ -80,10 +82,10 @@ class _LogsScreenState extends State { } String _formatTime(DateTime time) { - final hour = time.hour.toString().padLeft(2, '0'); - final minute = time.minute.toString().padLeft(2, '0'); - final second = time.second.toString().padLeft(2, '0'); - final millisecond = time.millisecond.toString().padLeft(3, '0'); + final hour = padNumber(time.hour, 2); + final minute = padNumber(time.minute, 2); + final second = padNumber(time.second, 2); + final millisecond = padNumber(time.millisecond, 3); return '$hour:$minute:$second.$millisecond'; } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 26763853..f9b614e9 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -14,6 +14,7 @@ import '../../focus/focusable_text_field.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../main_screen.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../mixins/refreshable.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/libraries_provider.dart'; @@ -57,7 +58,7 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } -class _SettingsScreenState extends State with FocusableTab { +class _SettingsScreenState extends State with FocusableTab, MountedSetStateMixin { late final FocusMemoryTracker _focusTracker; // Focus tracking keys @@ -95,13 +96,13 @@ class _SettingsScreenState extends State with FocusableTab { _focusTracker = FocusMemoryTracker( onFocusChanged: () { // ignore: no-empty-block - setState triggers rebuild to update focus styling - if (mounted) setState(() {}); + setStateIfMounted(() {}); }, debugLabelPrefix: 'settings', ); if (_keyboardShortcutsSupported) { KeyboardShortcutsService.getInstance().then((s) { - if (mounted) setState(() => _keyboardService = s); + setStateIfMounted(() => _keyboardService = s); }); } } diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index a326c1a6..10f39947 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -94,8 +94,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc // leak the token verbatim. `LogRedactionManager.redact()` also has // pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the // `Authorization: MediaBrowser ... Token="..."` header. - LogRedactionManager.registerServerUrl(connection.baseUrl); - LogRedactionManager.registerToken(connection.accessToken); + LogRedactionManager.registerServer(connection.baseUrl, connection.accessToken); String version = '1.0'; try { final pkg = await PackageInfo.fromPlatform(); diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index d0d314bf..5d2ee508 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; + import '../media/media_version.dart'; import '../media/media_source_info.dart'; import '../utils/jellyfin_time.dart'; @@ -197,7 +199,7 @@ Map? _parseTrickplayManifest(Object? raw, String? sourceId) // Source id not in the manifest — fall back to the first nested // entry so the user still gets *something*. The caller already // chose the right source; this is best-effort recovery. - final first = raw.values.cast().firstWhere((v) => v is Map, orElse: () => null); + final first = raw.values.firstWhereOrNull((v) => v is Map); if (first is! Map) return null; resolutionMap = first; } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 90260391..d6407f64 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -248,8 +248,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements : null, _onEndpointChanged = onEndpointChanged, _onAllEndpointsExhausted = onAllEndpointsExhausted { - LogRedactionManager.registerServerUrl(config.baseUrl); - LogRedactionManager.registerToken(config.token); + LogRedactionManager.registerServer(config.baseUrl, config.token); _http = MediaServerHttpClient( baseUrl: config.baseUrl, diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index 60902fe9..9ab7b338 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/app_logger.dart'; +import '../utils/formatters.dart'; import '../utils/platform_detector.dart'; import 'file_picker_service.dart'; import 'settings_service.dart'; @@ -271,9 +272,9 @@ class SettingsExportService { static Future _defaultFileName() async { final now = DateTime.now(); - final y = now.year.toString().padLeft(4, '0'); - final m = now.month.toString().padLeft(2, '0'); - final d = now.day.toString().padLeft(2, '0'); + final y = padNumber(now.year, 4); + final m = padNumber(now.month, 2); + final d = padNumber(now.day, 2); return 'plezy-settings-$y$m$d.$fileExtension'; } diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index 76ec9298..b81e26c9 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; import '../models/shader_preset.dart'; import '../utils/app_logger.dart'; @@ -211,7 +212,7 @@ class ShaderAssetLoader { static Future importCustomShader(String sourcePath) async { final customDir = await _getCustomShaderDirectory(); final ext = path.extension(sourcePath); - final uuid = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + final uuid = const Uuid().v4(); final storedName = '$uuid$ext'; final targetFile = File(path.join(customDir, storedName)); diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart index 5ec9214d..8072e297 100644 --- a/lib/utils/log_redaction_manager.dart +++ b/lib/utils/log_redaction_manager.dart @@ -85,6 +85,14 @@ class LogRedactionManager { _rebuildCombinedPattern(); } + /// Convenience: register a server's URL and access token together. + /// Call this before any HTTP traffic so the very first probe URL doesn't + /// leak credentials verbatim. + static void registerServer(String? url, String? token) { + registerServerUrl(url); + registerToken(token); + } + /// Register other sensitive values that need redaction. static void registerCustomValue(String? value) { final normalized = _normalize(value); diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 74cd877b..de84c28a 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; @@ -74,7 +75,7 @@ class _NotInSessionView extends StatefulWidget { State<_NotInSessionView> createState() => _NotInSessionViewState(); } -class _NotInSessionViewState extends State<_NotInSessionView> { +class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetStateMixin { bool _isCreating = false; bool _isJoining = false; String? _enteringRoomCode; @@ -218,7 +219,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { displayName: _plexDisplayName, ); await RecentRoomsService.addOrUpdateRoom(sessionId, controlMode: controlMode); - if (mounted) setState(() => _recentRooms = RecentRoomsService.getRecentRooms()); + setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms()); } catch (e) { appLogger.e('Failed to create session', error: e); if (mounted) { @@ -262,7 +263,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { try { await widget.watchTogether.joinSession(sessionId, displayName: _plexDisplayName); await RecentRoomsService.addOrUpdateRoom(sessionId); - if (mounted) setState(() => _recentRooms = RecentRoomsService.getRecentRooms()); + setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms()); } catch (e) { appLogger.e('Failed to join session', error: e); if (mounted) { @@ -285,7 +286,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { displayName: _plexDisplayName, ); await RecentRoomsService.addOrUpdateRoom(room.code); - if (mounted) setState(() => _recentRooms = RecentRoomsService.getRecentRooms()); + setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms()); } catch (e) { appLogger.e('Failed to enter room', error: e); if (mounted) { diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 238bc139..6f44da14 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -7,6 +7,7 @@ import '../../connection/connection_registry.dart'; import '../../focus/focusable_text_field.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../models/plex/plex_home.dart'; import '../../profiles/active_plex_identity.dart'; import '../../profiles/active_profile_provider.dart'; @@ -24,7 +25,7 @@ class DiscoveryView extends StatefulWidget { State createState() => _DiscoveryViewState(); } -class _DiscoveryViewState extends State with ControllerDisposerMixin { +class _DiscoveryViewState extends State with ControllerDisposerMixin, MountedSetStateMixin { late final _hostAddressController = createTextEditingController(); final _formKey = GlobalKey(); bool _isConnecting = false; @@ -127,7 +128,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi if (!mounted) return; setState(() => _errorMessage = _parseErrorMessage(e.toString())); } finally { - if (mounted) setState(() => _isConnecting = false); + setStateIfMounted(() => _isConnecting = false); } } diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index bd598e06..ee6a87c6 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../connection/connection_registry.dart'; import '../../i18n/strings.g.dart'; +import '../../mixins/mounted_set_state_mixin.dart'; import '../../profiles/active_plex_identity.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; @@ -27,7 +28,7 @@ class RemoteSessionDialog extends StatefulWidget { } } -class _RemoteSessionDialogState extends State { +class _RemoteSessionDialogState extends State with MountedSetStateMixin { bool _isStarting = false; String? _errorMessage; @@ -78,7 +79,7 @@ class _RemoteSessionDialogState extends State { await provider.startHostServer(); } - if (mounted) setState(() => _isStarting = false); + setStateIfMounted(() => _isStarting = false); } catch (e) { appLogger.e('Failed to start companion remote server', error: e); if (!mounted) return; diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 0bd3f371..2f05c169 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -14,6 +14,7 @@ import '../utils/grid_size_calculator.dart'; import '../theme/mono_tokens.dart'; import '../focus/locked_hub_controller.dart'; import '../media/media_hub.dart'; +import '../mixins/mounted_set_state_mixin.dart'; import '../screens/hub_detail_screen.dart'; import '../utils/media_navigation_helper.dart'; import 'focus_builders.dart'; @@ -76,7 +77,7 @@ class HubSection extends StatefulWidget { State createState() => HubSectionState(); } -class HubSectionState extends State { +class HubSectionState extends State with MountedSetStateMixin { static const _longPressDuration = Duration(milliseconds: 500); late FocusNode _hubFocusNode; @@ -130,7 +131,7 @@ class HubSectionState extends State { _longPressTriggered = false; } // ignore: no-empty-block - setState triggers rebuild to update focus styling - if (mounted) setState(() {}); + setStateIfMounted(() {}); } /// Request focus on this hub at a specific item index @@ -144,7 +145,7 @@ class HubSectionState extends State { _scrollToIndex(clamped); _hubFocusNode.requestFocus(); // ignore: no-empty-block - setState triggers rebuild to update focus styling - if (mounted) setState(() {}); + setStateIfMounted(() {}); _scrollHubIntoView(); } diff --git a/lib/widgets/loading_indicator_box.dart b/lib/widgets/loading_indicator_box.dart index 94eb63a0..1fd670a3 100644 --- a/lib/widgets/loading_indicator_box.dart +++ b/lib/widgets/loading_indicator_box.dart @@ -12,6 +12,11 @@ class LoadingIndicatorBox extends StatelessWidget { final double size; const LoadingIndicatorBox({super.key, this.size = 18}); + /// Full-screen centered spinner sized to fill the remaining space inside a + /// [CustomScrollView]. Replaces inline + /// `SliverFillRemaining(child: Center(child: CircularProgressIndicator()))`. + static const Widget sliver = SliverFillRemaining(child: Center(child: CircularProgressIndicator())); + @override Widget build(BuildContext context) => SizedBox(width: size, height: size, child: const CircularProgressIndicator(strokeWidth: 2)); diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 1932bed3..f6972961 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_memory_tracker.dart'; import '../media/media_library.dart'; +import '../mixins/mounted_set_state_mixin.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; @@ -186,7 +187,7 @@ class SideNavigationRail extends StatefulWidget { State createState() => SideNavigationRailState(); } -class SideNavigationRailState extends State { +class SideNavigationRailState extends State with MountedSetStateMixin { bool _librariesExpanded = true; bool _isHovered = false; @@ -225,7 +226,7 @@ class SideNavigationRailState extends State { _focusTracker = FocusMemoryTracker( onFocusChanged: () { // ignore: no-empty-block - setState triggers rebuild to update focus styling - if (mounted) setState(() {}); + setStateIfMounted(() {}); }, debugLabelPrefix: 'nav', ); diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index e96f666e..f6be4588 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; +import '../mixins/mounted_set_state_mixin.dart'; import '../utils/platform_detector.dart'; Future showTvVirtualKeyboard({ @@ -85,7 +86,7 @@ class _TvVirtualKeyboardDialog extends StatefulWidget { State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState(); } -class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { +class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with MountedSetStateMixin { static const double _keySize = 60; static const double _keyGap = 6; static const double _rowGap = 6; @@ -115,7 +116,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { } void _handleTextChanged() { - if (mounted) setState(() {}); + setStateIfMounted(() {}); } bool get _isNumberKeyboard {