@@ -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
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user