fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a one-item player queue or a two-track picker filled ~75% of a desktop window with empty space. BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus Flexible(child:), and each sheet body shrink-wraps its own scrollable. The scaffold's shrinkWrap flag is gone: its old true branch put the child on an unbounded axis, where an over-tall list overflowed instead of clamping and scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px to 118px for one chapter and the two-column track sheet from 750px to 154px for one audio and one subtitle track, both still clamping at the cap. Add SheetSplitColumns for the three side-by-side sheet layouts. A bare VerticalDivider has no intrinsic height, so it inflated those rows to the cap on its own; the rule now paints from a Positioned.fill that cannot size the Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics. Because sheets are bottom-anchored, a content-driven height moves the sheet's top edge and everything above the change point. Three surfaces opt out for that reason and say so at the call site: SubtitleSearchSheet and its language picker keep filling, since both refilter under an autofocused field; FiltersBottomSheet holds the outgoing page's height through its loading transient; and RatingBottomSheet no longer hides MAL/AniList rows asynchronously, which used to slide live rating controls down two rows several hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet boundary rather than editing a widget with 33 filling call sites. The host gains an AnimatedSize keyed per sheet session so nested pushes ease while a replacing show adopts its own height, a 720px absolute height ceiling on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold so short sheets neither close on a nudge nor become undismissable. Add videoControls.noAudioDevicesAvailable so the audio output page shows a placeholder instead of a bare header while devices load.
This commit is contained in:
@@ -5,6 +5,19 @@ import '../focus/key_event_utils.dart';
|
||||
import 'bottom_sheet_header.dart';
|
||||
|
||||
/// Shared page layout for bottom sheets with a stable header and content area.
|
||||
///
|
||||
/// The content area is a [Flexible], so the page is as tall as [child] wants to
|
||||
/// be and no taller, while still being clamped by the sheet's own maximum
|
||||
/// height. [child] should therefore shrink-wrap in the vertical axis: use a
|
||||
/// `SingleChildScrollView`, or a list with `shrinkWrap: true`. A plain
|
||||
/// scrollable sizes itself to the incoming maximum and reintroduces the empty
|
||||
/// space this layout exists to avoid.
|
||||
///
|
||||
/// A child may deliberately fill instead when a content-driven height would
|
||||
/// move a control the user is operating — sheets are bottom-anchored, so a
|
||||
/// shrinking body drags the top edge and anything above the scroll area with
|
||||
/// it. `SubtitleSearchSheet` and its language picker opt out for that
|
||||
/// reason; document any other.
|
||||
class BottomSheetPageScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
@@ -19,7 +32,6 @@ class BottomSheetPageScaffold extends StatelessWidget {
|
||||
final bool showHeaderBorder;
|
||||
final bool showHeaderDivider;
|
||||
final FocusNode? closeFocusNode;
|
||||
final bool shrinkWrap;
|
||||
|
||||
const BottomSheetPageScaffold({
|
||||
super.key,
|
||||
@@ -36,13 +48,12 @@ class BottomSheetPageScaffold extends StatelessWidget {
|
||||
this.showHeaderBorder = true,
|
||||
this.showHeaderDivider = false,
|
||||
this.closeFocusNode,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget content = Column(
|
||||
mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: title,
|
||||
@@ -58,7 +69,7 @@ class BottomSheetPageScaffold extends StatelessWidget {
|
||||
closeFocusNode: closeFocusNode,
|
||||
),
|
||||
if (showHeaderDivider) Divider(color: Theme.of(context).dividerColor, height: 1),
|
||||
if (shrinkWrap) child else Expanded(child: child),
|
||||
Flexible(child: child),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class _FileInfoBottomSheetState extends State<FileInfoBottomSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final versions = widget.fileInfo.versions;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: t.fileInfo.title,
|
||||
@@ -57,8 +58,9 @@ class _FileInfoBottomSheetState extends State<FileInfoBottomSheet> {
|
||||
// keeps no rule under it.
|
||||
showBorder: false,
|
||||
),
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
if (widget.title.isNotEmpty) _ItemHeadline(title: widget.title, versions: versions),
|
||||
|
||||
@@ -395,7 +395,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: handleReorderKeyEvent,
|
||||
child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys),
|
||||
child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys, shrinkWrap: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -403,6 +403,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded),
|
||||
Flexible(
|
||||
@@ -411,7 +412,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: handleReorderKeyEvent,
|
||||
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
|
||||
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys, shrinkWrap: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -421,12 +422,17 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
/// Build flat library list with a server subtitle when multiple servers are
|
||||
/// connected. The TV dialog passes [_dialogScrollController] so focused rows
|
||||
/// can be scrolled into view; the bottom sheet passes its own controller.
|
||||
Widget _buildFlatLibraryList(ScrollController scrollController, Set<String> hiddenLibraryKeys) {
|
||||
Widget _buildFlatLibraryList(
|
||||
ScrollController scrollController,
|
||||
Set<String> hiddenLibraryKeys, {
|
||||
required bool shrinkWrap,
|
||||
}) {
|
||||
final showServerNames = _hasMultipleServers();
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
return ReorderableListView.builder(
|
||||
scrollController: scrollController,
|
||||
shrinkWrap: shrinkWrap,
|
||||
onReorderItem: _reorderLibraries,
|
||||
itemCount: _tempLibraries.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -102,12 +103,29 @@ class OverlaySheetController {
|
||||
_state._autoFocus(clearSelectSuppression: false);
|
||||
}
|
||||
|
||||
/// Sizing applied when a caller supplies no explicit constraints: capped
|
||||
/// width on desktop, three quarters of the screen height everywhere.
|
||||
/// Absolute height ceiling for resizable desktop windows. Without it a 4K
|
||||
/// window yields a 1620px sheet, which reads as a wall of list rather than a
|
||||
/// sheet.
|
||||
static const _windowedMaxHeight = 720.0;
|
||||
|
||||
/// Sizing applied when a caller supplies no explicit constraints: three
|
||||
/// quarters of the viewport height everywhere, the capped width on wide
|
||||
/// viewports, and the absolute height ceiling on desktop windows only.
|
||||
///
|
||||
/// Both caps require `width > 600`. The height ceiling additionally requires
|
||||
/// a desktop OS and not TV, because it exists for a window the user can
|
||||
/// resize arbitrarily tall: a portrait tablet and a 10-foot UI keep the full
|
||||
/// 75%, and so does a desktop window narrower than 601px, which is
|
||||
/// phone-shaped and where 75% is the norm.
|
||||
static BoxConstraints _defaultSheetConstraints(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isDesktop = size.width > 600;
|
||||
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
|
||||
final isWideViewport = size.width > 600;
|
||||
final isDesktopWindow = isWideViewport && PlatformDetector.isDesktopOS() && !PlatformDetector.isTV();
|
||||
final maxHeight = size.height * 0.75;
|
||||
return BoxConstraints(
|
||||
maxWidth: isWideViewport ? 700 : double.infinity,
|
||||
maxHeight: isDesktopWindow ? math.min(maxHeight, _windowedMaxHeight) : maxHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a sheet using the overlay system if available, otherwise fall back
|
||||
@@ -288,6 +306,15 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
Offset? _lastPointerPosition;
|
||||
double? _sheetHorizontalAnchor;
|
||||
|
||||
/// Bumped on every [_show]. Keys the resize animation so a freshly opened
|
||||
/// sheet adopts its own height immediately instead of animating down from
|
||||
/// the previous sheet's; nested pushes within one sheet still animate.
|
||||
///
|
||||
/// Changing the key also remounts the sheet subtree, so a `show` that
|
||||
/// replaces a live sheet of the same widget type starts with fresh [State]
|
||||
/// rather than reconciling into the outgoing sheet's.
|
||||
int _sheetSession = 0;
|
||||
|
||||
// Drag-to-dismiss state
|
||||
double _dragOffset = 0;
|
||||
bool _isDragging = false;
|
||||
@@ -356,6 +383,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
|
||||
setState(() {
|
||||
_pageStack.add(entry);
|
||||
_sheetSession++;
|
||||
_isOpen = true;
|
||||
_isClosing = false;
|
||||
_barrierDismissible = barrierDismissible;
|
||||
@@ -602,9 +630,22 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
return renderBox?.size.height ?? 300;
|
||||
}
|
||||
|
||||
/// Minimum drag distance that dismisses a sheet, regardless of how short the
|
||||
/// sheet is. Content-sized sheets can be ~150px tall, where a bare 25% of the
|
||||
/// height is barely more than touch slop, so a slow nudge while scrolling or
|
||||
/// reaching would close them. Fast flicks are already handled by the velocity
|
||||
/// check in [_checkDismiss].
|
||||
static const _minDismissDrag = 96.0;
|
||||
|
||||
/// Ceiling on that floor, as a fraction of the sheet. A one-row menu can be
|
||||
/// shorter than [_minDismissDrag], and an unclamped floor would mean the only
|
||||
/// way to dismiss it by distance is to drag it clean off the screen.
|
||||
static const _maxDismissDragFraction = 0.6;
|
||||
|
||||
void _checkDismiss(double velocity) {
|
||||
final sheetHeight = _getSheetHeight();
|
||||
if (_dragOffset > sheetHeight * 0.25 || velocity > 500) {
|
||||
final threshold = math.min(math.max(sheetHeight * 0.25, _minDismissDrag), sheetHeight * _maxDismissDragFraction);
|
||||
if (_dragOffset > threshold || velocity > 500) {
|
||||
_close();
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -734,7 +775,19 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
bottom: !isTop,
|
||||
left: false,
|
||||
right: false,
|
||||
child: ConstrainedBox(constraints: effectiveConstraints, child: sheetContent),
|
||||
// Content is sized by the sheet body, so pushing a nested
|
||||
// page or resolving async content changes the sheet's
|
||||
// height. Ease the box between those heights instead of
|
||||
// snapping. The child is laid out at its final size and
|
||||
// pinned to the anchored edge throughout, so it is revealed
|
||||
// rather than stretched.
|
||||
child: AnimatedSize(
|
||||
key: ValueKey(_sheetSession),
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: isTop ? Alignment.topCenter : Alignment.bottomCenter,
|
||||
child: ConstrainedBox(constraints: effectiveConstraints, child: sheetContent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -56,7 +56,6 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
final Map<String, Timer> _autoSaveTimers = {};
|
||||
final Map<String, _TrackerRatingSource> _trackerSourcesByKey = {};
|
||||
final Set<String> _pendingAutoSaves = {};
|
||||
final Set<TrackerService> _hiddenTrackers = {};
|
||||
final Set<String> _loading = {};
|
||||
final Map<String, _SectionStatus> _statuses = {};
|
||||
TrackerIdResolver? _resolver;
|
||||
@@ -86,17 +85,13 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74);
|
||||
|
||||
// Trakt's account provider is watched by [_trackerSources] via `context`.
|
||||
return Consumer<TrackersProvider>(
|
||||
builder: (context, trackers, _) {
|
||||
final allTrackerSources = _trackerSources(context);
|
||||
final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList();
|
||||
final trackerSources = _trackerSources(context);
|
||||
_updateTrackerSourceMap(trackerSources);
|
||||
_resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected;
|
||||
_queueTrackerScoreLoad(allTrackerSources);
|
||||
_queueTrackerScoreLoad(trackerSources);
|
||||
|
||||
final serverCaps = widget.serverClient?.capabilities;
|
||||
final showServerRow = serverCaps != null && (serverCaps.numericUserRating || serverCaps.userFavorites);
|
||||
@@ -106,47 +101,49 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
];
|
||||
var focusIndex = 0;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 12),
|
||||
children: [
|
||||
if (showServerRow)
|
||||
_buildServerRow(
|
||||
widget.serverClient!,
|
||||
_serverFocusNode,
|
||||
autofocus: focusIndex == 0,
|
||||
onNavigateUp: _navTo(focusNodes, focusIndex - 1),
|
||||
onNavigateDown: _navTo(focusNodes, focusIndex++ + 1),
|
||||
// Hugs its content: a handful of rows in a 720px sheet was mostly empty
|
||||
// space. The row set is therefore fixed from the first frame — see
|
||||
// [_loadTrackerScores], which marks an unratable tracker `notAvailable`
|
||||
// rather than removing its row.
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 12),
|
||||
children: [
|
||||
if (showServerRow)
|
||||
_buildServerRow(
|
||||
widget.serverClient!,
|
||||
_serverFocusNode,
|
||||
autofocus: focusIndex == 0,
|
||||
onNavigateUp: _navTo(focusNodes, focusIndex - 1),
|
||||
onNavigateDown: _navTo(focusNodes, focusIndex++ + 1),
|
||||
),
|
||||
for (final source in trackerSources)
|
||||
_buildTrackerRow(
|
||||
source,
|
||||
_trackerFocusNode(source.service),
|
||||
autofocus: focusIndex == 0,
|
||||
onNavigateUp: _navTo(focusNodes, focusIndex - 1),
|
||||
onNavigateDown: _navTo(focusNodes, focusIndex++ + 1),
|
||||
),
|
||||
if (trackerSources.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
child: Text(
|
||||
t.rateSheet.noConnectedServices,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
for (final source in trackerSources)
|
||||
_buildTrackerRow(
|
||||
source,
|
||||
_trackerFocusNode(source.service),
|
||||
autofocus: focusIndex == 0,
|
||||
onNavigateUp: _navTo(focusNodes, focusIndex - 1),
|
||||
onNavigateDown: _navTo(focusNodes, focusIndex++ + 1),
|
||||
),
|
||||
if (allTrackerSources.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
child: Text(
|
||||
t.rateSheet.noConnectedServices,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -279,6 +276,11 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Every tracker that cannot rate this item keeps its row and shows
|
||||
/// `notAvailable`. Removing a row instead would shorten the sheet several
|
||||
/// hundred ms after it opens, and because sheets are bottom-anchored that
|
||||
/// slides the rows above it — which here are live rating controls — out from
|
||||
/// under the user's finger.
|
||||
Future<void> _loadTrackerScores(List<_TrackerRatingSource> sources) async {
|
||||
setState(() {
|
||||
for (final source in sources) {
|
||||
@@ -294,12 +296,8 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (final source in sources) {
|
||||
if (_hidesWhenUnavailable(source)) {
|
||||
_hideTrackerSource(source);
|
||||
} else {
|
||||
_loading.remove(source.service.name);
|
||||
_statuses[source.service.name] = _SectionStatus(t.rateSheet.notAvailable, isError: true);
|
||||
}
|
||||
_loading.remove(source.service.name);
|
||||
_statuses[source.service.name] = _SectionStatus(t.rateSheet.notAvailable, isError: true);
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -318,12 +316,6 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
} on TrackerRatingUnavailableException catch (e) {
|
||||
appLogger.d('Rating unavailable', error: e);
|
||||
if (!mounted) return;
|
||||
if (_hidesWhenUnavailable(source)) {
|
||||
setState(() {
|
||||
_hideTrackerSource(source);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_statuses[key] = _SectionStatus(t.rateSheet.notAvailable, isError: true);
|
||||
});
|
||||
@@ -344,21 +336,6 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _hidesWhenUnavailable(_TrackerRatingSource source) {
|
||||
return source.service == TrackerService.mal || source.service == TrackerService.anilist;
|
||||
}
|
||||
|
||||
void _hideTrackerSource(_TrackerRatingSource source) {
|
||||
final key = source.service.name;
|
||||
_hiddenTrackers.add(source.service);
|
||||
_loading.remove(key);
|
||||
_statuses.remove(key);
|
||||
_trackerScores.remove(source.service);
|
||||
_autoSaveTimers.remove(key)?.cancel();
|
||||
_pendingAutoSaves.remove(key);
|
||||
_trackerSourcesByKey.remove(key);
|
||||
}
|
||||
|
||||
void _setServerStarUnits(int units) {
|
||||
final clamped = units.clamp(0, 10).toInt();
|
||||
if ((_serverStars * 2).round() == clamped) return;
|
||||
|
||||
@@ -100,15 +100,23 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
final currentChapterIndex = chapterSnapshot.data;
|
||||
Widget content;
|
||||
if (!widget.chaptersLoaded) {
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
content = const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(heightFactor: 1, child: CircularProgressIndicator()),
|
||||
);
|
||||
} else if (widget.chapters.isEmpty) {
|
||||
content = Center(
|
||||
child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)),
|
||||
content = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
heightFactor: 1,
|
||||
child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_initialScroll.maybeScrollTo(currentChapterIndex);
|
||||
|
||||
content = ListView.builder(
|
||||
shrinkWrap: true,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.chapters.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -53,14 +53,19 @@ class _QueueSheetState extends State<QueueSheet> {
|
||||
|
||||
Widget content;
|
||||
if (items.isEmpty) {
|
||||
content = Center(
|
||||
child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)),
|
||||
content = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
heightFactor: 1,
|
||||
child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID);
|
||||
_initialScroll.maybeScrollTo(currentIndex);
|
||||
|
||||
content = ListView.builder(
|
||||
shrinkWrap: true,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Left-aligned label above a sheet selection column.
|
||||
///
|
||||
/// The [Align] has no `heightFactor`, so it only shrink-wraps while it sits on
|
||||
/// an unbounded main axis — i.e. as a non-flex child of a [Column]. Placing it
|
||||
/// under a [Flexible] would make it fill the sheet's whole height cap.
|
||||
class SheetColumnHeader extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
|
||||
@@ -76,11 +76,15 @@ class _SheetSelectionColumnState extends State<SheetSelectionColumn> implements
|
||||
_initialScroll.maybeScrollTo(widget.initialIndex);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (widget.headerLabel != null) SheetColumnHeader(label: widget.headerLabel!),
|
||||
if (_selectionPending) const LinearProgressIndicator(minHeight: 2),
|
||||
Expanded(
|
||||
// Reserve the bar's 2px unconditionally: growing the column mid-tap
|
||||
// would nudge a content-sized sheet, and the host eases that as a twitch.
|
||||
SizedBox(height: 2, child: _selectionPending ? const LinearProgressIndicator(minHeight: 2) : null),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.itemCount,
|
||||
itemBuilder: (context, index) => widget.itemBuilder(context, index, this),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Side-by-side pair of sheet columns separated by a hairline rule.
|
||||
///
|
||||
/// The pair is as tall as its taller column, not as tall as the sheet allows.
|
||||
/// That rules out a plain [VerticalDivider] between the columns: it has no
|
||||
/// intrinsic height, so under loose constraints it expands to the incoming
|
||||
/// maximum and drags the whole row to full height. Instead the row sizes
|
||||
/// itself from the columns alone and the rule is painted over it, stretched to
|
||||
/// the resolved height.
|
||||
class SheetSplitColumns extends StatelessWidget {
|
||||
final Widget start;
|
||||
final Widget end;
|
||||
|
||||
const SheetSplitColumns({super.key, required this.start, required this.end});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Expanded(child: start),
|
||||
const SizedBox(width: 1),
|
||||
Expanded(child: end),
|
||||
],
|
||||
),
|
||||
// Both halves have equal flex, so the reserved 1px gap above is exactly
|
||||
// at the horizontal centre. VerticalDivider has no intrinsic height and
|
||||
// fills whatever it is given — harmless here, because a positioned
|
||||
// child cannot influence the Stack's size.
|
||||
Positioned.fill(
|
||||
child: Center(child: VerticalDivider(width: 1, color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,11 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
title: t.videoControls.searchSubtitles,
|
||||
icon: Symbols.search_rounded,
|
||||
onBack: () => OverlaySheetController.of(context).pop(),
|
||||
// Deliberately fills the sheet's height cap instead of hugging content.
|
||||
// Overlay sheets are bottom-anchored, so a content-driven height would
|
||||
// move the search field on every state transition — spinner, results,
|
||||
// error, empty — while the user is still typing in it. A search surface
|
||||
// needs a stable frame; the results list normally fills it anyway.
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
@@ -440,6 +445,9 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle
|
||||
title: t.videoControls.language,
|
||||
icon: Symbols.language_rounded,
|
||||
onBack: widget.onBack,
|
||||
// Fills the height cap for the same reason as the search body: the filter
|
||||
// field is autofocused and refilters on every keystroke, so a
|
||||
// content-driven height would slide the field the user is typing in.
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import 'sheet_selection_column.dart';
|
||||
import 'sheet_split_columns.dart';
|
||||
import 'subtitle_search_sheet.dart';
|
||||
import '../models/track_controls_state.dart';
|
||||
import '../helpers/track_filter_helper.dart';
|
||||
@@ -108,13 +109,9 @@ class TrackSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
if (showAudio && showSubtitles) {
|
||||
return Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Expanded(child: FocusTraversalGroup(child: audioColumnFor(selection, true))),
|
||||
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(child: FocusTraversalGroup(child: subtitleColumnFor(selection, true))),
|
||||
],
|
||||
return SheetSplitColumns(
|
||||
start: FocusTraversalGroup(child: audioColumnFor(selection, true)),
|
||||
end: FocusTraversalGroup(child: subtitleColumnFor(selection, true)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../../utils/quality_preset_labels.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'sheet_selection_column.dart';
|
||||
import 'sheet_split_columns.dart';
|
||||
|
||||
String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) {
|
||||
return showQuality
|
||||
@@ -73,14 +74,7 @@ class VersionQualityPicker extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (showVersions && showQuality) {
|
||||
return Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Expanded(child: versionColumn),
|
||||
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(child: qualityColumn),
|
||||
],
|
||||
);
|
||||
return SheetSplitColumns(start: versionColumn, end: qualityColumn);
|
||||
} else if (showVersions) {
|
||||
return versionColumn;
|
||||
} else {
|
||||
|
||||
@@ -529,6 +529,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final isDesktop = PlatformDetector.isDesktop(context);
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
// Playback Speed - hidden for live TV and when user cannot control playback
|
||||
if (_state.canControl && !_state.isLive)
|
||||
@@ -756,6 +757,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final mode in modes)
|
||||
FocusableListTile(
|
||||
@@ -796,6 +798,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
@@ -826,6 +829,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
FocusableListTile(
|
||||
leading: AppIcon(Symbols.restart_alt_rounded, fill: 1, color: tokens(context).textMuted),
|
||||
@@ -927,6 +931,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
}
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final d in ungrouped) _buildDeviceTile(d, currentDevice),
|
||||
for (final entry in groups.entries) ...[
|
||||
@@ -948,7 +953,25 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
}
|
||||
|
||||
Widget _buildFlatDeviceList(List<AudioDevice> devices, AudioDevice currentDevice) {
|
||||
// The device list arrives asynchronously, so an empty list is the normal
|
||||
// first frame. Without a placeholder the shrink-wrapped page would render
|
||||
// as a bare header and then jump once devices land.
|
||||
//
|
||||
// A fixed placeholder rather than FiltersBottomSheet's hold-the-outgoing-
|
||||
// height technique: this page is entered from the menu, whose height is
|
||||
// unrelated to a device list, so holding it would be arbitrary. One small
|
||||
// upward move when the devices land beats two.
|
||||
if (devices.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
heightFactor: 1,
|
||||
child: Text(t.videoControls.noAudioDevicesAvailable, style: TextStyle(color: tokens(context).textMuted)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, index) => _buildDeviceTile(devices[index], currentDevice),
|
||||
);
|
||||
@@ -979,6 +1002,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
|
||||
// +1 for the import button at the end
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: presets.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == presets.length) {
|
||||
|
||||
@@ -31,6 +31,7 @@ class SleepTimerActiveStatus extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(
|
||||
t.videoControls.timerActive,
|
||||
|
||||
@@ -10,13 +10,14 @@ import '../../../widgets/app_icon.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import '../sheets/sheet_column_header.dart';
|
||||
import '../sheets/sheet_split_columns.dart';
|
||||
import 'sleep_timer_active_status.dart';
|
||||
|
||||
/// Shared UI for sleep timer selection and active status.
|
||||
///
|
||||
/// Layout mirrors the audio/subtitle [TrackSheet]: two side-by-side columns
|
||||
/// inside a [Row], each in its own [FocusTraversalGroup] so D-pad navigation
|
||||
/// stays inside the column the user is acting on.
|
||||
/// inside a [SheetSplitColumns], each in its own [FocusTraversalGroup] so
|
||||
/// D-pad navigation stays inside the column the user is acting on.
|
||||
///
|
||||
/// * Left column ("Stop at") — event-based stop options. Today this is just
|
||||
/// "End of current video"; the column is intentionally open-ended for
|
||||
@@ -48,31 +49,24 @@ class SleepTimerContent extends StatelessWidget {
|
||||
final showActiveStatus = sleepTimer.isActive && (remainingTime != null || sleepTimer.isEndOfVideoMode);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (showActiveStatus) ...[
|
||||
SleepTimerActiveStatus(sleepTimer: sleepTimer, remainingTime: remainingTime, onCancel: onCancel),
|
||||
Divider(color: Theme.of(context).dividerColor, height: 1),
|
||||
],
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusTraversalGroup(
|
||||
child: _SleepTimerEventColumn(player: player, sleepTimer: sleepTimer),
|
||||
),
|
||||
Flexible(
|
||||
child: SheetSplitColumns(
|
||||
start: FocusTraversalGroup(
|
||||
child: _SleepTimerEventColumn(player: player, sleepTimer: sleepTimer),
|
||||
),
|
||||
end: FocusTraversalGroup(
|
||||
child: _SleepTimerDurationColumn(
|
||||
player: player,
|
||||
sleepTimer: sleepTimer,
|
||||
defaultDuration: defaultDuration,
|
||||
),
|
||||
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(
|
||||
child: FocusTraversalGroup(
|
||||
child: _SleepTimerDurationColumn(
|
||||
player: player,
|
||||
sleepTimer: sleepTimer,
|
||||
defaultDuration: defaultDuration,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -93,10 +87,12 @@ class _SleepTimerEventColumn extends StatelessWidget {
|
||||
final label = t.videoControls.sleepTimerEndOfVideo;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
SheetColumnHeader(label: t.videoControls.sleepTimerStopAtHeader),
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
FocusableListTile(
|
||||
leading: AppIcon(
|
||||
@@ -147,10 +143,12 @@ class _SleepTimerDurationColumn extends StatelessWidget {
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
SheetColumnHeader(label: t.videoControls.sleepTimerDurationHeader),
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: durations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final minutes = durations[index];
|
||||
|
||||
Reference in New Issue
Block a user