diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index c1def1bc..45ca7cfd 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -594,7 +594,6 @@ class _MediaDetailScreenState extends State final isNumeric = mediaClient?.capabilities.numericUserRating ?? true; final hasRating = metadata.userRating != null && metadata.userRating! > 0; final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0; - final activate = isNumeric ? () => _showRatingDialog(metadata, starValue) : () => _toggleLike(metadata); final iconData = isNumeric ? Symbols.star_rounded : Symbols.thumb_up_rounded; final activeIconColor = isNumeric ? Colors.amber : Colors.teal; @@ -606,6 +605,9 @@ class _MediaDetailScreenState extends State return ListenableBuilder( listenable: _ratingChipFocusNode, builder: (context, _) { + final activate = isNumeric + ? () => _showRatingDialog(context, metadata, starValue) + : () => _toggleLike(metadata); final colorScheme = Theme.of(context).colorScheme; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode; @@ -680,9 +682,9 @@ class _MediaDetailScreenState extends State } } - void _showRatingDialog(MediaItem metadata, double currentStarValue) { - showModalBottomSheet( - context: context, + void _showRatingDialog(BuildContext sheetContext, MediaItem metadata, double currentStarValue) { + OverlaySheetController.showAdaptive( + sheetContext, builder: (context) => RatingBottomSheet( currentRating: currentStarValue, onRate: (stars) async { diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index c506de7f..a339f968 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -19,6 +19,7 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/dialog_action_button.dart'; import '../../utils/video_player_navigation.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/overlay_sheet.dart'; import '../models/watch_session.dart'; import '../providers/watch_together_provider.dart'; import '../services/recent_rooms_service.dart'; @@ -402,8 +403,8 @@ class _RecentRoomTile extends StatelessWidget { } void _showActions(BuildContext context) { - showModalBottomSheet( - context: context, + OverlaySheetController.showAdaptive( + context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -412,7 +413,7 @@ class _RecentRoomTile extends StatelessWidget { leading: const Icon(Symbols.edit_rounded), title: Text(t.watchTogether.renameRoom), onTap: () { - Navigator.pop(context); + OverlaySheetController.closeAdaptive(context); onRename(); }, ), @@ -420,7 +421,7 @@ class _RecentRoomTile extends StatelessWidget { leading: Icon(Symbols.delete_rounded, color: Theme.of(context).colorScheme.error), title: Text(t.watchTogether.removeRoom, style: TextStyle(color: Theme.of(context).colorScheme.error)), onTap: () { - Navigator.pop(context); + OverlaySheetController.closeAdaptive(context); onRemove(); }, ), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index c7c5e2ac..aef4323f 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -214,6 +214,8 @@ class _OverlaySheetHostState extends State with SingleTickerPr BoxConstraints? _constraints; Color? _explicitBackgroundColor; Alignment _alignment = Alignment.bottomCenter; + Offset? _lastPointerPosition; + double? _sheetHorizontalAnchor; // Drag-to-dismiss state double _dragOffset = 0; @@ -274,6 +276,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr final completer = Completer(); final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode); + final horizontalAnchor = _resolveSheetHorizontalAnchor(alignment); setState(() { _pageStack.add(entry); @@ -284,6 +287,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr _constraints = constraints; _explicitBackgroundColor = backgroundColor; _alignment = alignment; + _sheetHorizontalAnchor = horizontalAnchor; _dragOffset = 0; _isDragging = false; }); @@ -345,6 +349,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr _isClosing = false; _dragOffset = 0; _isDragging = false; + _sheetHorizontalAnchor = null; }); // Clear stale back-key flags. handleBackKeyAction sets // markClosedViaBackKey() expecting a route pop, but the overlay @@ -355,6 +360,18 @@ class _OverlaySheetHostState extends State with SingleTickerPr }); } + void _rememberPointerPosition(PointerEvent event) { + if (event.kind != PointerDeviceKind.mouse) return; + _lastPointerPosition = event.localPosition; + } + + double? _resolveSheetHorizontalAnchor(Alignment alignment) { + if (!PlatformDetector.isDesktopOS() || PlatformDetector.isTV()) return null; + if (InputModeTracker.isKeyboardMode(context)) return null; + if (alignment.x != 0 || alignment.y <= 0) return null; + return _lastPointerPosition?.dx; + } + void _autoFocus() { if (!InputModeTracker.isKeyboardMode(context)) return; @@ -458,25 +475,30 @@ class _OverlaySheetHostState extends State with SingleTickerPr return _OverlaySheetScope( controller: _controller, - child: Stack( - children: [ - widget.child, - // Barrier + sheet only when open - if (_isOpen) ...[ - Positioned.fill( - child: AnimatedBuilder( - animation: _barrierAnimation, - builder: (context, child) { - return GestureDetector( - onTap: _barrierDismissible ? () => _close() : null, - child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)), - ); - }, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _rememberPointerPosition, + onPointerHover: _rememberPointerPosition, + child: Stack( + children: [ + widget.child, + // Barrier + sheet only when open + if (_isOpen) ...[ + Positioned.fill( + child: AnimatedBuilder( + animation: _barrierAnimation, + builder: (context, child) { + return GestureDetector( + onTap: _barrierDismissible ? () => _close() : null, + child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)), + ); + }, + ), ), - ), - _buildSheet(context), + _buildSheet(context), + ], ], - ], + ), ), ); } @@ -586,8 +608,12 @@ class _OverlaySheetHostState extends State with SingleTickerPr canRequestFocus: false, skipTraversal: true, onKeyEvent: _handleKeyEvent, - child: Align( - alignment: _alignment, + child: CustomSingleChildLayout( + delegate: _OverlaySheetLayoutDelegate( + alignment: _alignment, + horizontalAnchor: _sheetHorizontalAnchor, + edgePadding: isDesktop ? _OverlaySheetLayoutDelegate.desktopEdgePadding : 0, + ), child: AnimatedBuilder( animation: _slideCurve, builder: (context, child) { @@ -654,3 +680,46 @@ class _OverlaySheetHostState extends State with SingleTickerPr return sheet; } } + +class _OverlaySheetLayoutDelegate extends SingleChildLayoutDelegate { + static const desktopEdgePadding = 16.0; + + final Alignment alignment; + final double? horizontalAnchor; + final double edgePadding; + + const _OverlaySheetLayoutDelegate({ + required this.alignment, + required this.horizontalAnchor, + required this.edgePadding, + }); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final size = constraints.biggest; + final maxWidth = size.width > edgePadding * 2 ? size.width - edgePadding * 2 : size.width; + return BoxConstraints.loose(Size(maxWidth, size.height)); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final hasHorizontalPadding = edgePadding > 0 && size.width > childSize.width + edgePadding * 2; + final minLeft = hasHorizontalPadding ? edgePadding : 0.0; + final maxLeft = hasHorizontalPadding ? size.width - childSize.width - edgePadding : minLeft; + final left = horizontalAnchor == null + ? minLeft + (maxLeft - minLeft) * (alignment.x + 1) / 2 + : (horizontalAnchor! - childSize.width / 2).clamp(minLeft, maxLeft).toDouble(); + + final maxTop = size.height > childSize.height ? size.height - childSize.height : 0.0; + final top = maxTop * (alignment.y + 1) / 2; + + return Offset(left, top); + } + + @override + bool shouldRelayout(_OverlaySheetLayoutDelegate oldDelegate) { + return alignment != oldDelegate.alignment || + horizontalAnchor != oldDelegate.horizontalAnchor || + edgePadding != oldDelegate.edgePadding; + } +}