fix(ios): handle status bar scroll taps

close #1101
This commit is contained in:
edde746
2026-05-22 15:40:03 +02:00
parent 636ecc9d3a
commit e1e9adbb01
11 changed files with 771 additions and 440 deletions
+10 -4
View File
@@ -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<T extends StatefulWidget> on State<T>, 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<Widget> 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<T extends StatefulWidget> on State<T>, 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)),
),
),
);
}
+124 -114
View File
@@ -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<HubDetailScreen>
@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<HubDetailScreen>
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(),
],
),
),
),
),
),
+204 -193
View File
@@ -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<bool> mediaDetailRoute({required MediaItem metadata, bool isOffline =
return PageRouteBuilder<bool>(
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<MediaDetailScreen>
}
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<MediaDetailScreen>
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<MediaDetailScreen>
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<double>(
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<double>(
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,
)!,
),
],
),
),
),
),
@@ -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<PlaylistDetai
@override
Widget build(BuildContext context) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final allowsNativeBackGesture = PlatformDetector.isHandheldIOS(context);
// For regular playlists, wrap the scroll view with the Focus widget
// (Focus is a RenderObject widget and cannot directly wrap a sliver)
final needsListFocus = !_isReadOnly && items.isNotEmpty;
Widget scrollView = CustomScrollView(
controller: scrollController,
primary: true,
slivers: [
CustomAppBar(
title: Column(
@@ -759,7 +761,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
return PopScope(
canPop: false,
canPop: allowsNativeBackGesture && _movingIndex == null,
onPopInvokedWithResult: (didPop, result) {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (didPop) return;
@@ -768,7 +770,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
Navigator.pop(context);
}
},
child: Scaffold(body: scrollView),
child: PrimaryScrollController(
controller: scrollController,
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: Scaffold(body: scrollView),
),
),
);
}
+49 -38
View File
@@ -21,6 +21,7 @@ import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@@ -287,51 +288,61 @@ class _LogsScreenState extends State<LogsScreen> 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(),
),
),
),
),
),
),
],
],
),
),
),
),
);
+33 -27
View File
@@ -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<MpvConfigScreen> 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),
]),
),
),
],
),
],
),
);
},
);
}
+6
View File
@@ -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);
+15 -12
View File
@@ -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<FocusedScrollScaffold> {
},
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,
],
),
),
),
),
@@ -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<IosStatusBarTapScrollToTop> createState() => _IosStatusBarTapScrollToTopState();
}
class _IosStatusBarTapScrollToTopState extends State<IosStatusBarTapScrollToTop> 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(),
),
),
],
);
}
}
@@ -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<ScrollableState>(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<void>? detailRoute;
await tester.pumpWidget(
MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.iOS),
home: Builder(
builder: (context) => TextButton(
onPressed: () {
detailRoute = MaterialPageRoute<void>(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<FocusableAction> 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')),
),
],
);
}
}
+133 -49
View File
@@ -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<void>.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<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.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<ScrollableState>(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<void>? playlistRoute;
await tester.pumpWidget(
harness.wrap(
Builder(
builder: (context) => TextButton(
onPressed: () {
playlistRoute = MaterialPageRoute<void>(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<MediaItem> _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<MediaItem> 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<void>.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<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: platform),
home: child,
),
),
);
}
}
class _PagedPlaylistClient implements MediaServerClient {