From 5937e5c721058b2e2a010e1650913a1fd49ff28b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:25:50 +0200 Subject: [PATCH] refactor: extract shared mixins and helpers to dedupe --- lib/focus/focusable_tile_mixin.dart | 43 ++++ lib/focus/key_repeat_helper.dart | 25 +++ lib/models/plex_media_info.dart | 16 ++ lib/mpv/player/platform/player_android.dart | 11 +- lib/mpv/player/player_base.dart | 19 ++ lib/mpv/player/player_native.dart | 11 +- lib/screens/actor_media_screen.dart | 29 +-- lib/screens/collection_detail_screen.dart | 39 ++-- .../focusable_detail_screen_mixin.dart | 22 ++ lib/screens/livetv/live_tv_actions_mixin.dart | 77 +++++++ lib/screens/livetv/live_tv_screen.dart | 47 ++--- .../livetv/live_tv_show_schedule_screen.dart | 71 ++----- lib/screens/livetv/tabs/whats_on_tab.dart | 80 ++----- lib/screens/metadata_edit_screen.dart | 52 ++--- lib/screens/profile/pin_entry_dialog.dart | 31 +-- lib/services/ambient_lighting_service.dart | 54 ++--- lib/services/gamepad_service.dart | 38 ++-- lib/services/track_selection_service.dart | 199 +++++++----------- lib/utils/dialogs.dart | 81 +++---- lib/widgets/artwork_picker_dialog.dart | 29 +-- lib/widgets/dialog_action_button.dart | 35 +++ lib/widgets/focusable_list_tile.dart | 124 +++-------- lib/widgets/plex_optimized_image.dart | 61 +----- lib/widgets/tag_edit_dialog.dart | 12 +- lib/widgets/tv_color_picker.dart | 31 +-- lib/widgets/tv_number_spinner.dart | 42 +--- .../video_controls/sheets/chapter_sheet.dart | 17 +- .../video_controls/widgets/content_strip.dart | 17 +- 28 files changed, 536 insertions(+), 777 deletions(-) create mode 100644 lib/focus/focusable_tile_mixin.dart create mode 100644 lib/focus/key_repeat_helper.dart create mode 100644 lib/screens/livetv/live_tv_actions_mixin.dart create mode 100644 lib/widgets/dialog_action_button.dart diff --git a/lib/focus/focusable_tile_mixin.dart b/lib/focus/focusable_tile_mixin.dart new file mode 100644 index 00000000..586ed419 --- /dev/null +++ b/lib/focus/focusable_tile_mixin.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../utils/scroll_utils.dart'; + +/// Manages the internal/external FocusNode lifecycle for list-tile widgets and +/// auto-scrolls the tile into view when it gains focus. +mixin FocusableTileStateMixin on State { + late FocusNode _effectiveFocusNode; + bool _ownsNode = false; + + FocusNode? get widgetFocusNode; + + FocusNode get effectiveFocusNode => _effectiveFocusNode; + + void initFocusNode() { + if (widgetFocusNode != null) { + _effectiveFocusNode = widgetFocusNode!; + _ownsNode = false; + } else { + _effectiveFocusNode = FocusNode(); + _ownsNode = true; + } + _effectiveFocusNode.addListener(_onFocusChange); + } + + void updateFocusNode(FocusNode? oldFocusNode) { + if (oldFocusNode != widgetFocusNode) { + disposeFocusNode(); + initFocusNode(); + } + } + + void disposeFocusNode() { + _effectiveFocusNode.removeListener(_onFocusChange); + if (_ownsNode) _effectiveFocusNode.dispose(); + } + + void _onFocusChange() { + if (_effectiveFocusNode.hasFocus) { + scrollContextToCenter(context); + } + } +} diff --git a/lib/focus/key_repeat_helper.dart b/lib/focus/key_repeat_helper.dart new file mode 100644 index 00000000..a5a2cb45 --- /dev/null +++ b/lib/focus/key_repeat_helper.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// Key-repeat timer for held dpad/keyboard inputs: fires immediately, then +/// every 100 ms after a 400 ms initial delay. Call [stopRepeat] in `dispose`. +mixin KeyRepeatHelper on State { + static const _initialDelay = Duration(milliseconds: 400); + static const _repeatInterval = Duration(milliseconds: 100); + + Timer? _repeatTimer; + + void startRepeat(VoidCallback action) { + action(); + _repeatTimer?.cancel(); + _repeatTimer = Timer(_initialDelay, () { + _repeatTimer = Timer.periodic(_repeatInterval, (_) => action()); + }); + } + + void stopRepeat() { + _repeatTimer?.cancel(); + _repeatTimer = null; + } +} diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index eebfc56b..77da7c47 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -209,6 +209,22 @@ class PlexChapter { Duration get startTime => Duration(milliseconds: startTimeOffset ?? 0); Duration? get endTime => endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null; + + /// Find the chapter index containing [position]. Returns null if none match. + /// A chapter's end defaults to the next chapter's start when [endTimeOffset] + /// is missing; the final chapter without an end extends to infinity. + static int? indexAtPosition(Duration position, List chapters) { + final positionMs = position.inMilliseconds; + for (int i = 0; i < chapters.length; i++) { + final chapter = chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = + chapter.endTimeOffset ?? + (i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); + if (positionMs >= startMs && positionMs < endMs) return i; + } + return null; + } } class PlexMarker { diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 8f9568fd..1c19a148 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -1,7 +1,6 @@ import 'package:flutter/services.dart'; import '../../models.dart'; -import '../../../utils/app_logger.dart'; import '../player_base.dart'; /// Android implementation of [Player] using ExoPlayer. @@ -138,15 +137,7 @@ class PlayerAndroid extends PlayerBase { @override Future seek(Duration position) async { - try { - await invoke('seek', {'positionMs': position.inMilliseconds}); - } on PlatformException catch (e) { - if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') { - appLogger.w('Seek failed (${e.code}), player not ready'); - return; - } - rethrow; - } + await runSeek(() => invoke('seek', {'positionMs': position.inMilliseconds})); } // ============================================ diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 6e4d666e..b87902e9 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -599,6 +599,25 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } } + // ============================================ + // Seek helpers + // ============================================ + + /// Run a backend-specific seek call, swallowing the common "not ready" errors + /// the native channel throws when the engine was torn down mid-seek. + @protected + Future runSeek(Future Function() seekFn) async { + try { + await seekFn(); + } on PlatformException catch (e) { + if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') { + appLogger.w('Seek failed (${e.code}), player not ready'); + return; + } + rethrow; + } + } + // ============================================ // Debug helpers // ============================================ diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 8a89ca4c..9d040bce 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -3,7 +3,6 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; import '../models.dart'; -import '../../utils/app_logger.dart'; import 'player_base.dart'; /// Shared native implementation of [Player] for iOS, macOS, Android (MPV fallback), and Linux. @@ -156,15 +155,7 @@ class PlayerNative extends PlayerBase { @override Future seek(Duration position) async { - try { - await command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']); - } on PlatformException catch (e) { - if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') { - appLogger.w('Seek failed (${e.code}), player not ready'); - return; - } - rethrow; - } + await runSeek(() => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute'])); } // ============================================ diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 3a85e508..924c5eea 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -8,7 +8,6 @@ import '../i18n/strings.g.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart'; -import '../focus/key_event_utils.dart'; import '../focus/focusable_action_bar.dart'; /// Screen to browse all media featuring a specific actor @@ -130,27 +129,13 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen @override Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (BackKeyCoordinator.consumeIfHandled()) return; - if (didPop) return; - final shouldPop = handleBackNavigation(); - if (shouldPop && mounted) { - Navigator.pop(context); - } - }, - child: Scaffold( - body: CustomScrollView( - controller: scrollController, - slivers: [ - CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()), - _buildActorHeader(), - ...buildStateSlivers(), - if (items.isNotEmpty) buildFocusableGrid(items: items, onRefresh: updateItem), - ], - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()), + _buildActorHeader(), + ...buildStateSlivers(), + if (items.isNotEmpty) buildFocusableGrid(items: items, onRefresh: updateItem), + ], ); } } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 524e8032..4795eb92 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -13,7 +13,6 @@ import '../utils/snackbar_helper.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart'; -import '../focus/key_event_utils.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { @@ -182,32 +181,18 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen on State, GridFocu } } + /// Wrap [slivers] in the standard detail-screen scaffold — PopScope that + /// defers to [handleBackNavigation], plus a Scaffold with a CustomScrollView + /// bound to [scrollController]. Callers build the slivers themselves + /// (typically `[appBar, ...header, ...buildStateSlivers(), grid]`). + Widget buildDetailScaffold({required List slivers}) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (BackKeyCoordinator.consumeIfHandled()) return; + if (didPop) return; + final shouldPop = handleBackNavigation(); + if (shouldPop && mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView(controller: scrollController, slivers: slivers), + ), + ); + } + /// Handle back navigation for PopScope. Returns true if should pop. bool handleBackNavigation() { // If BACK was already handled by a key event, don't pop diff --git a/lib/screens/livetv/live_tv_actions_mixin.dart b/lib/screens/livetv/live_tv_actions_mixin.dart new file mode 100644 index 00000000..7c961dea --- /dev/null +++ b/lib/screens/livetv/live_tv_actions_mixin.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/livetv_channel.dart'; +import '../../models/livetv_program.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../utils/live_tv_player_navigation.dart'; +import '../../utils/plex_image_helper.dart'; +import 'program_details_sheet.dart'; + +/// Shared live-TV actions: channel lookup, tuning, and program-details sheet. +/// +/// Implementers expose their channel list via [liveTvChannels] and invoke +/// [findChannel], [tuneChannel], and [showProgramDetails] as needed. +mixin LiveTvActionsMixin on State { + /// Channel list used for lookups and passed into the playback navigator. + List get liveTvChannels; + + /// Look up a channel by identifier or key. Returns null if no match. + LiveTvChannel? findChannel(String? channelIdentifier) { + if (channelIdentifier == null) return null; + return liveTvChannels.where((ch) { + return ch.identifier == channelIdentifier || ch.key == channelIdentifier; + }).firstOrNull; + } + + /// Start live playback for [channel] on its owning server. + Future tuneChannel(LiveTvChannel channel) async { + final multiServer = context.read(); + final serverInfo = + multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? + multiServer.liveTvServers.firstOrNull; + if (serverInfo == null) return; + + final client = multiServer.getClientForServer(serverInfo.serverId); + if (client == null) return; + + await navigateToLiveTv( + context, + client: client, + dvrKey: serverInfo.dvrKey, + channel: channel, + channels: liveTvChannels, + ); + } + + /// Open the program-details bottom sheet. The poster is resolved from + /// [posterThumb] on the server identified by [posterServerId]. + void showProgramDetails({ + required LiveTvProgram program, + required LiveTvChannel? channel, + required String? posterThumb, + required String posterServerId, + }) { + final multiServer = context.read(); + final client = multiServer.getClientForServer(posterServerId); + String? posterUrl; + if (posterThumb != null && client != null) { + posterUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: posterThumb, + maxWidth: 80, + maxHeight: 120, + devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + imageType: ImageType.poster, + ); + } + + showProgramDetailsSheet( + context, + program: program, + channel: channel, + posterUrl: posterUrl, + onTuneChannel: channel != null ? () => tuneChannel(channel) : null, + ); + } +} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 99850764..c0549f97 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -321,6 +321,21 @@ class _LiveTvScreenState extends State // Build // --------------------------------------------------------------------------- + List _buildTabChipItems() { + return [ + for (int i = 0; i < LiveTvTab.values.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + buildTabChip( + _getTabLabel(LiveTvTab.values[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTab, + onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), + ), + ], + ]; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -328,22 +343,7 @@ class _LiveTvScreenState extends State return Scaffold( appBar: AppBar( - title: useSideNav - ? Row( - children: [ - for (int i = 0; i < LiveTvTab.values.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - buildTabChip( - _getTabLabel(LiveTvTab.values[i]), - i, - onSelectWhenActive: _focusCurrentTab, - onNavigateDown: _focusCurrentTab, - onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), - ), - ], - ], - ) - : Text(t.liveTv.title), + title: useSideNav ? Row(children: _buildTabChipItems()) : Text(t.liveTv.title), actions: DesktopAppBarHelper.buildAdjustedActions([ FocusableActionBar( key: _actionBarKey, @@ -407,20 +407,7 @@ class _LiveTvScreenState extends State alignment: Alignment.centerLeft, child: SingleChildScrollView( scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (int i = 0; i < LiveTvTab.values.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - buildTabChip( - _getTabLabel(LiveTvTab.values[i]), - i, - onSelectWhenActive: _focusCurrentTab, - onNavigateDown: _focusCurrentTab, - onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), - ), - ], - ], - ), + child: Row(children: _buildTabChipItems()), ), ), Expanded( diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 707e1c86..25c26024 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -9,12 +9,10 @@ import '../../models/livetv_program.dart'; import '../../providers/multi_server_provider.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; -import '../../utils/live_tv_player_navigation.dart'; -import '../../utils/plex_image_helper.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/overlay_sheet.dart'; -import 'program_details_sheet.dart'; +import 'live_tv_actions_mixin.dart'; /// Shows all upcoming airings of a show, matching the Plex "upcoming episodes" view. class LiveTvShowScheduleScreen extends StatefulWidget { @@ -33,10 +31,14 @@ class LiveTvShowScheduleScreen extends StatefulWidget { State createState() => _LiveTvShowScheduleScreenState(); } -class _LiveTvShowScheduleScreenState extends State { +class _LiveTvShowScheduleScreenState extends State + with LiveTvActionsMixin { List _programs = []; bool _isLoading = true; + @override + List get liveTvChannels => widget.channels; + @override void initState() { super.initState(); @@ -76,56 +78,6 @@ class _LiveTvShowScheduleScreenState extends State { } } - LiveTvChannel? _findChannel(String? channelIdentifier) { - if (channelIdentifier == null) return null; - return widget.channels.where((ch) { - return ch.identifier == channelIdentifier || ch.key == channelIdentifier; - }).firstOrNull; - } - - Future _tuneChannel(LiveTvChannel channel) async { - final multiServer = context.read(); - final serverInfo = - multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? - multiServer.liveTvServers.firstOrNull; - if (serverInfo == null) return; - - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) return; - - await navigateToLiveTv( - context, - client: client, - dvrKey: serverInfo.dvrKey, - channel: channel, - channels: widget.channels, - ); - } - - void _showProgramDetails(LiveTvProgram program, LiveTvChannel? channel) { - final multiServer = context.read(); - final client = multiServer.getClientForServer(widget.serverId); - String? posterUrl; - if (program.thumb != null && client != null) { - posterUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: program.thumb, - maxWidth: 80, - maxHeight: 120, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), - imageType: ImageType.poster, - ); - } - - showProgramDetailsSheet( - context, - program: program, - channel: channel, - posterUrl: posterUrl, - onTuneChannel: channel != null ? () => _tuneChannel(channel) : null, - ); - } - @override Widget build(BuildContext context) { return OverlaySheetHost( @@ -140,12 +92,17 @@ class _LiveTvShowScheduleScreenState extends State { SliverList( delegate: SliverChildBuilderDelegate((context, index) { final program = _programs[index]; - final channel = _findChannel(program.channelIdentifier); + final channel = findChannel(program.channelIdentifier); void onTap() { if (program.isCurrentlyAiring && channel != null) { - _tuneChannel(channel); + tuneChannel(channel); } else { - _showProgramDetails(program, channel); + showProgramDetails( + program: program, + channel: channel, + posterThumb: program.thumb, + posterServerId: widget.serverId, + ); } } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index dac3447e..678bfe39 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -17,8 +17,6 @@ import '../../../providers/settings_provider.dart'; import '../../../utils/grid_size_calculator.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/app_logger.dart'; -import '../../../utils/live_tv_player_navigation.dart'; -import '../../../utils/plex_image_helper.dart'; import '../../../utils/provider_extensions.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focus_builders.dart'; @@ -26,8 +24,8 @@ import '../../../widgets/overlay_sheet.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/plex_optimized_image.dart'; +import '../live_tv_actions_mixin.dart'; import '../live_tv_show_schedule_screen.dart'; -import '../program_details_sheet.dart'; class WhatsOnTab extends StatefulWidget { final List channels; @@ -40,12 +38,15 @@ class WhatsOnTab extends StatefulWidget { State createState() => WhatsOnTabState(); } -class WhatsOnTabState extends State { +class WhatsOnTabState extends State with LiveTvActionsMixin { List _hubs = []; bool _isLoading = true; Timer? _refreshTimer; List> _hubKeys = []; + @override + List get liveTvChannels => widget.channels; + @override void initState() { super.initState(); @@ -135,39 +136,12 @@ class WhatsOnTabState extends State { return false; } - /// Find a channel by its identifier from the channel list. - LiveTvChannel? _findChannel(String? channelIdentifier) { - if (channelIdentifier == null) return null; - return widget.channels.where((ch) { - return ch.identifier == channelIdentifier || ch.key == channelIdentifier; - }).firstOrNull; - } - - Future _tuneChannel(LiveTvChannel channel) async { - final multiServer = context.read(); - final serverInfo = - multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? - multiServer.liveTvServers.firstOrNull; - if (serverInfo == null) return; - - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) return; - - await navigateToLiveTv( - context, - client: client, - dvrKey: serverInfo.dvrKey, - channel: channel, - channels: widget.channels, - ); - } - void _onItemTap(LiveTvHubEntry entry) { - final channel = _findChannel(entry.program.channelIdentifier); + final channel = findChannel(entry.program.channelIdentifier); if (entry.program.isCurrentlyAiring && channel != null) { // Live → play directly - _tuneChannel(channel); + tuneChannel(channel); } else if (entry.metadata.mediaType == PlexMediaType.show) { // Show with upcoming episodes → show full schedule Navigator.of(context).push( @@ -181,36 +155,13 @@ class WhatsOnTabState extends State { ); } else { // Individual program (episode, movie, etc.) → bottom sheet - _showProgramDetails(entry, channel); - } - } - - void _showProgramDetails(LiveTvHubEntry entry, LiveTvChannel? channel) { - final program = entry.program; - final metadata = entry.metadata; - - final multiServer = context.read(); - final client = multiServer.getClientForServer(metadata.serverId ?? ''); - final posterImage = metadata.grandparentThumb ?? metadata.thumb; - String? posterUrl; - if (posterImage != null && client != null) { - posterUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: posterImage, - maxWidth: 80, - maxHeight: 120, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), - imageType: ImageType.poster, + showProgramDetails( + program: entry.program, + channel: channel, + posterThumb: entry.metadata.grandparentThumb ?? entry.metadata.thumb, + posterServerId: entry.metadata.serverId ?? '', ); } - - showProgramDetailsSheet( - context, - program: program, - channel: channel, - posterUrl: posterUrl, - onTuneChannel: channel != null ? () => _tuneChannel(channel) : null, - ); } @override @@ -233,7 +184,12 @@ class WhatsOnTabState extends State { key: _hubKeys[index], hub: _hubs[index], onTap: _onItemTap, - onLongPress: (entry) => _showProgramDetails(entry, _findChannel(entry.program.channelIdentifier)), + onLongPress: (entry) => showProgramDetails( + program: entry.program, + channel: findChannel(entry.program.channelIdentifier), + posterThumb: entry.metadata.grandparentThumb ?? entry.metadata.thumb, + posterServerId: entry.metadata.serverId ?? '', + ), onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), onBack: widget.onBack, ); diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 0d984326..657c2068 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -723,44 +723,20 @@ class _MetadataEditScreenState extends State { (value: 'tvdbAbsolute', label: t.metadataEdit.tvdbAbsolute), ], ), - _buildAdvancedTile( - title: t.metadataEdit.metadataLanguage, - prefKey: 'languageOverride', - options: _metadataLanguageOptions(t.metadataEdit.libraryDefault), - ), - _buildAdvancedTile( - title: t.metadataEdit.useOriginalTitle, - prefKey: 'useOriginalTitle', - options: [ - (value: '-1', label: t.metadataEdit.libraryDefault), - (value: '0', label: t.common.no), - (value: '1', label: t.common.yes), - ], - ), - _buildAdvancedTile( - title: t.metadataEdit.preferredAudioLanguage, - prefKey: 'audioLanguage', - options: _audioSubtitleLanguageOptions(t.metadataEdit.accountDefault), - ), - _buildAdvancedTile( - title: t.metadataEdit.preferredSubtitleLanguage, - prefKey: 'subtitleLanguage', - options: _audioSubtitleLanguageOptions(t.metadataEdit.accountDefault), - ), - _buildAdvancedTile( - title: t.metadataEdit.subtitleMode, - prefKey: 'subtitleMode', - options: [ - (value: '-1', label: t.metadataEdit.accountDefault), - (value: '0', label: t.metadataEdit.manuallySelected), - (value: '1', label: t.metadataEdit.shownWithForeignAudio), - (value: '2', label: t.metadataEdit.alwaysEnabled), - ], - ), + ..._buildMetadataLanguageTiles(), + ..._buildAudioSubtitleTiles(t.metadataEdit.accountDefault), ]; } List _buildMovieAdvancedSettings() { + return _buildMetadataLanguageTiles(); + } + + List _buildSeasonAdvancedSettings() { + return _buildAudioSubtitleTiles(t.metadataEdit.seriesDefault); + } + + List _buildMetadataLanguageTiles() { return [ _buildAdvancedTile( title: t.metadataEdit.metadataLanguage, @@ -779,23 +755,23 @@ class _MetadataEditScreenState extends State { ]; } - List _buildSeasonAdvancedSettings() { + List _buildAudioSubtitleTiles(String defaultLabel) { return [ _buildAdvancedTile( title: t.metadataEdit.preferredAudioLanguage, prefKey: 'audioLanguage', - options: _audioSubtitleLanguageOptions(t.metadataEdit.seriesDefault), + options: _audioSubtitleLanguageOptions(defaultLabel), ), _buildAdvancedTile( title: t.metadataEdit.preferredSubtitleLanguage, prefKey: 'subtitleLanguage', - options: _audioSubtitleLanguageOptions(t.metadataEdit.seriesDefault), + options: _audioSubtitleLanguageOptions(defaultLabel), ), _buildAdvancedTile( title: t.metadataEdit.subtitleMode, prefKey: 'subtitleMode', options: [ - (value: '-1', label: t.metadataEdit.seriesDefault), + (value: '-1', label: defaultLabel), (value: '0', label: t.metadataEdit.manuallySelected), (value: '1', label: t.metadataEdit.shownWithForeignAudio), (value: '2', label: t.metadataEdit.alwaysEnabled), diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 90c06d23..2b74335b 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -8,6 +6,7 @@ import '../../focus/dpad_navigator.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; +import '../../focus/key_repeat_helper.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../utils/platform_detector.dart'; @@ -143,11 +142,10 @@ class _TvPinInput extends StatefulWidget { State<_TvPinInput> createState() => _TvPinInputState(); } -class _TvPinInputState extends State<_TvPinInput> { +class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInput> { final List _digits = [null, null, null, null]; int _activeIndex = 0; bool _isFocused = false; - Timer? _repeatTimer; // Hidden text fields for mobile keyboard input final List _mobileFocusNodes = List.generate(4, (_) => FocusNode()); @@ -173,7 +171,7 @@ class _TvPinInputState extends State<_TvPinInput> { @override void dispose() { - _repeatTimer?.cancel(); + stopRepeat(); _focusNode.dispose(); for (final node in _mobileFocusNodes) { node.dispose(); @@ -222,21 +220,6 @@ class _TvPinInputState extends State<_TvPinInput> { }); } - void _startRepeat(VoidCallback action) { - action(); - _repeatTimer?.cancel(); - _repeatTimer = Timer(const Duration(milliseconds: 400), () { - _repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { - action(); - }); - }); - } - - void _stopRepeat() { - _repeatTimer?.cancel(); - _repeatTimer = null; - } - // Map digit keys (both main keyboard and numpad) static final _digitKeyMap = { LogicalKeyboardKey.digit0: 0, @@ -296,13 +279,13 @@ class _TvPinInputState extends State<_TvPinInput> { // Up arrow → increment digit if (key.isUpKey) { - _startRepeat(_incrementDigit); + startRepeat(_incrementDigit); return KeyEventResult.handled; } // Down arrow → decrement digit if (key.isDownKey) { - _startRepeat(_decrementDigit); + startRepeat(_decrementDigit); return KeyEventResult.handled; } @@ -333,7 +316,7 @@ class _TvPinInputState extends State<_TvPinInput> { if (event is KeyUpEvent) { if (key.isUpKey || key.isDownKey) { - _stopRepeat(); + stopRepeat(); return KeyEventResult.handled; } } @@ -394,7 +377,7 @@ class _TvPinInputState extends State<_TvPinInput> { autofocus: true, onFocusChange: (hasFocus) { setState(() => _isFocused = hasFocus); - if (!hasFocus) _stopRepeat(); + if (!hasFocus) stopRepeat(); }, onKeyEvent: _handleKeyEvent, child: _buildDigitRow(context, showArrows: showArrows), diff --git a/lib/services/ambient_lighting_service.dart b/lib/services/ambient_lighting_service.dart index 38f06c7f..bab4dc74 100644 --- a/lib/services/ambient_lighting_service.dart +++ b/lib/services/ambient_lighting_service.dart @@ -145,22 +145,8 @@ class AmbientLightingService { ('BLUR8A', 'BLUR8B', '6.0', 'Blur2'), ('BLUR8B', 'BLUR8C', '12.0', 'Blur3'), ]; - for (final (input, output, offset, desc) in blur8Steps) { - buf.writeln('//!HOOK MAIN'); - buf.writeln('//!BIND $input'); - buf.writeln('//!SAVE $output'); - buf.writeln('//!WIDTH $input.w'); - buf.writeln('//!HEIGHT $input.h'); - buf.writeln('//!DESC Ambient Lighting $desc'); - buf.writeln('vec4 hook() {'); - buf.writeln(' vec2 ps = ${input}_pt;'); - buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)'); - buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)'); - buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)'); - buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);'); - buf.writeln(' return s * 0.25;'); - buf.writeln('}'); - buf.writeln(); + for (final step in blur8Steps) { + _writeKawasePass(buf, step.$1, step.$2, step.$3, step.$4); } // Pass 6: Downscale the already-blurred 1/8 texture to 1/64. @@ -177,22 +163,8 @@ class AmbientLightingService { // Pass 7-8: Two more Kawase blur passes at 1/64 for maximum diffusion. const blur64Steps = [('TINY', 'GLOW1', '3.0', 'Blur4'), ('GLOW1', 'GLOW', '6.0', 'Blur5')]; - for (final (input, output, offset, desc) in blur64Steps) { - buf.writeln('//!HOOK MAIN'); - buf.writeln('//!BIND $input'); - buf.writeln('//!SAVE $output'); - buf.writeln('//!WIDTH $input.w'); - buf.writeln('//!HEIGHT $input.h'); - buf.writeln('//!DESC Ambient Lighting $desc'); - buf.writeln('vec4 hook() {'); - buf.writeln(' vec2 ps = ${input}_pt;'); - buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)'); - buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)'); - buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)'); - buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);'); - buf.writeln(' return s * 0.25;'); - buf.writeln('}'); - buf.writeln(); + for (final step in blur64Steps) { + _writeKawasePass(buf, step.$1, step.$2, step.$3, step.$4); } // Pass 9: Composite — no //!SAVE so this replaces MAIN. @@ -230,6 +202,24 @@ class AmbientLightingService { return buf.toString(); } + void _writeKawasePass(StringBuffer buf, String input, String output, String offset, String desc) { + buf.writeln('//!HOOK MAIN'); + buf.writeln('//!BIND $input'); + buf.writeln('//!SAVE $output'); + buf.writeln('//!WIDTH $input.w'); + buf.writeln('//!HEIGHT $input.h'); + buf.writeln('//!DESC Ambient Lighting $desc'); + buf.writeln('vec4 hook() {'); + buf.writeln(' vec2 ps = ${input}_pt;'); + buf.writeln(' vec4 s = ${input}_tex(${input}_pos + vec2( $offset, $offset) * ps)'); + buf.writeln(' + ${input}_tex(${input}_pos + vec2( $offset, -$offset) * ps)'); + buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, $offset) * ps)'); + buf.writeln(' + ${input}_tex(${input}_pos + vec2(-$offset, -$offset) * ps);'); + buf.writeln(' return s * 0.25;'); + buf.writeln('}'); + buf.writeln(); + } + /// Write the shader to a temp file and return the path. Future _writeShaderToTemp(String shader) async { final cacheDir = await getTemporaryDirectory(); diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 2ffad2df..1a446507 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -296,35 +296,27 @@ class GamepadService with WindowListener { } void _dispatchKeyDown(LogicalKeyboardKey logicalKey) { - final focusNode = FocusManager.instance.primaryFocus; - if (focusNode == null) return; - - final event = KeyDownEvent( - physicalKey: _getPhysicalKey(logicalKey), - logicalKey: logicalKey, - timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + _dispatchKeyEvent( + KeyDownEvent( + physicalKey: _getPhysicalKey(logicalKey), + logicalKey: logicalKey, + timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + ), ); - - FocusNode? node = focusNode; - while (node != null) { - if (node.onKeyEvent != null) { - if (node.onKeyEvent!(node, event) == KeyEventResult.handled) break; - } - node = node.parent; - } } void _dispatchKeyUp(LogicalKeyboardKey logicalKey) { - final focusNode = FocusManager.instance.primaryFocus; - if (focusNode == null) return; - - final event = KeyUpEvent( - physicalKey: _getPhysicalKey(logicalKey), - logicalKey: logicalKey, - timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + _dispatchKeyEvent( + KeyUpEvent( + physicalKey: _getPhysicalKey(logicalKey), + logicalKey: logicalKey, + timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + ), ); + } - FocusNode? node = focusNode; + void _dispatchKeyEvent(KeyEvent event) { + FocusNode? node = FocusManager.instance.primaryFocus; while (node != null) { if (node.onKeyEvent != null) { if (node.onKeyEvent!(node, event) == KeyEventResult.handled) break; diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 4daedfde..a2db3126 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -16,6 +16,70 @@ import '../utils/language_codes.dart'; // codec, title, etc.) instead of list index, since the two may be ordered // differently. +/// Score how well an MPV subtitle track matches a Plex subtitle track. +/// Language (+10 / +1 exact) and codec (+5) carry the most weight; title, +/// forced flag, and identical ordinal position (only when [ordinalMatches] +/// is true) add smaller nudges. +int _scoreSubtitleMatch(SubtitleTrack mpvTrack, PlexSubtitleTrack plexTrack, {required bool ordinalMatches}) { + int score = 0; + + if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { + score += 10; + if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) { + score += 1; + } + } + + if (_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) { + score += 5; + } + + score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle); + + if (mpvTrack.isForced == plexTrack.forced) { + score += 2; + } + + if (ordinalMatches) { + score += 1; + } + + return score; +} + +/// Score how well an MPV audio track matches a Plex audio track. +/// Language (+10 / +1 exact) and codec (+5) dominate; channel count (+3), +/// title match (+2), and identical ordinal position ([ordinalMatches], +1) +/// act as tiebreakers. +int _scoreAudioMatch(AudioTrack mpvTrack, PlexAudioTrack plexTrack, {required bool ordinalMatches}) { + int score = 0; + + if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { + score += 10; + if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) { + score += 1; + } + } + + if (_audioCodecsMatch(mpvTrack.codec, plexTrack.codec)) { + score += 5; + } + + if (mpvTrack.channels != null && plexTrack.channels != null && mpvTrack.channels == plexTrack.channels) { + score += 3; + } + + if (_titlesMatch(mpvTrack.title, plexTrack.title, plexTrack.displayTitle)) { + score += 2; + } + + if (ordinalMatches) { + score += 1; + } + + return score; +} + /// Find the MPV subtitle track that matches a Plex subtitle track SubtitleTrack? findMpvTrackForPlexSubtitle( PlexSubtitleTrack plexTrack, @@ -50,37 +114,10 @@ SubtitleTrack? findMpvTrackForPlexSubtitle( // Skip external tracks when matching internal Plex tracks if (!plexTrack.isExternal && mpvTrack.isExternal) continue; - int score = 0; + final ordinalMatches = + internalMpvTracks != null && plexOrdinal >= 0 && internalMpvTracks.indexOf(mpvTrack) == plexOrdinal; - // Language match is most important (+10, +1 bonus for exact code match) - if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 10; - if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 1; - } - } - - // Codec match (+5) - if (_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) { - score += 5; - } - - // Title match (+3 for text match, +1 for null/empty) - score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle); - - // Forced flag match (+2) - if (mpvTrack.isForced == plexTrack.forced) { - score += 2; - } - - // Ordinal position tiebreaker (+1): when all properties match identically, - // prefer the track at the same position in both lists. - if (internalMpvTracks != null && plexOrdinal >= 0) { - final mpvOrdinal = internalMpvTracks.indexOf(mpvTrack); - if (mpvOrdinal >= 0 && plexOrdinal == mpvOrdinal) { - score += 1; - } - } + final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); if (score > bestScore) { bestScore = score; @@ -123,36 +160,10 @@ PlexSubtitleTrack? findPlexTrackForMpvSubtitle( // Skip external Plex tracks when matching internal MPV tracks if (!mpvTrack.isExternal && plexTrack.isExternal) continue; - int score = 0; + final ordinalMatches = + internalPlexTracks != null && mpvOrdinal >= 0 && internalPlexTracks.indexOf(plexTrack) == mpvOrdinal; - // Language match is most important (+10, +1 bonus for exact code match) - if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 10; - if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 1; - } - } - - // Codec match (+5) - if (_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) { - score += 5; - } - - // Title match (+3 for text match, +1 for null/empty) - score += _titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle); - - // Forced flag match (+2) - if (mpvTrack.isForced == plexTrack.forced) { - score += 2; - } - - // Ordinal position tiebreaker (+1) - if (internalPlexTracks != null && mpvOrdinal >= 0) { - final plexOrdinal = internalPlexTracks.indexOf(plexTrack); - if (plexOrdinal >= 0 && mpvOrdinal == plexOrdinal) { - score += 1; - } - } + final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); if (score > bestScore) { bestScore = score; @@ -177,40 +188,9 @@ AudioTrack? findMpvTrackForPlexAudio( final plexOrdinal = allPlexTracks?.indexOf(plexTrack) ?? -1; for (final mpvTrack in mpvTracks) { - int score = 0; + final ordinalMatches = plexOrdinal >= 0 && mpvTracks.indexOf(mpvTrack) == plexOrdinal; - // Language match is most important (+10, +1 bonus for exact code match) - if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 10; - if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 1; - } - } - - // Codec match (+5) - if (_audioCodecsMatch(mpvTrack.codec, plexTrack.codec)) { - score += 5; - } - - // Channel count match (+3) - if (mpvTrack.channels != null && plexTrack.channels != null) { - if (mpvTrack.channels == plexTrack.channels) { - score += 3; - } - } - - // Title match (+2) - if (_titlesMatch(mpvTrack.title, plexTrack.title, plexTrack.displayTitle)) { - score += 2; - } - - // Ordinal position tiebreaker (+1) - if (plexOrdinal >= 0) { - final mpvOrdinal = mpvTracks.indexOf(mpvTrack); - if (mpvOrdinal >= 0 && plexOrdinal == mpvOrdinal) { - score += 1; - } - } + final score = _scoreAudioMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); if (score > bestScore) { bestScore = score; @@ -235,40 +215,9 @@ PlexAudioTrack? findPlexTrackForMpvAudio( final mpvOrdinal = allMpvTracks?.indexOf(mpvTrack) ?? -1; for (final plexTrack in plexTracks) { - int score = 0; + final ordinalMatches = mpvOrdinal >= 0 && plexTracks.indexOf(plexTrack) == mpvOrdinal; - // Language match is most important (+10, +1 bonus for exact code match) - if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 10; - if (_languageCodesExactMatch(mpvTrack.language, plexTrack.languageCode)) { - score += 1; - } - } - - // Codec match (+5) - if (_audioCodecsMatch(mpvTrack.codec, plexTrack.codec)) { - score += 5; - } - - // Channel count match (+3) - if (mpvTrack.channels != null && plexTrack.channels != null) { - if (mpvTrack.channels == plexTrack.channels) { - score += 3; - } - } - - // Title match (+2) - if (_titlesMatch(mpvTrack.title, plexTrack.title, plexTrack.displayTitle)) { - score += 2; - } - - // Ordinal position tiebreaker (+1) - if (mpvOrdinal >= 0) { - final plexOrdinal = plexTracks.indexOf(plexTrack); - if (plexOrdinal >= 0 && mpvOrdinal == plexOrdinal) { - score += 1; - } - } + final score = _scoreAudioMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); if (score > bestScore) { bestScore = score; diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 84459ef9..e9b272bf 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -4,6 +4,7 @@ import '../focus/focusable_button.dart'; import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; import '../widgets/app_icon.dart'; +import '../widgets/dialog_action_button.dart'; import '../widgets/focusable_list_tile.dart'; import 'focus_utils.dart'; @@ -198,6 +199,29 @@ Future showMultilineTextInputDialog( ); } +/// Shared lifecycle for the two private text-input dialogs below: a single +/// [TextEditingController] seeded from [initialValue], plus a focus node for +/// the save button. +mixin _TextInputDialogStateMixin on State { + late final TextEditingController _controller; + final _saveFocusNode = FocusNode(); + + String? get initialValue; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: initialValue); + } + + @override + void dispose() { + _controller.dispose(); + _saveFocusNode.dispose(); + super.dispose(); + } +} + class _MultilineTextInputDialog extends StatefulWidget { final String title; final String labelText; @@ -209,22 +233,10 @@ class _MultilineTextInputDialog extends StatefulWidget { State<_MultilineTextInputDialog> createState() => _MultilineTextInputDialogState(); } -class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> { - late final TextEditingController _controller; - final _saveFocusNode = FocusNode(); - +class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> + with _TextInputDialogStateMixin<_MultilineTextInputDialog> { @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.initialValue); - } - - @override - void dispose() { - _controller.dispose(); - _saveFocusNode.dispose(); - super.dispose(); - } + String? get initialValue => widget.initialValue; @override Widget build(BuildContext context) { @@ -241,14 +253,11 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> { ), ), actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - FocusableButton( - focusNode: _saveFocusNode, + DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), + DialogActionButton( onPressed: () => Navigator.pop(context, _controller.text), - child: TextButton(onPressed: () => Navigator.pop(context, _controller.text), child: Text(t.common.save)), + label: t.common.save, + focusNode: _saveFocusNode, ), ], ); @@ -280,22 +289,9 @@ class _TextInputDialog extends StatefulWidget { State<_TextInputDialog> createState() => _TextInputDialogState(); } -class _TextInputDialogState extends State<_TextInputDialog> { - late final TextEditingController _controller; - final _saveFocusNode = FocusNode(); - +class _TextInputDialogState extends State<_TextInputDialog> with _TextInputDialogStateMixin<_TextInputDialog> { @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.initialValue); - } - - @override - void dispose() { - _controller.dispose(); - _saveFocusNode.dispose(); - super.dispose(); - } + String? get initialValue => widget.initialValue; void _submit() { final text = _controller.text; @@ -318,15 +314,8 @@ class _TextInputDialogState extends State<_TextInputDialog> { onSubmitted: (_) => _saveFocusNode.requestFocus(), ), actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - FocusableButton( - focusNode: _saveFocusNode, - onPressed: _submit, - child: TextButton(onPressed: _submit, child: Text(widget.confirmText ?? t.common.save)), - ), + DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), + DialogActionButton(onPressed: _submit, label: widget.confirmText ?? t.common.save, focusNode: _saveFocusNode), ], ); } diff --git a/lib/widgets/artwork_picker_dialog.dart b/lib/widgets/artwork_picker_dialog.dart index 141df725..cf7f51fb 100644 --- a/lib/widgets/artwork_picker_dialog.dart +++ b/lib/widgets/artwork_picker_dialog.dart @@ -61,18 +61,8 @@ class _ArtworkPickerDialogState extends State { if (url == null || _isApplying) return; setState(() => _isApplying = true); - final success = await widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url); - - if (!mounted) return; - setState(() => _isApplying = false); - - if (success) { - showSuccessSnackBar(context, t.metadataEdit.artworkUpdated); - Navigator.pop(context, true); - } else { - showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed); - } + _handleArtworkUpdate(success); } Future _addFromUrl() async { @@ -86,18 +76,8 @@ class _ArtworkPickerDialogState extends State { if (url == null || url.isEmpty || !mounted) return; setState(() => _isApplying = true); - final success = await widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url); - - if (!mounted) return; - setState(() => _isApplying = false); - - if (success) { - showSuccessSnackBar(context, t.metadataEdit.artworkUpdated); - Navigator.pop(context, true); - } else { - showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed); - } + _handleArtworkUpdate(success); } Future _uploadFile() async { @@ -109,12 +89,13 @@ class _ArtworkPickerDialogState extends State { if (bytes == null) return; setState(() => _isApplying = true); - final success = await widget.client.uploadArtwork(widget.ratingKey, widget.element, bytes); + _handleArtworkUpdate(success); + } + void _handleArtworkUpdate(bool success) { if (!mounted) return; setState(() => _isApplying = false); - if (success) { showSuccessSnackBar(context, t.metadataEdit.artworkUpdated); Navigator.pop(context, true); diff --git a/lib/widgets/dialog_action_button.dart b/lib/widgets/dialog_action_button.dart new file mode 100644 index 00000000..7827df10 --- /dev/null +++ b/lib/widgets/dialog_action_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../focus/focusable_button.dart'; + +/// A dialog action button that wraps [FocusableButton] around a [TextButton] +/// (or [FilledButton] when [isPrimary] is true). +/// +/// Use in an [AlertDialog]'s `actions:` list — replaces the 4-line +/// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))` +/// boilerplate with a single call. +class DialogActionButton extends StatelessWidget { + final VoidCallback onPressed; + final String label; + final FocusNode? focusNode; + final bool isPrimary; + + const DialogActionButton({ + super.key, + required this.onPressed, + required this.label, + this.focusNode, + this.isPrimary = false, + }); + + @override + Widget build(BuildContext context) { + return FocusableButton( + focusNode: focusNode, + onPressed: onPressed, + child: isPrimary + ? FilledButton(onPressed: onPressed, child: Text(label)) + : TextButton(onPressed: onPressed, child: Text(label)), + ); + } +} diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 057639a6..a7f517f3 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../focus/dpad_navigator.dart'; -import '../utils/scroll_utils.dart'; +import '../focus/focusable_tile_mixin.dart'; /// A ListTile that accepts a FocusNode for keyboard/controller navigation. /// @@ -83,55 +83,31 @@ class FocusableListTile extends StatefulWidget { State createState() => _FocusableListTileState(); } -class _FocusableListTileState extends State { +class _FocusableListTileState extends State with FocusableTileStateMixin { bool _suppressionConsumed = false; bool _isHoveredOrFocused = false; - late FocusNode _effectiveFocusNode; - bool _ownsNode = false; + + @override + FocusNode? get widgetFocusNode => widget.focusNode; @override void initState() { super.initState(); - _initFocusNode(); + initFocusNode(); } @override void didUpdateWidget(FocusableListTile oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - _disposeFocusNode(); - _initFocusNode(); - } + updateFocusNode(oldWidget.focusNode); } @override void dispose() { - _disposeFocusNode(); + disposeFocusNode(); super.dispose(); } - void _initFocusNode() { - if (widget.focusNode != null) { - _effectiveFocusNode = widget.focusNode!; - _ownsNode = false; - } else { - _effectiveFocusNode = FocusNode(); - _ownsNode = true; - } - _effectiveFocusNode.addListener(_onFocusChange); - } - - void _disposeFocusNode() { - _effectiveFocusNode.removeListener(_onFocusChange); - if (_ownsNode) _effectiveFocusNode.dispose(); - } - - void _onFocusChange() { - if (_effectiveFocusNode.hasFocus) { - scrollContextToCenter(context); - } - } - @override Widget build(BuildContext context) { // When hovered/focused with a custom hoverColor, use onError-style foreground @@ -155,7 +131,7 @@ class _FocusableListTileState extends State { selected: widget.selected, contentPadding: widget.contentPadding, visualDensity: widget.visualDensity, - focusNode: widget.suppressInitialSelect ? null : _effectiveFocusNode, + focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode, autofocus: widget.suppressInitialSelect ? false : widget.autofocus, hoverColor: widget.hoverColor, textColor: textColor, @@ -168,7 +144,7 @@ class _FocusableListTileState extends State { } return Focus( - focusNode: _effectiveFocusNode, + focusNode: effectiveFocusNode, autofocus: widget.autofocus, onKeyEvent: (node, event) { if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { @@ -234,53 +210,29 @@ class FocusableRadioListTile extends StatefulWidget { State> createState() => _FocusableRadioListTileState(); } -class _FocusableRadioListTileState extends State> { - late FocusNode _effectiveFocusNode; - bool _ownsNode = false; +class _FocusableRadioListTileState extends State> + with FocusableTileStateMixin> { + @override + FocusNode? get widgetFocusNode => widget.focusNode; @override void initState() { super.initState(); - _initFocusNode(); + initFocusNode(); } @override void didUpdateWidget(FocusableRadioListTile oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - _disposeFocusNode(); - _initFocusNode(); - } + updateFocusNode(oldWidget.focusNode); } @override void dispose() { - _disposeFocusNode(); + disposeFocusNode(); super.dispose(); } - void _initFocusNode() { - if (widget.focusNode != null) { - _effectiveFocusNode = widget.focusNode!; - _ownsNode = false; - } else { - _effectiveFocusNode = FocusNode(); - _ownsNode = true; - } - _effectiveFocusNode.addListener(_onFocusChange); - } - - void _disposeFocusNode() { - _effectiveFocusNode.removeListener(_onFocusChange); - if (_ownsNode) _effectiveFocusNode.dispose(); - } - - void _onFocusChange() { - if (_effectiveFocusNode.hasFocus) { - scrollContextToCenter(context); - } - } - @override Widget build(BuildContext context) { return RadioListTile( @@ -291,7 +243,7 @@ class _FocusableRadioListTileState extends State> { // groupValue and onChanged provided by RadioGroup ancestor dense: widget.dense, visualDensity: widget.visualDensity, - focusNode: _effectiveFocusNode, + focusNode: effectiveFocusNode, autofocus: widget.autofocus, enabled: widget.enabled, ); @@ -346,53 +298,29 @@ class FocusableSwitchListTile extends StatefulWidget { State createState() => _FocusableSwitchListTileState(); } -class _FocusableSwitchListTileState extends State { - late FocusNode _effectiveFocusNode; - bool _ownsNode = false; +class _FocusableSwitchListTileState extends State + with FocusableTileStateMixin { + @override + FocusNode? get widgetFocusNode => widget.focusNode; @override void initState() { super.initState(); - _initFocusNode(); + initFocusNode(); } @override void didUpdateWidget(FocusableSwitchListTile oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - _disposeFocusNode(); - _initFocusNode(); - } + updateFocusNode(oldWidget.focusNode); } @override void dispose() { - _disposeFocusNode(); + disposeFocusNode(); super.dispose(); } - void _initFocusNode() { - if (widget.focusNode != null) { - _effectiveFocusNode = widget.focusNode!; - _ownsNode = false; - } else { - _effectiveFocusNode = FocusNode(); - _ownsNode = true; - } - _effectiveFocusNode.addListener(_onFocusChange); - } - - void _disposeFocusNode() { - _effectiveFocusNode.removeListener(_onFocusChange); - if (_ownsNode) _effectiveFocusNode.dispose(); - } - - void _onFocusChange() { - if (_effectiveFocusNode.hasFocus) { - scrollContextToCenter(context); - } - } - @override Widget build(BuildContext context) { return SwitchListTile( @@ -403,7 +331,7 @@ class _FocusableSwitchListTileState extends State { onChanged: widget.onChanged, dense: widget.dense, visualDensity: widget.visualDensity, - focusNode: _effectiveFocusNode, + focusNode: effectiveFocusNode, autofocus: widget.autofocus, ); } diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 9d8590c0..48b04c5b 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -99,60 +99,7 @@ class PlexOptimizedImage extends StatelessWidget { }) = PlexOptimizedImage._; /// Named constructor for poster images with default fallback icon. - const factory PlexOptimizedImage.poster({ - Key? key, - PlexClient? client, - required String? imagePath, - double? width, - double? height, - BoxFit fit, - FilterQuality filterQuality, - Widget Function(BuildContext, String)? placeholder, - Widget Function(BuildContext, String, dynamic)? errorWidget, - Duration fadeInDuration, - bool enableTranscoding, - String? cacheKey, - Alignment alignment, - String? localFilePath, - }) = PlexOptimizedImage._poster; - - /// Named constructor for episode thumbnails. - const factory PlexOptimizedImage.thumb({ - Key? key, - PlexClient? client, - required String? imagePath, - double? width, - double? height, - BoxFit fit, - FilterQuality filterQuality, - Widget Function(BuildContext, String)? placeholder, - Widget Function(BuildContext, String, dynamic)? errorWidget, - Duration fadeInDuration, - bool enableTranscoding, - String? cacheKey, - Alignment alignment, - String? localFilePath, - }) = PlexOptimizedImage._thumb; - - /// Named constructor for playlist images. - const factory PlexOptimizedImage.playlist({ - Key? key, - PlexClient? client, - required String? imagePath, - double? width, - double? height, - BoxFit fit, - FilterQuality filterQuality, - Widget Function(BuildContext, String)? placeholder, - Widget Function(BuildContext, String, dynamic)? errorWidget, - Duration fadeInDuration, - bool enableTranscoding, - String? cacheKey, - Alignment alignment, - String? localFilePath, - }) = PlexOptimizedImage._playlist; - - const PlexOptimizedImage._poster({ + const PlexOptimizedImage.poster({ Key? key, PlexClient? client, required String? imagePath, @@ -186,7 +133,8 @@ class PlexOptimizedImage extends StatelessWidget { localFilePath: localFilePath, ); - const PlexOptimizedImage._thumb({ + /// Named constructor for episode thumbnails. + const PlexOptimizedImage.thumb({ Key? key, PlexClient? client, required String? imagePath, @@ -220,7 +168,8 @@ class PlexOptimizedImage extends StatelessWidget { localFilePath: localFilePath, ); - const PlexOptimizedImage._playlist({ + /// Named constructor for playlist images. + const PlexOptimizedImage.playlist({ Key? key, PlexClient? client, required String? imagePath, diff --git a/lib/widgets/tag_edit_dialog.dart b/lib/widgets/tag_edit_dialog.dart index e76bed03..30157427 100644 --- a/lib/widgets/tag_edit_dialog.dart +++ b/lib/widgets/tag_edit_dialog.dart @@ -4,6 +4,7 @@ import '../focus/dpad_navigator.dart'; import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../widgets/app_icon.dart'; +import '../widgets/dialog_action_button.dart'; import '../widgets/focusable_list_tile.dart'; class TagEditDialog extends StatefulWidget { @@ -104,14 +105,11 @@ class _TagEditDialogState extends State { ), ), actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - FocusableButton( - focusNode: _saveFocusNode, + DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), + DialogActionButton( onPressed: () => Navigator.pop(context, _tags), - child: TextButton(onPressed: () => Navigator.pop(context, _tags), child: Text(t.common.save)), + label: t.common.save, + focusNode: _saveFocusNode, ), ], ); diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index bc6ca54a..1da5cca1 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -1,11 +1,10 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; +import '../focus/key_repeat_helper.dart'; import '../theme/mono_tokens.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'app_icon.dart'; @@ -216,9 +215,8 @@ class _ColorChannelRow extends StatefulWidget { State<_ColorChannelRow> createState() => _ColorChannelRowState(); } -class _ColorChannelRowState extends State<_ColorChannelRow> { +class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> { late FocusNode _focusNode; - Timer? _repeatTimer; bool _isFocused = false; @override @@ -229,7 +227,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> { @override void dispose() { - _repeatTimer?.cancel(); + stopRepeat(); _focusNode.dispose(); super.dispose(); } @@ -248,21 +246,6 @@ class _ColorChannelRowState extends State<_ColorChannelRow> { } } - void _startRepeat(VoidCallback action) { - action(); - _repeatTimer?.cancel(); - _repeatTimer = Timer(const Duration(milliseconds: 400), () { - _repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { - action(); - }); - }); - } - - void _stopRepeat() { - _repeatTimer?.cancel(); - _repeatTimer = null; - } - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -277,10 +260,10 @@ class _ColorChannelRowState extends State<_ColorChannelRow> { return KeyEventResult.handled; } if (key.isRightKey) { - _startRepeat(_increment); + startRepeat(_increment); return KeyEventResult.handled; } else if (key.isLeftKey) { - _startRepeat(_decrement); + startRepeat(_decrement); return KeyEventResult.handled; } } else if (event is KeyRepeatEvent) { @@ -292,7 +275,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> { } } else if (event is KeyUpEvent) { if (key.isRightKey || key.isLeftKey) { - _stopRepeat(); + stopRepeat(); return KeyEventResult.handled; } } @@ -313,7 +296,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> { autofocus: widget.autofocus, onFocusChange: (hasFocus) { setState(() => _isFocused = hasFocus); - if (!hasFocus) _stopRepeat(); + if (!hasFocus) stopRepeat(); }, onKeyEvent: _handleKeyEvent, child: AnimatedContainer( diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 6c056108..91982cd3 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +5,7 @@ import '../focus/dpad_navigator.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; +import '../focus/key_repeat_helper.dart'; import 'app_icon.dart'; import '../theme/mono_tokens.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -62,9 +61,8 @@ class TvNumberSpinner extends StatefulWidget { State createState() => _TvNumberSpinnerState(); } -class _TvNumberSpinnerState extends State { +class _TvNumberSpinnerState extends State with KeyRepeatHelper { late FocusNode _focusNode; - Timer? _repeatTimer; bool _isFocused = false; @override @@ -75,7 +73,7 @@ class _TvNumberSpinnerState extends State { @override void dispose() { - _repeatTimer?.cancel(); + stopRepeat(); _focusNode.dispose(); super.dispose(); } @@ -94,24 +92,6 @@ class _TvNumberSpinnerState extends State { } } - void _startRepeat(VoidCallback action) { - // Execute once immediately - action(); - - // Start repeat timer after initial delay - _repeatTimer?.cancel(); - _repeatTimer = Timer(const Duration(milliseconds: 400), () { - _repeatTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { - action(); - }); - }); - } - - void _stopRepeat() { - _repeatTimer?.cancel(); - _repeatTimer = null; - } - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -129,15 +109,15 @@ class _TvNumberSpinnerState extends State { return KeyEventResult.handled; } if (key.isUpKey || key.isRightKey) { - _startRepeat(_increment); + startRepeat(_increment); return KeyEventResult.handled; } else if (key.isDownKey || key.isLeftKey) { - _startRepeat(_decrement); + startRepeat(_decrement); return KeyEventResult.handled; } } else if (event is KeyUpEvent) { if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) { - _stopRepeat(); + stopRepeat(); return KeyEventResult.handled; } } @@ -158,7 +138,7 @@ class _TvNumberSpinnerState extends State { autofocus: widget.autofocus, onFocusChange: (hasFocus) { setState(() => _isFocused = hasFocus); - if (!hasFocus) _stopRepeat(); + if (!hasFocus) stopRepeat(); }, onKeyEvent: _handleKeyEvent, child: AnimatedContainer( @@ -181,8 +161,8 @@ class _TvNumberSpinnerState extends State { _SpinnerButton( icon: Symbols.remove_rounded, onPressed: canDecrement ? _decrement : null, - onLongPressStart: canDecrement ? () => _startRepeat(_decrement) : null, - onLongPressEnd: _stopRepeat, + onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null, + onLongPressEnd: stopRepeat, semanticLabel: 'Decrease', ), const SizedBox(width: 16), @@ -200,8 +180,8 @@ class _TvNumberSpinnerState extends State { _SpinnerButton( icon: Symbols.add_rounded, onPressed: canIncrement ? _increment : null, - onLongPressStart: canIncrement ? () => _startRepeat(_increment) : null, - onLongPressEnd: _stopRepeat, + onLongPressStart: canIncrement ? () => startRepeat(_increment) : null, + onLongPressEnd: stopRepeat, semanticLabel: 'Increase', ), ], diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 8eb52abf..8cb21905 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -72,22 +72,7 @@ class _ChapterSheetState extends State { initialData: widget.player.state.position, builder: (context, positionSnapshot) { final currentPosition = positionSnapshot.data ?? Duration.zero; - final currentPositionMs = currentPosition.inMilliseconds; - - // Find the current chapter based on position - int? currentChapterIndex; - for (int i = 0; i < widget.chapters.length; i++) { - final chapter = widget.chapters[i]; - final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? - (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); - - if (currentPositionMs >= startMs && currentPositionMs < endMs) { - currentChapterIndex = i; - break; - } - } + final currentChapterIndex = PlexChapter.indexAtPosition(currentPosition, widget.chapters); Widget content; if (!widget.chaptersLoaded) { diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index ab04185e..dfd84e95 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -327,26 +327,13 @@ class ContentStripState extends State { initialData: widget.player.state.position, builder: (context, positionSnapshot) { final currentPosition = positionSnapshot.data ?? Duration.zero; - final currentPositionMs = currentPosition.inMilliseconds; - - int? currentChapterIndex; - for (int i = 0; i < widget.chapters.length; i++) { - final chapter = widget.chapters[i]; - final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? - (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); - if (currentPositionMs >= startMs && currentPositionMs < endMs) { - currentChapterIndex = i; - break; - } - } + final currentChapterIndex = PlexChapter.indexAtPosition(currentPosition, widget.chapters); // Auto-scroll to current chapter on first build if (!_hasAutoScrolledChapters && currentChapterIndex != null) { _hasAutoScrolledChapters = true; WidgetsBinding.instance.addPostFrameCallback((_) { - _autoScrollTo(_chapterScrollController, currentChapterIndex!, isTablet: isTablet); + _autoScrollTo(_chapterScrollController, currentChapterIndex, isTablet: isTablet); }); }