refactor: adopt shared mixins, helpers, and sliver widgets

This commit is contained in:
edde746
2026-05-05 03:06:56 +02:00
parent ef8026797c
commit 955bc9548c
37 changed files with 181 additions and 174 deletions
+3 -2
View File
@@ -19,6 +19,7 @@ import 'profiles/active_profile_provider.dart';
import 'profiles/profile.dart'; import 'profiles/profile.dart';
import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_connection_registry.dart';
import 'profiles/profile_registry.dart'; import 'profiles/profile_registry.dart';
import 'mixins/mounted_set_state_mixin.dart';
import 'profiles/plex_home_service.dart'; import 'profiles/plex_home_service.dart';
import 'screens/main_screen.dart'; import 'screens/main_screen.dart';
import 'screens/auth_screen.dart'; import 'screens/auth_screen.dart';
@@ -991,7 +992,7 @@ class SetupScreen extends StatefulWidget {
State<SetupScreen> createState() => _SetupScreenState(); State<SetupScreen> createState() => _SetupScreenState();
} }
class _SetupScreenState extends State<SetupScreen> { class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
String _statusMessage = ''; String _statusMessage = '';
// Per-server connection status: serverId -> (name, connected?) // Per-server connection status: serverId -> (name, connected?)
@@ -1004,7 +1005,7 @@ class _SetupScreenState extends State<SetupScreen> {
} }
void _setStatus(String message) { void _setStatus(String message) {
if (mounted) setState(() => _statusMessage = message); setStateIfMounted(() => _statusMessage = message);
} }
Future<void> _loadSavedCredentials() async { Future<void> _loadSavedCredentials() async {
+2 -4
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../models.dart'; import '../../models.dart';
@@ -200,10 +201,7 @@ class PlayerAndroid extends PlayerBase {
final storedId = _hiddenSubtitleTrackId; final storedId = _hiddenSubtitleTrackId;
if (storedId != null) { if (storedId != null) {
_hiddenSubtitleTrackId = null; _hiddenSubtitleTrackId = null;
final track = state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere( final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
(t) => t?.id == storedId,
orElse: () => null,
);
if (track != null) { if (track != null) {
await selectSubtitleTrack(track); await selectSubtitleTrack(track);
} }
+5 -9
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show protected; import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -267,12 +268,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
case 'audio-device': case 'audio-device':
if (value is String && value.isNotEmpty) { if (value is String && value.isNotEmpty) {
final device = final device = _state.audioDevices.firstWhereOrNull((d) => d.name == value) ?? AudioDevice(name: value);
_state.audioDevices.cast<AudioDevice?>().firstWhere(
(d) => d?.name == value,
orElse: () => AudioDevice(name: value),
) ??
AudioDevice(name: value);
_state = _state.copyWith(audioDevice: device); _state = _state.copyWith(audioDevice: device);
audioDeviceController.add(device); audioDeviceController.add(device);
} }
@@ -441,7 +437,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
AudioTrack? selectedTrack; AudioTrack? selectedTrack;
if (id != null && id != 'no') { if (id != null && id != 'no') {
selectedTrack = _state.tracks.audio.cast<AudioTrack?>().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)); _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') selectedTrack = (id == null || id == 'no')
? SubtitleTrack.off ? SubtitleTrack.off
: _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null); : _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id);
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
trackController.add(_state.track); trackController.add(_state.track);
@@ -467,7 +463,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (id == null || id == 'no') { if (id == null || id == 'no') {
selectedTrack = null; selectedTrack = null;
} else { } else {
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().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)); _state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack));
+2 -2
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../mixins/disposable_change_notifier_mixin.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; bool get isShaderEnabled => _currentPreset.type != ShaderPresetType.none;
ShaderPreset? findPresetById(String id) { ShaderPreset? findPresetById(String id) {
return ShaderPreset.fromId(id) ?? return ShaderPreset.fromId(id) ?? _customPresets.firstWhereOrNull((p) => p.id == id);
_customPresets.cast<ShaderPreset?>().firstWhere((p) => p!.id == id, orElse: () => null);
} }
Future<void> setPreset(ShaderPreset preset) async { Future<void> setPreset(ShaderPreset preset) async {
@@ -8,6 +8,7 @@ import '../media/media_server_client.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../services/media_list_playback_launcher.dart'; import '../services/media_list_playback_launcher.dart';
import '../widgets/loading_indicator_box.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
@@ -124,7 +125,7 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
} }
if (items.isEmpty && isLoading) { if (items.isEmpty && isLoading) {
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))]; return [LoadingIndicatorBox.sliver];
} }
if (items.isEmpty) { if (items.isEmpty) {
+4 -10
View File
@@ -25,6 +25,7 @@ import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart'; import '../providers/libraries_provider.dart';
import '../providers/playback_state_provider.dart'; import '../providers/playback_state_provider.dart';
import '../widgets/hub_section.dart'; import '../widgets/hub_section.dart';
import '../widgets/loading_indicator_box.dart';
import 'profile/profile_switch_screen.dart'; import 'profile/profile_switch_screen.dart';
import '../connection/connection_registry.dart'; import '../connection/connection_registry.dart';
import '../profiles/active_profile_provider.dart'; import '../profiles/active_profile_provider.dart';
@@ -53,7 +54,7 @@ import '../utils/platform_detector.dart';
import '../theme/mono_tokens.dart'; import '../theme/mono_tokens.dart';
import '../services/watch_next_service.dart'; import '../services/watch_next_service.dart';
import 'auth_screen.dart'; import 'auth_screen.dart';
import 'libraries/state_messages.dart'; import 'libraries/content_state_builder.dart';
import 'main_screen.dart'; import 'main_screen.dart';
import '../watch_together/watch_together.dart'; import '../watch_together/watch_together.dart';
import '../providers/companion_remote_provider.dart'; import '../providers/companion_remote_provider.dart';
@@ -1173,15 +1174,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
); );
}, },
), ),
if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())), if (_isLoading) LoadingIndicatorBox.sliver,
if (_errorMessage != null) if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _loadContent),
SliverFillRemaining(
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadContent,
),
),
if (!_isLoading && _errorMessage == null) ...[ if (!_isLoading && _errorMessage == null) ...[
// On Deck / Continue Watching // On Deck / Continue Watching
if (_onDeck.isNotEmpty) if (_onDeck.isNotEmpty)
+4 -9
View File
@@ -11,12 +11,13 @@ import '../utils/provider_extensions.dart';
import '../widgets/focusable_media_card.dart'; import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart'; import '../widgets/media_grid_delegate.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../focus/focusable_action_bar.dart'; import '../focus/focusable_action_bar.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart';
import 'libraries/sort_bottom_sheet.dart'; import 'libraries/sort_bottom_sheet.dart';
import 'libraries/state_messages.dart'; import 'libraries/content_state_builder.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'focusable_detail_screen_mixin.dart'; import 'focusable_detail_screen_mixin.dart';
@@ -290,15 +291,9 @@ class _HubDetailScreenState extends State<HubDetailScreen>
slivers: [ slivers: [
CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()),
if (_errorMessage != null) if (_errorMessage != null)
SliverFillRemaining( SliverErrorState(message: _errorMessage!, onRetry: _loadMoreItems)
child: ErrorStateWidget(
message: _errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadMoreItems,
),
)
else if (_filteredItems.isEmpty && _isLoading) else if (_filteredItems.isEmpty && _isLoading)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) LoadingIndicatorBox.sliver
else if (_filteredItems.isEmpty) else if (_filteredItems.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound)))
else else
@@ -3,6 +3,39 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import 'state_messages.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 /// A widget that handles loading, error, empty, and content states
/// Provides a consistent UI pattern across the app for data-driven screens /// Provides a consistent UI pattern across the app for data-driven screens
class ContentStateBuilder<T> extends StatelessWidget { class ContentStateBuilder<T> extends StatelessWidget {
+4 -4
View File
@@ -4,6 +4,7 @@ import '../../media/media_item.dart';
import '../../media/media_kind.dart'; import '../../media/media_kind.dart';
import '../../services/play_queue_launcher.dart'; import '../../services/play_queue_launcher.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/error_message_utils.dart';
import '../../utils/media_navigation_helper.dart'; import '../../utils/media_navigation_helper.dart';
import '../../utils/provider_extensions.dart'; import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
@@ -85,9 +86,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
appLogger.e('Failed to load root folders', error: e);
setState(() { setState(() {
_errorMessage = t.errors.failedToLoad(context: t.libraries.folders, error: e.toString()); _errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
_isLoadingRoot = false; _isLoadingRoot = false;
}); });
} }
@@ -130,13 +130,13 @@ class FolderTreeViewState extends State<FolderTreeView> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
appLogger.e('Failed to load folder children', error: e); final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders);
setState(() { setState(() {
_loadingFolders.remove(folderKey); _loadingFolders.remove(folderKey);
}); });
if (mounted) { if (mounted) {
showErrorSnackBar(context, t.errors.failedToLoad(context: t.libraries.folders, error: e.toString())); showErrorSnackBar(context, message);
} }
} }
} }
@@ -30,6 +30,7 @@ import '../library_alpha_bar_strategy.dart';
import '../library_filter_sort_loader.dart'; import '../library_filter_sort_loader.dart';
import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/loading_indicator_box.dart';
import '../../../widgets/media_grid_delegate.dart'; import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/overlay_sheet.dart';
import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../mixins/library_tab_focus_mixin.dart';
@@ -38,7 +39,7 @@ import '../filters_bottom_sheet.dart';
import '../sort_bottom_sheet.dart'; import '../sort_bottom_sheet.dart';
import '../../../widgets/app_icon.dart'; import '../../../widgets/app_icon.dart';
import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/focusable_list_tile.dart';
import '../state_messages.dart'; import '../content_state_builder.dart';
import '../../../services/storage_service.dart'; import '../../../services/storage_service.dart';
import '../../../services/settings_service.dart'; import '../../../services/settings_service.dart';
import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/grid_focus_node_mixin.dart';
@@ -1384,28 +1385,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
} }
if (isLoading && totalSize == 0 && loadedItems.isEmpty) { if (isLoading && totalSize == 0 && loadedItems.isEmpty) {
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))]; return [LoadingIndicatorBox.sliver];
} }
if (errorMessage != null && loadedItems.isEmpty) { if (errorMessage != null && loadedItems.isEmpty) {
return [ return [SliverErrorState(message: errorMessage!, onRetry: _loadContent)];
SliverFillRemaining(
child: ErrorStateWidget(
message: errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadContent,
retryLabel: t.common.retry,
),
),
];
} }
if (totalSize == 0 && !isLoading) { if (totalSize == 0 && !isLoading) {
return [ return [SliverEmptyState(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded)];
SliverFillRemaining(
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
),
];
} }
return [ return [
@@ -6,12 +6,14 @@ import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_wrapper.dart'; import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart'; import '../../models/livetv_channel.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../models/livetv_program.dart'; import '../../models/livetv_program.dart';
import '../../providers/multi_server_provider.dart'; import '../../providers/multi_server_provider.dart';
import '../../theme/mono_tokens.dart'; import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../widgets/app_icon.dart'; import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/loading_indicator_box.dart';
import '../../widgets/overlay_sheet.dart'; import '../../widgets/overlay_sheet.dart';
import 'live_tv_actions_mixin.dart'; import 'live_tv_actions_mixin.dart';
import 'livetv_recording_actions.dart'; import 'livetv_recording_actions.dart';
@@ -34,7 +36,7 @@ class LiveTvShowScheduleScreen extends StatefulWidget {
} }
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen> class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
with LiveTvActionsMixin<LiveTvShowScheduleScreen> { with LiveTvActionsMixin<LiveTvShowScheduleScreen>, MountedSetStateMixin {
List<LiveTvProgram> _programs = []; List<LiveTvProgram> _programs = [];
bool _isLoading = true; bool _isLoading = true;
@@ -51,7 +53,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final genericClient = multiServer.getClientForServer(widget.serverId); final genericClient = multiServer.getClientForServer(widget.serverId);
if (genericClient == null) { if (genericClient == null) {
if (mounted) setState(() => _isLoading = false); setStateIfMounted(() => _isLoading = false);
return; return;
} }
@@ -128,7 +130,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
: null, : null,
slivers: [ slivers: [
if (_isLoading) if (_isLoading)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) LoadingIndicatorBox.sliver
else if (_programs.isEmpty) else if (_programs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms)))
else else
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart'; import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../media/media_server_client.dart'; import '../../media/media_server_client.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../models/livetv_channel.dart'; import '../../models/livetv_channel.dart';
import '../../models/livetv_program.dart'; import '../../models/livetv_program.dart';
import '../../models/media_subscription.dart'; import '../../models/media_subscription.dart';
@@ -79,7 +80,7 @@ class _ProgramDetailsSheetContent extends StatefulWidget {
State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState(); State<_ProgramDetailsSheetContent> createState() => _ProgramDetailsSheetContentState();
} }
class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> { class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent> with MountedSetStateMixin {
final List<FocusNode> _focusNodes = []; final List<FocusNode> _focusNodes = [];
MediaSubscription? _existingSubscription; MediaSubscription? _existingSubscription;
bool _checkedMapping = false; bool _checkedMapping = false;
@@ -115,7 +116,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
final providerId = widget.program.providerIdentifier; final providerId = widget.program.providerIdentifier;
final ratingKey = widget.program.ratingKey; final ratingKey = widget.program.ratingKey;
if (client == null || providerId == null || providerId.isEmpty || ratingKey == null || ratingKey.isEmpty) { if (client == null || providerId == null || providerId.isEmpty || ratingKey == null || ratingKey.isEmpty) {
if (mounted) setState(() => _checkedMapping = true); setStateIfMounted(() => _checkedMapping = true);
return; return;
} }
try { try {
@@ -129,7 +130,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
} catch (e) { } catch (e) {
// 403 (no DVR access) or transient — treat as unscheduled. // 403 (no DVR access) or transient — treat as unscheduled.
appLogger.d('Subscription mapping check failed: $e'); appLogger.d('Subscription mapping check failed: $e');
if (mounted) setState(() => _checkedMapping = true); setStateIfMounted(() => _checkedMapping = true);
} }
} }
+5 -16
View File
@@ -8,6 +8,7 @@ import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart'; import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../media/media_server_client.dart'; import '../../media/media_server_client.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../models/livetv_program.dart'; import '../../models/livetv_program.dart';
import '../../models/media_subscription.dart'; import '../../models/media_subscription.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
@@ -472,13 +473,13 @@ class _IntSettingRow extends StatefulWidget {
State<_IntSettingRow> createState() => _IntSettingRowState(); State<_IntSettingRow> createState() => _IntSettingRowState();
} }
class _IntSettingRowState extends State<_IntSettingRow> { class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerMixin {
late final TextEditingController _controller; late final TextEditingController _controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: widget.currentValue?.toString() ?? ''); _controller = createTextEditingController(text: widget.currentValue?.toString() ?? '');
} }
@override @override
@@ -488,12 +489,6 @@ class _IntSettingRowState extends State<_IntSettingRow> {
if (next != _controller.text) _controller.text = next; if (next != _controller.text) _controller.text = next;
} }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -542,13 +537,13 @@ class _TextSettingRow extends StatefulWidget {
State<_TextSettingRow> createState() => _TextSettingRowState(); State<_TextSettingRow> createState() => _TextSettingRowState();
} }
class _TextSettingRowState extends State<_TextSettingRow> { class _TextSettingRowState extends State<_TextSettingRow> with ControllerDisposerMixin {
late final TextEditingController _controller; late final TextEditingController _controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: widget.currentValue?.toString() ?? ''); _controller = createTextEditingController(text: widget.currentValue?.toString() ?? '');
} }
@override @override
@@ -558,12 +553,6 @@ class _TextSettingRowState extends State<_TextSettingRow> {
if (next != _controller.text) _controller.text = next; if (next != _controller.text) _controller.text = next;
} }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
+4 -3
View File
@@ -10,6 +10,7 @@ import '../../../focus/dpad_navigator.dart';
import '../../../focus/input_mode_tracker.dart'; import '../../../focus/input_mode_tracker.dart';
import '../../../focus/key_event_utils.dart'; import '../../../focus/key_event_utils.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
import '../../../models/livetv_channel.dart'; import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart'; import '../../../models/livetv_program.dart';
import '../../../providers/multi_server_provider.dart'; import '../../../providers/multi_server_provider.dart';
@@ -45,7 +46,7 @@ class GuideTab extends StatefulWidget {
enum _GuideZone { timeNav, grid } enum _GuideZone { timeNav, grid }
class GuideTabState extends State<GuideTab> { class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
static const _slotWidth = 180.0; static const _slotWidth = 180.0;
static const _channelColumnWidth = 100.0; static const _channelColumnWidth = 100.0;
static const _rowHeight = 64.0; static const _rowHeight = 64.0;
@@ -111,7 +112,7 @@ class GuideTabState extends State<GuideTab> {
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
// ignore: no-empty-block - setState triggers rebuild to update time indicator // ignore: no-empty-block - setState triggers rebuild to update time indicator
if (mounted) setState(() {}); setStateIfMounted(() {});
}); });
} }
@@ -121,7 +122,7 @@ class GuideTabState extends State<GuideTab> {
_timeIndicatorTimer?.cancel(); _timeIndicatorTimer?.cancel();
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) { _timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
// ignore: no-empty-block - setState triggers rebuild to update time indicator // ignore: no-empty-block - setState triggers rebuild to update time indicator
if (mounted) setState(() {}); setStateIfMounted(() {});
}); });
} }
+6 -5
View File
@@ -10,6 +10,7 @@ import '../../../focus/key_event_utils.dart';
import '../../../focus/locked_hub_controller.dart'; import '../../../focus/locked_hub_controller.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../media/media_item_types.dart'; import '../../../media/media_item_types.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
import '../../../models/livetv_channel.dart'; import '../../../models/livetv_channel.dart';
import '../../../models/livetv_hub_result.dart'; import '../../../models/livetv_hub_result.dart';
import '../../../providers/multi_server_provider.dart'; import '../../../providers/multi_server_provider.dart';
@@ -39,7 +40,7 @@ class WhatsOnTab extends StatefulWidget {
State<WhatsOnTab> createState() => WhatsOnTabState(); State<WhatsOnTab> createState() => WhatsOnTabState();
} }
class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnTab> { class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin {
List<LiveTvHubResult> _hubs = []; List<LiveTvHubResult> _hubs = [];
bool _isLoading = true; bool _isLoading = true;
Timer? _refreshTimer; Timer? _refreshTimer;
@@ -104,7 +105,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
}); });
} catch (e) { } catch (e) {
appLogger.e('Failed to load live TV hubs', error: e); appLogger.e('Failed to load live TV hubs', error: e);
if (mounted) setState(() => _isLoading = false); setStateIfMounted(() => _isLoading = false);
} }
} }
@@ -223,7 +224,7 @@ class _LiveTvHubSection extends StatefulWidget {
State<_LiveTvHubSection> createState() => _LiveTvHubSectionState(); State<_LiveTvHubSection> createState() => _LiveTvHubSectionState();
} }
class _LiveTvHubSectionState extends State<_LiveTvHubSection> { class _LiveTvHubSectionState extends State<_LiveTvHubSection> with MountedSetStateMixin {
static const _longPressDuration = Duration(milliseconds: 500); static const _longPressDuration = Duration(milliseconds: 500);
late FocusNode _hubFocusNode; late FocusNode _hubFocusNode;
@@ -271,7 +272,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
_longPressTriggered = false; _longPressTriggered = false;
} }
// ignore: no-empty-block - setState triggers rebuild to update focus styling // ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {}); setStateIfMounted(() {});
} }
void requestFocusAt(int index) { void requestFocusAt(int index) {
@@ -283,7 +284,7 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
_scrollToIndex(clamped); _scrollToIndex(clamped);
_hubFocusNode.requestFocus(); _hubFocusNode.requestFocus();
// ignore: no-empty-block - setState triggers rebuild to update focus styling // ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {}); setStateIfMounted(() {});
_scrollHubIntoView(); _scrollHubIntoView();
} }
+4 -2
View File
@@ -18,6 +18,7 @@ import '../utils/snackbar_helper.dart';
import '../utils/update_dialog.dart'; import '../utils/update_dialog.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import '../main.dart'; import '../main.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../mixins/tab_visibility_aware.dart'; import '../mixins/tab_visibility_aware.dart';
@@ -96,7 +97,8 @@ class MainScreen extends StatefulWidget {
State<MainScreen> createState() => _MainScreenState(); State<MainScreen> createState() => _MainScreenState();
} }
class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener, WidgetsBindingObserver { class _MainScreenState extends State<MainScreen>
with RouteAware, WindowListener, WidgetsBindingObserver, MountedSetStateMixin {
NavigationTabId _currentTab = NavigationTabId.discover; NavigationTabId _currentTab = NavigationTabId.discover;
String? _selectedLibraryGlobalKey; String? _selectedLibraryGlobalKey;
@@ -797,7 +799,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
await serverManager.checkServerHealth(); await serverManager.checkServerHealth();
await serverManager.reconnectOfflineServers(forceRediscovery: true); await serverManager.reconnectOfflineServers(forceRediscovery: true);
} finally { } finally {
if (mounted) setState(() => _isReconnecting = false); setStateIfMounted(() => _isReconnecting = false);
} }
}()); }());
} }
+19 -36
View File
@@ -1848,6 +1848,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return Symbols.recommend_rounded; 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 /// Build episode list directly when the library hides seasons for single-season shows
Widget _buildEpisodesList() { Widget _buildEpisodesList() {
final client = _getMediaClientForMetadata(context); final client = _getMediaClientForMetadata(context);
@@ -2217,19 +2230,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (isShow && !_showEpisodesDirectly) ...[ if (isShow && !_showEpisodesDirectly) ...[
// Season tabs + inline episodes // Season tabs + inline episodes
if (_isLoadingSeasons) if (_isLoadingSeasons)
const Center( _sectionLoading
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()),
)
else if (_seasons.isEmpty) else if (_seasons.isEmpty)
Padding( _sectionEmpty(context, t.messages.noSeasonsFound)
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
t.messages.noSeasonsFound,
style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey),
),
),
)
else ...[ else ...[
Text( Text(
key: _seasonsSectionKey, key: _seasonsSectionKey,
@@ -2240,21 +2243,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_buildSeasonTabs(), _buildSeasonTabs(),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_isLoadingSeasonEpisodes) if (_isLoadingSeasonEpisodes)
const Center( _sectionLoading
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()),
)
else if (_episodes.isNotEmpty) else if (_episodes.isNotEmpty)
_buildEpisodesList() _buildEpisodesList()
else else
Padding( _sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
t.messages.noEpisodesFoundGeneral,
style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey),
),
),
),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[
@@ -2266,21 +2259,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_isLoadingSeasons || _isLoadingEpisodes) if (_isLoadingSeasons || _isLoadingEpisodes)
const Center( _sectionLoading
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()),
)
else if (_episodes.isNotEmpty) else if (_episodes.isNotEmpty)
_buildEpisodesList() _buildEpisodesList()
else else
Padding( _sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
t.messages.noEpisodesFoundGeneral,
style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey),
),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
+3 -6
View File
@@ -7,6 +7,7 @@ import '../media/media_kind.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/formatters.dart';
import '../utils/language_codes.dart'; import '../utils/language_codes.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
@@ -269,8 +270,7 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
if (picked != null && mounted) { if (picked != null && mounted) {
setState(() { setState(() {
_originallyAvailableAt = _originallyAvailableAt = '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}';
'${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}';
}); });
} }
} }
@@ -456,10 +456,7 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) { if (_isLoading) {
return FocusedScrollScaffold( return FocusedScrollScaffold(title: Text(t.metadataEdit.screenTitle), slivers: [LoadingIndicatorBox.sliver]);
title: Text(t.metadataEdit.screenTitle),
slivers: [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))],
);
} }
return FocusedScrollScaffold( return FocusedScrollScaffold(
@@ -23,6 +23,7 @@ import '../../utils/app_logger.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart'; import '../../widgets/app_icon.dart';
import '../../widgets/backend_badge.dart'; import '../../widgets/backend_badge.dart';
import '../../widgets/loading_indicator_box.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
import '../libraries/state_messages.dart'; import '../libraries/state_messages.dart';
import 'pin_entry_dialog.dart'; import 'pin_entry_dialog.dart';
@@ -175,7 +176,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
), ),
), ),
if (snapshot.connectionState != ConnectionState.done) if (snapshot.connectionState != ConnectionState.done)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) LoadingIndicatorBox.sliver
else if (candidates.isEmpty) else if (candidates.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: EmptyStateWidget( child: EmptyStateWidget(
@@ -9,6 +9,7 @@ import '../../connection/connection_registry.dart';
import '../../focus/focusable_wrapper.dart'; import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../media/media_backend.dart'; import '../../media/media_backend.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart'; import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart'; import '../../profiles/plex_home_service.dart';
@@ -47,7 +48,7 @@ class ProfileSwitchScreen extends StatefulWidget {
State<ProfileSwitchScreen> createState() => _ProfileSwitchScreenState(); State<ProfileSwitchScreen> createState() => _ProfileSwitchScreenState();
} }
class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> { class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedSetStateMixin {
bool _allowPop = false; bool _allowPop = false;
final FocusNode _firstSelectableFocusNode = FocusNode(); final FocusNode _firstSelectableFocusNode = FocusNode();
bool _focusRequested = false; bool _focusRequested = false;
@@ -68,7 +69,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
if (_storage == null) { if (_storage == null) {
unawaited( unawaited(
StorageService.getInstance().then((s) { StorageService.getInstance().then((s) {
if (mounted) setState(() => _storage = s); setStateIfMounted(() => _storage = s);
}), }),
); );
} }
@@ -351,7 +352,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
} }
navigator.pop(true); navigator.pop(true);
} finally { } finally {
if (mounted) setState(() => _switching = false); setStateIfMounted(() => _switching = false);
} }
} }
} }
+2 -1
View File
@@ -13,6 +13,7 @@ import '../providers/multi_server_provider.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/pill_input_decoration.dart'; import '../widgets/pill_input_decoration.dart';
import '../widgets/focusable_media_card.dart'; import '../widgets/focusable_media_card.dart';
import '../utils/focus_utils.dart'; import '../utils/focus_utils.dart';
@@ -232,7 +233,7 @@ class _SearchScreenState extends State<SearchScreen>
), ),
), ),
if (_isSearching) if (_isSearching)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) LoadingIndicatorBox.sliver
else if (!_hasSearched) else if (!_hasSearched)
SliverFillRemaining( SliverFillRemaining(
child: StateMessageWidget( child: StateMessageWidget(
+2 -4
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/loading_indicator_box.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
class MergedLicenseEntry { class MergedLicenseEntry {
@@ -52,10 +53,7 @@ class LicensesScreen extends StatelessWidget {
builder: (context, snapshot) { builder: (context, snapshot) {
final mergedLicenses = snapshot.data; final mergedLicenses = snapshot.data;
if (mergedLicenses == null) { if (mergedLicenses == null) {
return FocusedScrollScaffold( return FocusedScrollScaffold(title: Text(t.screens.licenses), slivers: [LoadingIndicatorBox.sliver]);
title: Text(t.screens.licenses),
slivers: const [SliverFillRemaining(child: Center(child: CircularProgressIndicator()))],
);
} }
return FocusedScrollScaffold( return FocusedScrollScaffold(
+8 -6
View File
@@ -13,9 +13,11 @@ import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart'; import '../../focus/focusable_button.dart';
import '../../focus/key_event_utils.dart'; import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../utils/dialogs.dart'; import '../../utils/dialogs.dart';
import '../../main.dart' show gitCommit; import '../../main.dart' show gitCommit;
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart'; import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
@@ -27,7 +29,7 @@ class LogsScreen extends StatefulWidget {
State<LogsScreen> createState() => _LogsScreenState(); State<LogsScreen> createState() => _LogsScreenState();
} }
class _LogsScreenState extends State<LogsScreen> { class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
List<LogEntry> _logs = []; List<LogEntry> _logs = [];
String _deviceInfo = ''; String _deviceInfo = '';
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -64,7 +66,7 @@ class _LogsScreenState extends State<LogsScreen> {
buffer.writeln('Linux ${info.versionId ?? info.id}'); buffer.writeln('Linux ${info.versionId ?? info.id}');
} }
if (mounted) setState(() => _deviceInfo = buffer.toString().trimRight()); setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight());
} }
@override @override
@@ -80,10 +82,10 @@ class _LogsScreenState extends State<LogsScreen> {
} }
String _formatTime(DateTime time) { String _formatTime(DateTime time) {
final hour = time.hour.toString().padLeft(2, '0'); final hour = padNumber(time.hour, 2);
final minute = time.minute.toString().padLeft(2, '0'); final minute = padNumber(time.minute, 2);
final second = time.second.toString().padLeft(2, '0'); final second = padNumber(time.second, 2);
final millisecond = time.millisecond.toString().padLeft(3, '0'); final millisecond = padNumber(time.millisecond, 3);
return '$hour:$minute:$second.$millisecond'; return '$hour:$minute:$second.$millisecond';
} }
+4 -3
View File
@@ -14,6 +14,7 @@ import '../../focus/focusable_text_field.dart';
import '../../focus/input_mode_tracker.dart'; import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../main_screen.dart'; import '../main_screen.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../mixins/refreshable.dart'; import '../../mixins/refreshable.dart';
import '../../providers/hidden_libraries_provider.dart'; import '../../providers/hidden_libraries_provider.dart';
import '../../providers/libraries_provider.dart'; import '../../providers/libraries_provider.dart';
@@ -57,7 +58,7 @@ class SettingsScreen extends StatefulWidget {
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
} }
class _SettingsScreenState extends State<SettingsScreen> with FocusableTab { class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, MountedSetStateMixin {
late final FocusMemoryTracker _focusTracker; late final FocusMemoryTracker _focusTracker;
// Focus tracking keys // Focus tracking keys
@@ -95,13 +96,13 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
_focusTracker = FocusMemoryTracker( _focusTracker = FocusMemoryTracker(
onFocusChanged: () { onFocusChanged: () {
// ignore: no-empty-block - setState triggers rebuild to update focus styling // ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {}); setStateIfMounted(() {});
}, },
debugLabelPrefix: 'settings', debugLabelPrefix: 'settings',
); );
if (_keyboardShortcutsSupported) { if (_keyboardShortcutsSupported) {
KeyboardShortcutsService.getInstance().then((s) { KeyboardShortcutsService.getInstance().then((s) {
if (mounted) setState(() => _keyboardService = s); setStateIfMounted(() => _keyboardService = s);
}); });
} }
} }
+1 -2
View File
@@ -94,8 +94,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
// leak the token verbatim. `LogRedactionManager.redact()` also has // leak the token verbatim. `LogRedactionManager.redact()` also has
// pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the // pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the
// `Authorization: MediaBrowser ... Token="..."` header. // `Authorization: MediaBrowser ... Token="..."` header.
LogRedactionManager.registerServerUrl(connection.baseUrl); LogRedactionManager.registerServer(connection.baseUrl, connection.accessToken);
LogRedactionManager.registerToken(connection.accessToken);
String version = '1.0'; String version = '1.0';
try { try {
final pkg = await PackageInfo.fromPlatform(); final pkg = await PackageInfo.fromPlatform();
+3 -1
View File
@@ -1,3 +1,5 @@
import 'package:collection/collection.dart';
import '../media/media_version.dart'; import '../media/media_version.dart';
import '../media/media_source_info.dart'; import '../media/media_source_info.dart';
import '../utils/jellyfin_time.dart'; import '../utils/jellyfin_time.dart';
@@ -197,7 +199,7 @@ Map<int, TrickplayInfo>? _parseTrickplayManifest(Object? raw, String? sourceId)
// Source id not in the manifest — fall back to the first nested // Source id not in the manifest — fall back to the first nested
// entry so the user still gets *something*. The caller already // entry so the user still gets *something*. The caller already
// chose the right source; this is best-effort recovery. // chose the right source; this is best-effort recovery.
final first = raw.values.cast<Object?>().firstWhere((v) => v is Map, orElse: () => null); final first = raw.values.firstWhereOrNull((v) => v is Map);
if (first is! Map) return null; if (first is! Map) return null;
resolutionMap = first; resolutionMap = first;
} }
+1 -2
View File
@@ -248,8 +248,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
: null, : null,
_onEndpointChanged = onEndpointChanged, _onEndpointChanged = onEndpointChanged,
_onAllEndpointsExhausted = onAllEndpointsExhausted { _onAllEndpointsExhausted = onAllEndpointsExhausted {
LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerServer(config.baseUrl, config.token);
LogRedactionManager.registerToken(config.token);
_http = MediaServerHttpClient( _http = MediaServerHttpClient(
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
+4 -3
View File
@@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/formatters.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import 'file_picker_service.dart'; import 'file_picker_service.dart';
import 'settings_service.dart'; import 'settings_service.dart';
@@ -271,9 +272,9 @@ class SettingsExportService {
static Future<String> _defaultFileName() async { static Future<String> _defaultFileName() async {
final now = DateTime.now(); final now = DateTime.now();
final y = now.year.toString().padLeft(4, '0'); final y = padNumber(now.year, 4);
final m = now.month.toString().padLeft(2, '0'); final m = padNumber(now.month, 2);
final d = now.day.toString().padLeft(2, '0'); final d = padNumber(now.day, 2);
return 'plezy-settings-$y$m$d.$fileExtension'; return 'plezy-settings-$y$m$d.$fileExtension';
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import '../models/shader_preset.dart'; import '../models/shader_preset.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
@@ -211,7 +212,7 @@ class ShaderAssetLoader {
static Future<String> importCustomShader(String sourcePath) async { static Future<String> importCustomShader(String sourcePath) async {
final customDir = await _getCustomShaderDirectory(); final customDir = await _getCustomShaderDirectory();
final ext = path.extension(sourcePath); final ext = path.extension(sourcePath);
final uuid = DateTime.now().millisecondsSinceEpoch.toRadixString(36); final uuid = const Uuid().v4();
final storedName = '$uuid$ext'; final storedName = '$uuid$ext';
final targetFile = File(path.join(customDir, storedName)); final targetFile = File(path.join(customDir, storedName));
+8
View File
@@ -85,6 +85,14 @@ class LogRedactionManager {
_rebuildCombinedPattern(); _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. /// Register other sensitive values that need redaction.
static void registerCustomValue(String? value) { static void registerCustomValue(String? value) {
final normalized = _normalize(value); final normalized = _normalize(value);
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../focus/focusable_button.dart'; import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart'; import '../../focus/focusable_wrapper.dart';
@@ -74,7 +75,7 @@ class _NotInSessionView extends StatefulWidget {
State<_NotInSessionView> createState() => _NotInSessionViewState(); State<_NotInSessionView> createState() => _NotInSessionViewState();
} }
class _NotInSessionViewState extends State<_NotInSessionView> { class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetStateMixin {
bool _isCreating = false; bool _isCreating = false;
bool _isJoining = false; bool _isJoining = false;
String? _enteringRoomCode; String? _enteringRoomCode;
@@ -218,7 +219,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
displayName: _plexDisplayName, displayName: _plexDisplayName,
); );
await RecentRoomsService.addOrUpdateRoom(sessionId, controlMode: controlMode); await RecentRoomsService.addOrUpdateRoom(sessionId, controlMode: controlMode);
if (mounted) setState(() => _recentRooms = RecentRoomsService.getRecentRooms()); setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
} catch (e) { } catch (e) {
appLogger.e('Failed to create session', error: e); appLogger.e('Failed to create session', error: e);
if (mounted) { if (mounted) {
@@ -262,7 +263,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
try { try {
await widget.watchTogether.joinSession(sessionId, displayName: _plexDisplayName); await widget.watchTogether.joinSession(sessionId, displayName: _plexDisplayName);
await RecentRoomsService.addOrUpdateRoom(sessionId); await RecentRoomsService.addOrUpdateRoom(sessionId);
if (mounted) setState(() => _recentRooms = RecentRoomsService.getRecentRooms()); setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
} catch (e) { } catch (e) {
appLogger.e('Failed to join session', error: e); appLogger.e('Failed to join session', error: e);
if (mounted) { if (mounted) {
@@ -285,7 +286,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
displayName: _plexDisplayName, displayName: _plexDisplayName,
); );
await RecentRoomsService.addOrUpdateRoom(room.code); await RecentRoomsService.addOrUpdateRoom(room.code);
if (mounted) setState(() => _recentRooms = RecentRoomsService.getRecentRooms()); setStateIfMounted(() => _recentRooms = RecentRoomsService.getRecentRooms());
} catch (e) { } catch (e) {
appLogger.e('Failed to enter room', error: e); appLogger.e('Failed to enter room', error: e);
if (mounted) { if (mounted) {
@@ -7,6 +7,7 @@ import '../../connection/connection_registry.dart';
import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_text_field.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart'; import '../../mixins/controller_disposer_mixin.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../models/plex/plex_home.dart'; import '../../models/plex/plex_home.dart';
import '../../profiles/active_plex_identity.dart'; import '../../profiles/active_plex_identity.dart';
import '../../profiles/active_profile_provider.dart'; import '../../profiles/active_profile_provider.dart';
@@ -24,7 +25,7 @@ class DiscoveryView extends StatefulWidget {
State<DiscoveryView> createState() => _DiscoveryViewState(); State<DiscoveryView> createState() => _DiscoveryViewState();
} }
class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin { class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin, MountedSetStateMixin {
late final _hostAddressController = createTextEditingController(); late final _hostAddressController = createTextEditingController();
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isConnecting = false; bool _isConnecting = false;
@@ -127,7 +128,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
if (!mounted) return; if (!mounted) return;
setState(() => _errorMessage = _parseErrorMessage(e.toString())); setState(() => _errorMessage = _parseErrorMessage(e.toString()));
} finally { } finally {
if (mounted) setState(() => _isConnecting = false); setStateIfMounted(() => _isConnecting = false);
} }
} }
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../connection/connection_registry.dart'; import '../../connection/connection_registry.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../profiles/active_plex_identity.dart'; import '../../profiles/active_plex_identity.dart';
import '../../profiles/active_profile_provider.dart'; import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart'; import '../../profiles/plex_home_service.dart';
@@ -27,7 +28,7 @@ class RemoteSessionDialog extends StatefulWidget {
} }
} }
class _RemoteSessionDialogState extends State<RemoteSessionDialog> { class _RemoteSessionDialogState extends State<RemoteSessionDialog> with MountedSetStateMixin {
bool _isStarting = false; bool _isStarting = false;
String? _errorMessage; String? _errorMessage;
@@ -78,7 +79,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
await provider.startHostServer(); await provider.startHostServer();
} }
if (mounted) setState(() => _isStarting = false); setStateIfMounted(() => _isStarting = false);
} catch (e) { } catch (e) {
appLogger.e('Failed to start companion remote server', error: e); appLogger.e('Failed to start companion remote server', error: e);
if (!mounted) return; if (!mounted) return;
+4 -3
View File
@@ -14,6 +14,7 @@ import '../utils/grid_size_calculator.dart';
import '../theme/mono_tokens.dart'; import '../theme/mono_tokens.dart';
import '../focus/locked_hub_controller.dart'; import '../focus/locked_hub_controller.dart';
import '../media/media_hub.dart'; import '../media/media_hub.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../screens/hub_detail_screen.dart'; import '../screens/hub_detail_screen.dart';
import '../utils/media_navigation_helper.dart'; import '../utils/media_navigation_helper.dart';
import 'focus_builders.dart'; import 'focus_builders.dart';
@@ -76,7 +77,7 @@ class HubSection extends StatefulWidget {
State<HubSection> createState() => HubSectionState(); State<HubSection> createState() => HubSectionState();
} }
class HubSectionState extends State<HubSection> { class HubSectionState extends State<HubSection> with MountedSetStateMixin {
static const _longPressDuration = Duration(milliseconds: 500); static const _longPressDuration = Duration(milliseconds: 500);
late FocusNode _hubFocusNode; late FocusNode _hubFocusNode;
@@ -130,7 +131,7 @@ class HubSectionState extends State<HubSection> {
_longPressTriggered = false; _longPressTriggered = false;
} }
// ignore: no-empty-block - setState triggers rebuild to update focus styling // 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 /// Request focus on this hub at a specific item index
@@ -144,7 +145,7 @@ class HubSectionState extends State<HubSection> {
_scrollToIndex(clamped); _scrollToIndex(clamped);
_hubFocusNode.requestFocus(); _hubFocusNode.requestFocus();
// ignore: no-empty-block - setState triggers rebuild to update focus styling // ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {}); setStateIfMounted(() {});
_scrollHubIntoView(); _scrollHubIntoView();
} }
+5
View File
@@ -12,6 +12,11 @@ class LoadingIndicatorBox extends StatelessWidget {
final double size; final double size;
const LoadingIndicatorBox({super.key, this.size = 18}); 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 @override
Widget build(BuildContext context) => Widget build(BuildContext context) =>
SizedBox(width: size, height: size, child: const CircularProgressIndicator(strokeWidth: 2)); SizedBox(width: size, height: size, child: const CircularProgressIndicator(strokeWidth: 2));
+3 -2
View File
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/focus_memory_tracker.dart'; import '../focus/focus_memory_tracker.dart';
import '../media/media_library.dart'; import '../media/media_library.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../navigation/navigation_tabs.dart'; import '../navigation/navigation_tabs.dart';
import '../providers/hidden_libraries_provider.dart'; import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart'; import '../providers/libraries_provider.dart';
@@ -186,7 +187,7 @@ class SideNavigationRail extends StatefulWidget {
State<SideNavigationRail> createState() => SideNavigationRailState(); State<SideNavigationRail> createState() => SideNavigationRailState();
} }
class SideNavigationRailState extends State<SideNavigationRail> { class SideNavigationRailState extends State<SideNavigationRail> with MountedSetStateMixin {
bool _librariesExpanded = true; bool _librariesExpanded = true;
bool _isHovered = false; bool _isHovered = false;
@@ -225,7 +226,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_focusTracker = FocusMemoryTracker( _focusTracker = FocusMemoryTracker(
onFocusChanged: () { onFocusChanged: () {
// ignore: no-empty-block - setState triggers rebuild to update focus styling // ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {}); setStateIfMounted(() {});
}, },
debugLabelPrefix: 'nav', debugLabelPrefix: 'nav',
); );
+3 -2
View File
@@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
Future<void> showTvVirtualKeyboard({ Future<void> showTvVirtualKeyboard({
@@ -85,7 +86,7 @@ class _TvVirtualKeyboardDialog extends StatefulWidget {
State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState(); State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState();
} }
class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with MountedSetStateMixin {
static const double _keySize = 60; static const double _keySize = 60;
static const double _keyGap = 6; static const double _keyGap = 6;
static const double _rowGap = 6; static const double _rowGap = 6;
@@ -115,7 +116,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> {
} }
void _handleTextChanged() { void _handleTextChanged() {
if (mounted) setState(() {}); setStateIfMounted(() {});
} }
bool get _isNumberKeyboard { bool get _isNumberKeyboard {