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.
182 lines
6.3 KiB
Dart
182 lines
6.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../../../i18n/strings.g.dart';
|
|
import '../../../mpv/mpv.dart';
|
|
import '../../../services/sleep_timer_service.dart';
|
|
import '../../../utils/formatters.dart';
|
|
import '../../../utils/snackbar_helper.dart';
|
|
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 [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
|
|
/// future event triggers (end of next episode, end of chapter, …).
|
|
/// * Right column ("Timer") — fixed-duration options (5/10/15/30/45/60/90/120
|
|
/// minutes, plus an optional default duration injected from settings).
|
|
class SleepTimerContent extends StatelessWidget {
|
|
final Player player;
|
|
final SleepTimerService sleepTimer;
|
|
final int? defaultDuration;
|
|
final VoidCallback? onCancel;
|
|
|
|
const SleepTimerContent({
|
|
super.key,
|
|
required this.player,
|
|
required this.sleepTimer,
|
|
this.defaultDuration,
|
|
this.onCancel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: sleepTimer,
|
|
builder: (context, _) {
|
|
final remainingTime = sleepTimer.remainingTime;
|
|
// Active status renders either with a countdown (duration timer) or
|
|
// without (end-of-video mode). Both share the same widget.
|
|
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),
|
|
],
|
|
Flexible(
|
|
child: SheetSplitColumns(
|
|
start: FocusTraversalGroup(
|
|
child: _SleepTimerEventColumn(player: player, sleepTimer: sleepTimer),
|
|
),
|
|
end: FocusTraversalGroup(
|
|
child: _SleepTimerDurationColumn(
|
|
player: player,
|
|
sleepTimer: sleepTimer,
|
|
defaultDuration: defaultDuration,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SleepTimerEventColumn extends StatelessWidget {
|
|
final Player player;
|
|
final SleepTimerService sleepTimer;
|
|
|
|
const _SleepTimerEventColumn({required this.player, required this.sleepTimer});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final label = t.videoControls.sleepTimerEndOfVideo;
|
|
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
SheetColumnHeader(label: t.videoControls.sleepTimerStopAtHeader),
|
|
Flexible(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
FocusableListTile(
|
|
leading: AppIcon(
|
|
Symbols.hourglass_bottom_rounded,
|
|
fill: 1,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
title: Text(label),
|
|
selected: sleepTimer.isEndOfVideoMode,
|
|
onTap: () {
|
|
sleepTimer.armEndOfVideo(() {
|
|
// Pause playback when the current video ends
|
|
player.pause();
|
|
});
|
|
OverlaySheetController.closeAdaptive(context);
|
|
|
|
showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label));
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SleepTimerDurationColumn extends StatelessWidget {
|
|
final Player player;
|
|
final SleepTimerService sleepTimer;
|
|
final int? defaultDuration;
|
|
|
|
const _SleepTimerDurationColumn({required this.player, required this.sleepTimer, this.defaultDuration});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
|
// Add default duration if provided and not already in list
|
|
if (defaultDuration != null && !durations.contains(defaultDuration)) {
|
|
durations.add(defaultDuration!);
|
|
durations.sort();
|
|
}
|
|
|
|
// Highlight the currently running duration timer (end-of-video mode is
|
|
// handled by the event column and intentionally does not light up here).
|
|
final activeMinutes = sleepTimer.isActive && !sleepTimer.isEndOfVideoMode
|
|
? sleepTimer.originalDuration?.inMinutes
|
|
: null;
|
|
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
SheetColumnHeader(label: t.videoControls.sleepTimerDurationHeader),
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: durations.length,
|
|
itemBuilder: (context, index) {
|
|
final minutes = durations[index];
|
|
final label = formatDurationTextual(
|
|
minutes * 60 * 1000, // Convert minutes to milliseconds
|
|
abbreviated: false, // Use full format for better readability
|
|
);
|
|
|
|
return FocusableListTile(
|
|
leading: AppIcon(Symbols.timer_rounded, fill: 1, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
|
title: Text(label),
|
|
selected: minutes == activeMinutes,
|
|
onTap: () {
|
|
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
|
// Pause playback when timer completes
|
|
player.pause();
|
|
});
|
|
OverlaySheetController.closeAdaptive(context);
|
|
|
|
// Show confirmation snackbar
|
|
showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label));
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|