Files
plezy/lib/widgets/video_controls/widgets/sleep_timer_content.dart
T
edde746 24a041977b 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.
2026-08-08 00:37:17 +02:00

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));
},
);
},
),
),
],
);
}
}