From e1e9adbb01513a69a67faf5898a1ce4a9587c1a9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 22 May 2026 15:39:04 +0200 Subject: [PATCH] fix(ios): handle status bar scroll taps close #1101 --- .../focusable_detail_screen_mixin.dart | 14 +- lib/screens/hub_detail_screen.dart | 238 ++++++----- lib/screens/media_detail_screen.dart | 397 +++++++++--------- .../playlist/playlist_detail_screen.dart | 14 +- lib/screens/settings/logs_screen.dart | 87 ++-- lib/screens/settings/mpv_config_screen.dart | 60 +-- lib/utils/platform_detector.dart | 6 + lib/widgets/focused_scroll_scaffold.dart | 27 +- .../ios_status_bar_tap_scroll_to_top.dart | 72 ++++ .../focusable_detail_screen_mixin_test.dart | 114 +++++ test/screens/playlist_detail_screen_test.dart | 182 +++++--- 11 files changed, 771 insertions(+), 440 deletions(-) create mode 100644 lib/widgets/ios_status_bar_tap_scroll_to_top.dart create mode 100644 test/screens/focusable_detail_screen_mixin_test.dart diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 2ea94d5e..f21b1a87 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -6,6 +6,8 @@ import '../media/media_item.dart'; import '../media/media_playlist.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../services/settings_service.dart'; +import '../utils/platform_detector.dart'; +import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/settings_builder.dart'; import '../utils/grid_size_calculator.dart'; import '../widgets/focusable_media_card.dart'; @@ -90,11 +92,11 @@ mixin FocusableDetailScreenMixin 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 + /// bound as the primary scroll view. Callers build the slivers themselves /// (typically `[appBar, ...header, ...buildStateSlivers(), grid]`). Widget buildDetailScaffold({required List slivers}) { return PopScope( - canPop: false, + canPop: PlatformDetector.isHandheldIOS(context), onPopInvokedWithResult: (didPop, result) { if (BackKeyCoordinator.consumeIfHandled()) return; if (didPop) return; @@ -103,8 +105,12 @@ mixin FocusableDetailScreenMixin on State, GridFocu Navigator.pop(context); } }, - child: Scaffold( - body: CustomScrollView(controller: scrollController, slivers: slivers), + child: PrimaryScrollController( + controller: scrollController, + child: IosStatusBarTapScrollToTop( + controller: scrollController, + child: Scaffold(body: CustomScrollView(primary: true, slivers: slivers)), + ), ), ); } diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index c1654d0e..b586b9d2 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -12,8 +12,10 @@ import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; import '../utils/app_logger.dart'; import '../utils/grid_size_calculator.dart'; +import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../widgets/focusable_media_card.dart'; +import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/media_grid_delegate.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; @@ -475,7 +477,7 @@ class _HubDetailScreenState extends State @override Widget build(BuildContext context) { return PopScope( - canPop: false, + canPop: PlatformDetector.isHandheldIOS(context), onPopInvokedWithResult: (didPop, _) { if (BackKeyCoordinator.consumeIfHandled()) return; if (didPop) return; @@ -484,127 +486,135 @@ class _HubDetailScreenState extends State Navigator.pop(context); } }, - child: OverlaySheetHost( - child: Scaffold( - key: _overlayChildKey, - body: CustomScrollView( - controller: scrollController, - clipBehavior: Clip.none, - slivers: [ - CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), - if (_errorMessage != null) - SliverErrorState(message: _errorMessage!, onRetry: _loadMoreItems) - else if (_filteredItems.isEmpty && _isLoading) - LoadingIndicatorBox.sliver - else if (_filteredItems.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) - else - SettingsBuilder( - prefs: const [ - SettingsService.viewMode, - SettingsService.episodePosterMode, - SettingsService.libraryDensity, - ], - builder: (context) { - final svc = SettingsService.instanceOrNull!; - final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; - final episodePosterMode = svc.read(SettingsService.episodePosterMode); - final libraryDensity = svc.read(SettingsService.libraryDensity); + child: PrimaryScrollController( + controller: scrollController, + child: IosStatusBarTapScrollToTop( + controller: scrollController, + child: OverlaySheetHost( + child: Scaffold( + key: _overlayChildKey, + body: CustomScrollView( + primary: true, + clipBehavior: Clip.none, + slivers: [ + CustomAppBar(title: Text(widget.hub.title), pinned: true, actions: buildFocusableAppBarActions()), + if (_errorMessage != null) + SliverErrorState(message: _errorMessage!, onRetry: _loadMoreItems) + else if (_filteredItems.isEmpty && _isLoading) + LoadingIndicatorBox.sliver + else if (_filteredItems.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.hubDetail.noItemsFound))) + else + SettingsBuilder( + prefs: const [ + SettingsService.viewMode, + SettingsService.episodePosterMode, + SettingsService.libraryDensity, + ], + builder: (context) { + final svc = SettingsService.instanceOrNull!; + final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list; + final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final libraryDensity = svc.read(SettingsService.libraryDensity); - // Determine hub content type for layout decisions - final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); - final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); + // Determine hub content type for layout decisions + final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = _filteredItems.any( + (item) => !item.usesWideAspectRatio(episodePosterMode), + ); - // Mixed hub = has both episodes AND non-episodes - final isMixedHub = hasEpisodes && hasNonEpisodes; + // Mixed hub = has both episodes AND non-episodes + final isMixedHub = hasEpisodes && hasNonEpisodes; - // Episode-only = all items are episodes with thumbnails - final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; + // Episode-only = all items are episodes with thumbnails + final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) - final useWideLayout = - episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + final useWideLayout = + episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); - if (isListMode) { - return SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverList.builder( - itemCount: _filteredItems.length, - itemBuilder: (context, index) { - final item = _filteredItems[index]; - final focusNode = _focusNodeForIndex(index); + if (isListMode) { + return SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverList.builder( + itemCount: _filteredItems.length, + itemBuilder: (context, index) { + final item = _filteredItems[index]; + final focusNode = _focusNodeForIndex(index); - return FocusableMediaCard( - focusNode: focusNode, - item: item, - disableScale: true, - onRefresh: _handleItemRefresh, - onRemoveFromContinueWatching: widget.isInContinueWatching - ? _handleRemoveFromContinueWatching - : null, - isInContinueWatching: widget.isInContinueWatching, - onNavigateUp: index == 0 ? navigateToAppBar : null, - onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), - mixedHubContext: isMixedHub, - ); - }, - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverLayoutBuilder( - builder: (context, constraints) { - final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( - context, - libraryDensity, - 16, - ); - final columnCount = GridSizeCalculator.getColumnCount( - constraints.crossAxisExtent, - useWideLayout ? maxExtent * 1.8 : maxExtent, - ); - - return SliverGrid( - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: libraryDensity, - usePaddingAware: true, - horizontalPadding: 16, - useWideAspectRatio: useWideLayout, + return FocusableMediaCard( + focusNode: focusNode, + item: item, + disableScale: true, + onRefresh: _handleItemRefresh, + onRemoveFromContinueWatching: widget.isInContinueWatching + ? _handleRemoveFromContinueWatching + : null, + isInContinueWatching: widget.isInContinueWatching, + onNavigateUp: index == 0 ? navigateToAppBar : null, + onBack: handleBackFromContent, + onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + mixedHubContext: isMixedHub, + ); + }, ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = _filteredItems[index]; - final focusNode = _focusNodeForIndex(index); - final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); - final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); - - return FocusableMediaCard( - focusNode: focusNode, - item: item, - onRefresh: _handleItemRefresh, - onRemoveFromContinueWatching: widget.isInContinueWatching - ? _handleRemoveFromContinueWatching - : null, - isInContinueWatching: widget.isInContinueWatching, - onNavigateUp: isFirstRow ? navigateToAppBar : null, - onNavigateLeft: isFirstColumn ? () {} : null, - onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), - mixedHubContext: isMixedHub, - ); - }, childCount: _filteredItems.length), ); - }, - ), - ); - }, - ), - if (_filteredItems.isNotEmpty && (_isLoadingMore || _continuationErrorMessage != null)) - _buildContinuationStatusSliver(), - ], + } + + return SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final maxExtent = GridSizeCalculator.getMaxCrossAxisExtentWithPadding( + context, + libraryDensity, + 16, + ); + final columnCount = GridSizeCalculator.getColumnCount( + constraints.crossAxisExtent, + useWideLayout ? maxExtent * 1.8 : maxExtent, + ); + + return SliverGrid( + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: libraryDensity, + usePaddingAware: true, + horizontalPadding: 16, + useWideAspectRatio: useWideLayout, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = _filteredItems[index]; + final focusNode = _focusNodeForIndex(index); + final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); + final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); + + return FocusableMediaCard( + focusNode: focusNode, + item: item, + onRefresh: _handleItemRefresh, + onRemoveFromContinueWatching: widget.isInContinueWatching + ? _handleRemoveFromContinueWatching + : null, + isInContinueWatching: widget.isInContinueWatching, + onNavigateUp: isFirstRow ? navigateToAppBar : null, + onNavigateLeft: isFirstColumn ? () {} : null, + onBack: handleBackFromContent, + onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + mixedHubContext: isMixedHub, + ); + }, childCount: _filteredItems.length), + ); + }, + ), + ); + }, + ), + if (_filteredItems.isNotEmpty && (_isLoadingMore || _continuationErrorMessage != null)) + _buildContinuationStatusSliver(), + ], + ), + ), ), ), ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 3cb88274..10b4f70f 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -72,6 +72,7 @@ import '../widgets/fitting_title_text.dart'; import 'actor_media_screen.dart'; import '../widgets/focusable_tab_chip.dart'; import '../widgets/hub_section.dart'; +import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_background.dart'; @@ -108,8 +109,8 @@ PageRoute mediaDetailRoute({required MediaItem metadata, bool isOffline = return PageRouteBuilder( opaque: false, - pageBuilder: (_, __, ___) => page, - transitionsBuilder: (_, animation, __, child) { + pageBuilder: (_, _, _) => page, + transitionsBuilder: (_, animation, _, child) { return FadeTransition( opacity: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic), child: child, @@ -399,7 +400,7 @@ class _MediaDetailScreenState extends State } void _patchLoadedDescendantsOf(String parentId, bool isWatched, {required bool clearWatchedProgress}) { - final isDescendant = (MediaItem item) => item.parentChain.contains(parentId); + bool isDescendant(MediaItem item) => item.parentChain.contains(parentId); _patchWatchedInListWhere(_seasons, isDescendant, isWatched, clearWatchedProgress: clearWatchedProgress); _patchWatchedInListWhere(_episodes, isDescendant, isWatched, clearWatchedProgress: clearWatchedProgress); for (final entry in _episodeCache.entries) { @@ -985,7 +986,7 @@ class _MediaDetailScreenState extends State return ListenableBuilder( listenable: _ratingChipFocusNode, builder: (context, _) { - final activate = () => _showRatingDialog(context, metadata); + void activate() => _showRatingDialog(context, metadata); final colorScheme = Theme.of(context).colorScheme; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode; @@ -2803,204 +2804,214 @@ class _MediaDetailScreenState extends State return _buildTvDetailScreen(context, metadata, _handleMediaDetailBackKey); } - final content = OverlaySheetHost( - child: Focus( - onKeyEvent: _handleMediaDetailBackKey, - child: Scaffold( - body: Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - // Hero header with background art - SliverToBoxAdapter(child: _buildHeroHeader(context, metadata, size, headerHeight)), + final content = PrimaryScrollController( + controller: _scrollController, + child: IosStatusBarTapScrollToTop( + controller: _scrollController, + child: OverlaySheetHost( + child: Focus( + onKeyEvent: _handleMediaDetailBackKey, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + primary: true, + slivers: [ + // Hero header with background art + SliverToBoxAdapter(child: _buildHeroHeader(context, metadata, size, headerHeight)), - // Main content - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: isTv ? TvLayoutConstants.horizontalInset : 16, - vertical: isTv ? 8 : 16, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Summary - if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ - Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle), - const SizedBox(height: 12), - Focus( - focusNode: _overviewFocusNode, - onKeyEvent: _handleOverviewKeyEvent, - child: ListenableBuilder( - listenable: _overviewFocusNode, - builder: (context, _) { - final showFocus = - _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: Border.all( - color: showFocus - ? theme.colorScheme.primary.withValues(alpha: 0.5) - : Colors.transparent, - width: 2, - ), - ), - child: () { - final summaryStyle = theme.textTheme.bodyLarge?.copyWith(height: 1.6); - if (isTv) { - return Text(metadata.summary!, style: summaryStyle); - } - return CollapsibleText( - text: metadata.summary!, - maxLines: isMobile ? 6 : 4, - style: summaryStyle, + // Main content + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isTv ? TvLayoutConstants.horizontalInset : 16, + vertical: isTv ? 8 : 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary + if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ + Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle), + const SizedBox(height: 12), + Focus( + focusNode: _overviewFocusNode, + onKeyEvent: _handleOverviewKeyEvent, + child: ListenableBuilder( + listenable: _overviewFocusNode, + builder: (context, _) { + final showFocus = + _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: showFocus + ? theme.colorScheme.primary.withValues(alpha: 0.5) + : Colors.transparent, + width: 2, + ), + ), + child: () { + final summaryStyle = theme.textTheme.bodyLarge?.copyWith(height: 1.6); + if (isTv) { + return Text(metadata.summary!, style: summaryStyle); + } + return CollapsibleText( + text: metadata.summary!, + maxLines: isMobile ? 6 : 4, + style: summaryStyle, + ); + }(), ); - }(), - ); - }, - ), - ), - const SizedBox(height: 24), - ], + }, + ), + ), + const SizedBox(height: 24), + ], - // Seasons / Episodes (for TV shows and seasons) - if (isShow && !_showEpisodesDirectly) ...[ - // Season tabs + inline episodes - if (_isLoadingSeasons) - _sectionLoading - else if (_seasons.isEmpty) - _sectionEmpty(context, t.messages.noSeasonsFound) - else ...[ - Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle), - const SizedBox(height: 12), - _buildSeasonTabs(), - const SizedBox(height: 16), - if (_isLoadingSeasonEpisodes) - _sectionLoading - else if (_episodes.isNotEmpty) - _buildEpisodesList() - else - _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), - ], - const SizedBox(height: 24), - ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ - // Server says flatten — existing behavior unchanged - Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle), - const SizedBox(height: 12), - if (_isLoadingSeasons || _isLoadingEpisodes) - _sectionLoading - else if (_episodes.isNotEmpty) - _buildEpisodesList() - else - _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), - const SizedBox(height: 24), - ], - - // Cast - if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[ - Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle), - const SizedBox(height: 12), - _buildCastSection(metadata), - const SizedBox(height: 24), - ], - - // Trailers & Extras Section - if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ - Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle), - const SizedBox(height: 12), - _buildExtrasSection(), - const SizedBox(height: 24), - ], - - // Related Hubs (Collections, Similar, More From...) - for (int i = 0; i < _relatedHubs.length; i++) ...[ - HubSection( - key: _relatedHubKeys[i], - hub: _relatedHubs[i], - icon: _getRelatedHubIcon(_relatedHubs[i]), - inset: true, - onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), - ), - SizedBox(height: isTv ? 28 : 8), - ], - - // Additional info — wrapped in Focus so DPAD DOWN from the - // last focusable section lands here and scrolls it into view. - if (_hasInfoRows) - Focus( - focusNode: _infoRowsFocusNode, - onKeyEvent: _handleInfoRowsKeyEvent, - child: Column( - key: _infoRowsSectionKey, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (metadata.studio != null) ...[ - _buildInfoRow(t.discover.studio, metadata.studio!), - const SizedBox(height: 12), - ], - if (metadata.contentRating != null) ...[ - _buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), - const SizedBox(height: 12), - ], + // Seasons / Episodes (for TV shows and seasons) + if (isShow && !_showEpisodesDirectly) ...[ + // Season tabs + inline episodes + if (_isLoadingSeasons) + _sectionLoading + else if (_seasons.isEmpty) + _sectionEmpty(context, t.messages.noSeasonsFound) + else ...[ + Text( + key: _seasonsSectionKey, + t.libraries.groupings.episodes, + style: sectionTitleStyle, + ), + const SizedBox(height: 12), + _buildSeasonTabs(), + const SizedBox(height: 16), + if (_isLoadingSeasonEpisodes) + _sectionLoading + else if (_episodes.isNotEmpty) + _buildEpisodesList() + else + _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), ], - ), - ), - ], + const SizedBox(height: 24), + ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ + // Server says flatten — existing behavior unchanged + Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle), + const SizedBox(height: 12), + if (_isLoadingSeasons || _isLoadingEpisodes) + _sectionLoading + else if (_episodes.isNotEmpty) + _buildEpisodesList() + else + _sectionEmpty(context, t.messages.noEpisodesFoundGeneral), + const SizedBox(height: 24), + ], + + // Cast + if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[ + Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle), + const SizedBox(height: 12), + _buildCastSection(metadata), + const SizedBox(height: 24), + ], + + // Trailers & Extras Section + if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ + Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle), + const SizedBox(height: 12), + _buildExtrasSection(), + const SizedBox(height: 24), + ], + + // Related Hubs (Collections, Similar, More From...) + for (int i = 0; i < _relatedHubs.length; i++) ...[ + HubSection( + key: _relatedHubKeys[i], + hub: _relatedHubs[i], + icon: _getRelatedHubIcon(_relatedHubs[i]), + inset: true, + onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), + ), + SizedBox(height: isTv ? 28 : 8), + ], + + // Additional info — wrapped in Focus so DPAD DOWN from the + // last focusable section lands here and scrolls it into view. + if (_hasInfoRows) + Focus( + focusNode: _infoRowsFocusNode, + onKeyEvent: _handleInfoRowsKeyEvent, + child: Column( + key: _infoRowsSectionKey, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (metadata.studio != null) ...[ + _buildInfoRow(t.discover.studio, metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), + const SizedBox(height: 12), + ], + ], + ), + ), + ], + ), + ), + ), + SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom)), + ], + ), + // Sticky top bar with fading background + Positioned( + top: 0, + left: 0, + right: 0, + child: ValueListenableBuilder( + valueListenable: _scrollOffset, + builder: (context, offset, child) => IgnorePointer( + ignoring: offset < 50, + child: AnimatedOpacity( + opacity: (offset / 100).clamp(0.0, 1.0), + duration: const Duration(milliseconds: 150), + child: child!, + ), + ), + child: Container( + height: MediaQuery.paddingOf(context).top + 58, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.scaffoldBackgroundColor.withValues(alpha: 0.8), + theme.scaffoldBackgroundColor.withValues(alpha: 0.5), + theme.scaffoldBackgroundColor.withValues(alpha: 0), + ], + stops: const [0.0, 0.3, 1.0], + ), + ), ), ), ), - SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom)), + // Back button (always visible) + Positioned( + top: 0, + left: 0, + child: DesktopAppBarHelper.buildAdjustedLeading( + AppBarBackButton( + style: BackButtonStyle.circular, + onPressed: () => Navigator.pop(context, _watchStateChanged), + ), + context: context, + )!, + ), ], ), - // Sticky top bar with fading background - Positioned( - top: 0, - left: 0, - right: 0, - child: ValueListenableBuilder( - valueListenable: _scrollOffset, - builder: (context, offset, child) => IgnorePointer( - ignoring: offset < 50, - child: AnimatedOpacity( - opacity: (offset / 100).clamp(0.0, 1.0), - duration: const Duration(milliseconds: 150), - child: child!, - ), - ), - child: Container( - height: MediaQuery.paddingOf(context).top + 58, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - theme.scaffoldBackgroundColor.withValues(alpha: 0.8), - theme.scaffoldBackgroundColor.withValues(alpha: 0.5), - theme.scaffoldBackgroundColor.withValues(alpha: 0), - ], - stops: const [0.0, 0.3, 1.0], - ), - ), - ), - ), - ), - // Back button (always visible) - Positioned( - top: 0, - left: 0, - child: DesktopAppBarHelper.buildAdjustedLeading( - AppBarBackButton( - style: BackButtonStyle.circular, - onPressed: () => Navigator.pop(context, _watchStateChanged), - ), - context: context, - )!, - ), - ], + ), ), ), ), diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 80164e8a..5c689937 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -23,6 +23,7 @@ import '../../utils/platform_detector.dart'; import '../../utils/dialogs.dart'; import '../../utils/download_utils.dart'; import '../../utils/snackbar_helper.dart'; +import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; import '../../mixins/grid_focus_node_mixin.dart'; @@ -689,13 +690,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen with MountedSetStateMixin { } return KeyEventResult.ignored; }, - child: Scaffold( - body: CustomScrollView( + child: PrimaryScrollController( + controller: _scrollController, + child: IosStatusBarTapScrollToTop( controller: _scrollController, - slivers: [ - CustomAppBar( - title: Text(t.screens.logs), - pinned: true, - actions: [ - FocusableActionBar( + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: [ + CustomAppBar( + title: Text(t.screens.logs), + pinned: true, actions: [ - FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs), - FocusableAction( - icon: Symbols.upload_rounded, - tooltip: t.logs.uploadLogs, - onPressed: _logs.isNotEmpty ? _uploadLogs : null, - ), - FocusableAction( - icon: Symbols.content_copy_rounded, - tooltip: t.logs.copyLogs, - onPressed: _logs.isNotEmpty ? _copyAllLogs : null, - ), - FocusableAction( - icon: Symbols.delete_outline_rounded, - tooltip: t.logs.clearLogs, - onPressed: _logs.isNotEmpty ? _clearLogs : null, + FocusableActionBar( + actions: [ + FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs), + FocusableAction( + icon: Symbols.upload_rounded, + tooltip: t.logs.uploadLogs, + onPressed: _logs.isNotEmpty ? _uploadLogs : null, + ), + FocusableAction( + icon: Symbols.content_copy_rounded, + tooltip: t.logs.copyLogs, + onPressed: _logs.isNotEmpty ? _copyAllLogs : null, + ), + FocusableAction( + icon: Symbols.delete_outline_rounded, + tooltip: t.logs.clearLogs, + onPressed: _logs.isNotEmpty ? _clearLogs : null, + ), + ], ), ], ), - ], - ), - if (_logs.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) - else - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: SelectableText.rich( - TextSpan( - style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontSize: 12, height: 1.5), - children: _buildLogSpans(), + if (_logs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) + else + SliverPadding( + padding: const EdgeInsets.all(12), + sliver: SliverToBoxAdapter( + child: SelectableText.rich( + TextSpan( + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + height: 1.5, + ), + children: _buildLogSpans(), + ), + ), ), ), - ), - ), - ], + ], + ), + ), ), ), ); diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index f039bcd5..b0cb4f93 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -9,6 +9,7 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/mpv_config_models.dart'; import '../../utils/dialogs.dart'; +import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../mixins/settings_effect_mixin.dart'; import '../../services/settings_service.dart'; @@ -89,34 +90,39 @@ class _MpvConfigScreenState extends State with SettingsEffectMi @override Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - if (BackKeyCoordinator.consumeIfHandled()) return; - BackKeyUpSuppressor.suppressBackUntilKeyUp(); - if (_textFieldFocusNode.hasFocus && _savePresetFocusNode.canRequestFocus) { - _savePresetFocusNode.requestFocus(); - } else { - Navigator.pop(context); - } - }, - child: FocusedScrollScaffold( - title: Text(t.screens.mpvConfig), - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildConfigEditor(), - const SizedBox(height: 16), - _buildPresetsCard(), - const SizedBox(height: 24), - ]), - ), + return ListenableBuilder( + listenable: _textFieldFocusNode, + builder: (context, _) { + return PopScope( + canPop: PlatformDetector.isHandheldIOS(context) && !_textFieldFocusNode.hasFocus, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (BackKeyCoordinator.consumeIfHandled()) return; + BackKeyUpSuppressor.suppressBackUntilKeyUp(); + if (_textFieldFocusNode.hasFocus && _savePresetFocusNode.canRequestFocus) { + _savePresetFocusNode.requestFocus(); + } else { + Navigator.pop(context); + } + }, + child: FocusedScrollScaffold( + title: Text(t.screens.mpvConfig), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildConfigEditor(), + const SizedBox(height: 16), + _buildPresetsCard(), + const SizedBox(height: 24), + ]), + ), + ), + ], ), - ], - ), + ); + }, ); } diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 44a220ec..2d068908 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -173,6 +173,12 @@ class PlatformDetector { return isMobile(context) && !isTV(); } + /// True for iPhone/iPad-style iOS navigation. Excludes tvOS and forced-TV + /// modes, where route back gestures conflict with D-pad navigation. + static bool isHandheldIOS(BuildContext context) { + return !isTV() && Theme.of(context).platform == TargetPlatform.iOS; + } + /// Detects if running on a desktop platform (Windows, macOS, or Linux) static bool isDesktop(BuildContext context) { return !isMobile(context); diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index a0ce63eb..b5afdf2b 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import 'desktop_app_bar.dart'; +import 'ios_status_bar_tap_scroll_to_top.dart'; /// A scaffold widget that wraps Focus + Scaffold + CustomScrollView /// with consistent keyboard navigation handling and app bar styling. @@ -89,19 +90,21 @@ class _FocusedScrollScaffoldState extends State { }, child: FocusScope( node: _scopeNode, - child: Scaffold( - body: CustomScrollView( - slivers: [ - ExcludeFocus( - child: CustomAppBar( - title: widget.title, - pinned: widget.pinned, - actions: widget.actions, - automaticallyImplyLeading: widget.automaticallyImplyLeading, + child: IosStatusBarTapScrollToTop( + child: Scaffold( + body: CustomScrollView( + slivers: [ + ExcludeFocus( + child: CustomAppBar( + title: widget.title, + pinned: widget.pinned, + actions: widget.actions, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + ), ), - ), - ...widget.slivers, - ], + ...widget.slivers, + ], + ), ), ), ), diff --git a/lib/widgets/ios_status_bar_tap_scroll_to_top.dart b/lib/widgets/ios_status_bar_tap_scroll_to_top.dart new file mode 100644 index 00000000..6142c42b --- /dev/null +++ b/lib/widgets/ios_status_bar_tap_scroll_to_top.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import '../utils/platform_detector.dart'; + +/// Captures iPhone/iPad status-bar taps and scrolls the nearest primary +/// scroll view to the top. +/// +/// Flutter's [Scaffold] handles the native `handleScrollToTop` callback, but +/// some iOS versions also deliver a normal pointer near the top of the Flutter +/// view. This prevents that pointer from activating controls underneath the +/// status bar. +class IosStatusBarTapScrollToTop extends StatefulWidget { + final Widget child; + final ScrollController? controller; + + const IosStatusBarTapScrollToTop({super.key, required this.child, this.controller}); + + @override + State createState() => _IosStatusBarTapScrollToTopState(); +} + +class _IosStatusBarTapScrollToTopState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void handleStatusBarTap() { + _scrollToTop(); + } + + bool get _isCurrentRoute => ModalRoute.of(context)?.isCurrent ?? true; + + void _scrollToTop() { + if (!PlatformDetector.isHandheldIOS(context) || !_isCurrentRoute) return; + final controller = widget.controller ?? PrimaryScrollController.maybeOf(context); + if (controller == null || !controller.hasClients) return; + controller.animateTo(0, duration: const Duration(milliseconds: 1000), curve: Curves.easeOutCirc); + } + + @override + Widget build(BuildContext context) { + final topInset = MediaQuery.paddingOf(context).top; + if (!PlatformDetector.isHandheldIOS(context) || topInset <= 0) return widget.child; + + return Stack( + children: [ + widget.child, + Positioned( + top: 0, + left: 0, + right: 0, + height: topInset, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: _scrollToTop, + child: const SizedBox.expand(), + ), + ), + ], + ); + } +} diff --git a/test/screens/focusable_detail_screen_mixin_test.dart b/test/screens/focusable_detail_screen_mixin_test.dart new file mode 100644 index 00000000..e36539b1 --- /dev/null +++ b/test/screens/focusable_detail_screen_mixin_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/mixins/grid_focus_node_mixin.dart'; +import 'package:plezy/screens/focusable_detail_screen_mixin.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TvDetectionService.debugSetAppleTVOverride(false); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('detail scaffold scrolls to top on iOS top safe-area tap', (tester) async { + var topTargetTaps = 0; + await tester.pumpWidget( + MaterialApp( + theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.iOS), + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25)), + child: SizedBox(width: 390, height: 844, child: _TestDetailScreen(onTopTargetTap: () => topTargetTaps++)), + ), + ), + ); + + await tester.tapAt(const Offset(20, 10)); + await tester.pumpAndSettle(); + expect(topTargetTaps, 0); + + final scrollable = tester.state(find.byType(Scrollable)); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -2500)); + await tester.pumpAndSettle(); + expect(scrollable.position.pixels, greaterThan(0)); + + await tester.tapAt(const Offset(20, 10)); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, 0); + }); + + testWidgets('detail scaffold allows native iOS pop gesture when pushed', (tester) async { + MaterialPageRoute? detailRoute; + + await tester.pumpWidget( + MaterialApp( + theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.iOS), + home: Builder( + builder: (context) => TextButton( + onPressed: () { + detailRoute = MaterialPageRoute(builder: (_) => const _TestDetailScreen()); + Navigator.of(context).push(detailRoute!); + }, + child: const Text('Open detail'), + ), + ), + ), + ); + + await tester.tap(find.text('Open detail')); + await tester.pumpAndSettle(); + + expect(detailRoute, isNotNull); + expect(detailRoute!.popGestureEnabled, isTrue); + }); +} + +class _TestDetailScreen extends StatefulWidget { + final VoidCallback? onTopTargetTap; + + const _TestDetailScreen({this.onTopTargetTap}); + + @override + State<_TestDetailScreen> createState() => _TestDetailScreenState(); +} + +class _TestDetailScreenState extends State<_TestDetailScreen> + with GridFocusNodeMixin<_TestDetailScreen>, FocusableDetailScreenMixin<_TestDetailScreen> { + @override + bool get hasItems => true; + + @override + List getAppBarActions() => const []; + + @override + void dispose() { + disposeFocusResources(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return buildDetailScaffold( + slivers: [ + SliverToBoxAdapter( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTopTargetTap, + child: const SizedBox(height: 80, child: Text('Top target')), + ), + ), + SliverList.builder( + itemCount: 80, + itemBuilder: (context, index) => SizedBox(height: 80, child: Text('Row $index')), + ), + ], + ); + } +} diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 9d4bc4dc..272d0b65 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -37,17 +37,6 @@ void main() { }); testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async { - await SettingsService.getInstance(); - - final db = AppDatabase.forTesting(NativeDatabase.memory()); - PlexApiCache.initialize(db); - JellyfinApiCache.initialize(db); - - final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); - downloadManager.recoveryFuture = Future.value(); - final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); - await downloadProvider.ensureInitialized(); - final items = List.generate( playlistItemsPageSize + 5, (index) => MediaItem( @@ -59,52 +48,19 @@ void main() { serverName: 'Server', ), ); - final client = _PagedPlaylistClient(items); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - - addTearDown(() async { - downloadProvider.dispose(); - downloadManager.dispose(); - multiServerProvider.dispose(); - await db.close(); - }); + final harness = await _createHarness(items); await tester.pumpWidget( - TranslationProvider( - child: MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: downloadProvider), - ], - child: MaterialApp( - theme: monoTheme(dark: true), - home: SizedBox( - width: 1280, - height: 720, - child: PlaylistDetailScreen( - playlist: const MediaPlaylist( - id: 'playlist_1', - backend: MediaBackend.plex, - title: 'Long Playlist', - playlistType: 'video', - serverId: 'server_1', - serverName: 'Server', - ), - ), - ), - ), - ), - ), + harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _playlist))), ); - for (var i = 0; i < 10 && client.requestedStarts.length < 2; i++) { + for (var i = 0; i < 10 && harness.client.requestedStarts.length < 2; i++) { await tester.pump(const Duration(milliseconds: 10)); } await tester.pumpAndSettle(); - expect(client.requestedStarts, [0, playlistItemsPageSize]); - expect(client.requestedSizes, [playlistItemsPageSize, playlistItemsPageSize]); + expect(harness.client.requestedStarts, [0, playlistItemsPageSize]); + expect(harness.client.requestedSizes, [playlistItemsPageSize, playlistItemsPageSize]); expect(tester.takeException(), isNull); await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000)); @@ -114,6 +70,134 @@ void main() { expect(find.textContaining('Unsupported operation'), findsNothing); expect(find.text(t.common.retry), findsNothing); }); + + testWidgets('iOS top safe-area tap scrolls long playlists to top', (tester) async { + final items = _mediaItems(playlistItemsPageSize + 5); + final harness = await _createHarness(items); + + await tester.pumpWidget( + harness.wrap( + const MediaQuery( + data: MediaQueryData(padding: EdgeInsets.only(top: 25)), + child: SizedBox(width: 390, height: 844, child: PlaylistDetailScreen(playlist: _playlist)), + ), + platform: TargetPlatform.iOS, + ), + ); + + for (var i = 0; i < 10 && harness.client.requestedStarts.length < 2; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + await tester.pumpAndSettle(); + + final scrollable = tester.state(find.byType(Scrollable)); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -3000)); + await tester.pumpAndSettle(); + expect(scrollable.position.pixels, greaterThan(0)); + + await tester.tapAt(const Offset(20, 10)); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, 0); + }); + + testWidgets('pushed iOS playlist route allows native pop gesture', (tester) async { + final harness = await _createHarness(_mediaItems(1)); + MaterialPageRoute? playlistRoute; + + await tester.pumpWidget( + harness.wrap( + Builder( + builder: (context) => TextButton( + onPressed: () { + playlistRoute = MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist)); + Navigator.of(context).push(playlistRoute!); + }, + child: const Text('Open playlist'), + ), + ), + platform: TargetPlatform.iOS, + ), + ); + + await tester.tap(find.text('Open playlist')); + await tester.pumpAndSettle(); + + expect(playlistRoute, isNotNull); + expect(playlistRoute!.popGestureEnabled, isTrue); + }); +} + +const _playlist = MediaPlaylist( + id: 'playlist_1', + backend: MediaBackend.plex, + title: 'Long Playlist', + playlistType: 'video', + serverId: 'server_1', + serverName: 'Server', +); + +List _mediaItems(int count) { + return List.generate( + count, + (index) => MediaItem( + id: 'item_$index', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Item $index', + serverId: 'server_1', + serverName: 'Server', + ), + ); +} + +Future<_PlaylistHarness> _createHarness(List items) async { + await SettingsService.getInstance(); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + + final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); + downloadManager.recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + + final client = _PagedPlaylistClient(items); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + + addTearDown(() async { + downloadProvider.dispose(); + downloadManager.dispose(); + multiServerProvider.dispose(); + await db.close(); + }); + + return _PlaylistHarness(client: client, multiServerProvider: multiServerProvider, downloadProvider: downloadProvider); +} + +class _PlaylistHarness { + final _PagedPlaylistClient client; + final MultiServerProvider multiServerProvider; + final DownloadProvider downloadProvider; + + const _PlaylistHarness({required this.client, required this.multiServerProvider, required this.downloadProvider}); + + Widget wrap(Widget child, {TargetPlatform platform = TargetPlatform.android}) { + return TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: downloadProvider), + ], + child: MaterialApp( + theme: monoTheme(dark: true).copyWith(platform: platform), + home: child, + ), + ), + ); + } } class _PagedPlaylistClient implements MediaServerClient {