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_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<SetupScreen> createState() => _SetupScreenState();
}
class _SetupScreenState extends State<SetupScreen> {
class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
String _statusMessage = '';
// Per-server connection status: serverId -> (name, connected?)
@@ -1004,7 +1005,7 @@ class _SetupScreenState extends State<SetupScreen> {
}
void _setStatus(String message) {
if (mounted) setState(() => _statusMessage = message);
setStateIfMounted(() => _statusMessage = message);
}
Future<void> _loadSavedCredentials() async {
+2 -4
View File
@@ -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<SubtitleTrack?>().firstWhere(
(t) => t?.id == storedId,
orElse: () => null,
);
final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
if (track != null) {
await selectSubtitleTrack(track);
}
+5 -9
View File
@@ -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<AudioDevice?>().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<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));
@@ -454,7 +450,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
selectedTrack = (id == null || id == 'no')
? 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));
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<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));
+2 -2
View File
@@ -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<ShaderPreset?>().firstWhere((p) => p!.id == id, orElse: () => null);
return ShaderPreset.fromId(id) ?? _customPresets.firstWhereOrNull((p) => p.id == id);
}
Future<void> setPreset(ShaderPreset preset) async {
@@ -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<T extends StatefulWidget> extends State
}
if (items.isEmpty && isLoading) {
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
return [LoadingIndicatorBox.sliver];
}
if (items.isEmpty) {
+4 -10
View File
@@ -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<DiscoverScreen>
);
},
),
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)
+4 -9
View File
@@ -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<HubDetailScreen>
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
@@ -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<T> extends StatelessWidget {
+4 -4
View File
@@ -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<FolderTreeView> {
} 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<FolderTreeView> {
} 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);
}
}
}
@@ -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<MediaItem, LibraryBrows
}
if (isLoading && totalSize == 0 && loadedItems.isEmpty) {
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
return [LoadingIndicatorBox.sliver];
}
if (errorMessage != null && loadedItems.isEmpty) {
return [
SliverFillRemaining(
child: ErrorStateWidget(
message: errorMessage!,
icon: Symbols.error_outline_rounded,
onRetry: _loadContent,
retryLabel: t.common.retry,
),
),
];
return [SliverErrorState(message: errorMessage!, onRetry: _loadContent)];
}
if (totalSize == 0 && !isLoading) {
return [
SliverFillRemaining(
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
),
];
return [SliverEmptyState(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded)];
}
return [
@@ -6,12 +6,14 @@ import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../models/livetv_program.dart';
import '../../providers/multi_server_provider.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/loading_indicator_box.dart';
import '../../widgets/overlay_sheet.dart';
import 'live_tv_actions_mixin.dart';
import 'livetv_recording_actions.dart';
@@ -34,7 +36,7 @@ class LiveTvShowScheduleScreen extends StatefulWidget {
}
class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
with LiveTvActionsMixin<LiveTvShowScheduleScreen> {
with LiveTvActionsMixin<LiveTvShowScheduleScreen>, MountedSetStateMixin {
List<LiveTvProgram> _programs = [];
bool _isLoading = true;
@@ -51,7 +53,7 @@ class _LiveTvShowScheduleScreenState extends State<LiveTvShowScheduleScreen>
final multiServer = context.read<MultiServerProvider>();
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<LiveTvShowScheduleScreen>
: 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
@@ -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<FocusNode> _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);
}
}
+5 -16
View File
@@ -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);
+4 -3
View File
@@ -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<GuideTab> {
class GuideTabState extends State<GuideTab> 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<GuideTab> {
_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<GuideTab> {
_timeIndicatorTimer?.cancel();
_timeIndicatorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
// 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 '../../../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<WhatsOnTab> createState() => WhatsOnTabState();
}
class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnTab> {
class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnTab>, MountedSetStateMixin {
List<LiveTvHubResult> _hubs = [];
bool _isLoading = true;
Timer? _refreshTimer;
@@ -104,7 +105,7 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
});
} catch (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();
}
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();
}
+4 -2
View File
@@ -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<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;
String? _selectedLibraryGlobalKey;
@@ -797,7 +799,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
await serverManager.checkServerHealth();
await serverManager.reconnectOfflineServers(forceRediscovery: true);
} 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;
}
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<MediaDetailScreen>
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<MediaDetailScreen>
_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<MediaDetailScreen>
),
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),
],
+3 -6
View File
@@ -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<PlexMetadataEditScreen> {
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<PlexMetadataEditScreen> {
@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(
@@ -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<BorrowConnectionScreen> {
),
),
if (snapshot.connectionState != ConnectionState.done)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
LoadingIndicatorBox.sliver
else if (candidates.isEmpty)
SliverFillRemaining(
child: EmptyStateWidget(
@@ -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<ProfileSwitchScreen> createState() => _ProfileSwitchScreenState();
}
class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedSetStateMixin {
bool _allowPop = false;
final FocusNode _firstSelectableFocusNode = FocusNode();
bool _focusRequested = false;
@@ -68,7 +69,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
if (_storage == null) {
unawaited(
StorageService.getInstance().then((s) {
if (mounted) setState(() => _storage = s);
setStateIfMounted(() => _storage = s);
}),
);
}
@@ -351,7 +352,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
}
navigator.pop(true);
} 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/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<SearchScreen>
),
),
if (_isSearching)
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
LoadingIndicatorBox.sliver
else if (!_hasSearched)
SliverFillRemaining(
child: StateMessageWidget(
+2 -4
View File
@@ -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(
+8 -6
View File
@@ -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<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> {
class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
List<LogEntry> _logs = [];
String _deviceInfo = '';
final ScrollController _scrollController = ScrollController();
@@ -64,7 +66,7 @@ class _LogsScreenState extends State<LogsScreen> {
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<LogsScreen> {
}
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';
}
+4 -3
View File
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, MountedSetStateMixin {
late final FocusMemoryTracker _focusTracker;
// Focus tracking keys
@@ -95,13 +96,13 @@ class _SettingsScreenState extends State<SettingsScreen> 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);
});
}
}
+1 -2
View File
@@ -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();
+3 -1
View File
@@ -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<int, TrickplayInfo>? _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<Object?>().firstWhere((v) => v is Map, orElse: () => null);
final first = raw.values.firstWhereOrNull((v) => v is Map);
if (first is! Map) return null;
resolutionMap = first;
}
+1 -2
View File
@@ -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,
+4 -3
View File
@@ -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<String> _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';
}
+2 -1
View File
@@ -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<String> 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));
+8
View File
@@ -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);
@@ -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) {
@@ -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<DiscoveryView> createState() => _DiscoveryViewState();
}
class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin {
class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin, MountedSetStateMixin {
late final _hostAddressController = createTextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isConnecting = false;
@@ -127,7 +128,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
if (!mounted) return;
setState(() => _errorMessage = _parseErrorMessage(e.toString()));
} finally {
if (mounted) setState(() => _isConnecting = false);
setStateIfMounted(() => _isConnecting = false);
}
}
@@ -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<RemoteSessionDialog> {
class _RemoteSessionDialogState extends State<RemoteSessionDialog> with MountedSetStateMixin {
bool _isStarting = false;
String? _errorMessage;
@@ -78,7 +79,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
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;
+4 -3
View File
@@ -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<HubSection> createState() => HubSectionState();
}
class HubSectionState extends State<HubSection> {
class HubSectionState extends State<HubSection> with MountedSetStateMixin {
static const _longPressDuration = Duration(milliseconds: 500);
late FocusNode _hubFocusNode;
@@ -130,7 +131,7 @@ class HubSectionState extends State<HubSection> {
_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<HubSection> {
_scrollToIndex(clamped);
_hubFocusNode.requestFocus();
// ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {});
setStateIfMounted(() {});
_scrollHubIntoView();
}
+5
View File
@@ -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));
+3 -2
View File
@@ -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<SideNavigationRail> createState() => SideNavigationRailState();
}
class SideNavigationRailState extends State<SideNavigationRail> {
class SideNavigationRailState extends State<SideNavigationRail> with MountedSetStateMixin {
bool _librariesExpanded = true;
bool _isHovered = false;
@@ -225,7 +226,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_focusTracker = FocusMemoryTracker(
onFocusChanged: () {
// ignore: no-empty-block - setState triggers rebuild to update focus styling
if (mounted) setState(() {});
setStateIfMounted(() {});
},
debugLabelPrefix: 'nav',
);
+3 -2
View File
@@ -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<void> 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 {