deduplicate SystemChrome calls and optimize PiP animation rebuilds

- extract _scheduleSystemUiModeSync to avoid redundant platform calls on
  every build, deferred via addPostFrameCallback with state tracking
- use AnimatedBuilder.child so the video subtree is not rebuilt on every
  animation tick during PiP spring-back
- move orientation reset from build() to initState() in home screen
- reformat landscape LayoutBuilder for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-07 00:13:08 -08:00
parent d28a3279e4
commit ed59896c5f
3 changed files with 133 additions and 122 deletions

View file

@ -58,6 +58,8 @@ class _VideoChatState extends State<VideoChat>
late AnimationController _animationController;
late Animation<double> _springBackAnimation;
bool? _isImmersiveSystemUiEnabled;
late final ChatTabsStore _chatTabsStore = ChatTabsStore(
twitchApi: context.twitchApi,
bttvApi: context.bttvApi,
@ -197,14 +199,38 @@ class _VideoChatState extends State<VideoChat>
});
}
void _scheduleSystemUiModeSync({required bool immersive}) {
if (_isImmersiveSystemUiEnabled == immersive) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isImmersiveSystemUiEnabled == immersive) return;
_isImmersiveSystemUiEnabled = immersive;
if (immersive) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} else {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
}
});
}
/// Wraps a video widget with PiP swipe-down gesture handling.
///
/// Provides visual feedback (translate + scale), haptic feedback,
/// and an instructional overlay during the drag gesture.
Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) {
final wrappedChild = aspectRatio != null
? AspectRatio(aspectRatio: aspectRatio, child: child)
: child;
return AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
child: wrappedChild,
builder: (context, animatedChild) {
final currentDragDistance = _isPipDragging
? _pipDragDistance
: _springBackAnimation.value;
@ -212,11 +238,6 @@ class _VideoChatState extends State<VideoChat>
final scaleFactor =
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
Widget content = child;
if (aspectRatio != null) {
content = AspectRatio(aspectRatio: aspectRatio, child: child);
}
return Transform.translate(
offset: Offset(0, currentDragDistance),
child: Transform.scale(
@ -228,7 +249,7 @@ class _VideoChatState extends State<VideoChat>
onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel,
child: content,
child: animatedChild,
),
if (!_videoStore.isInPipMode)
Positioned.fill(
@ -272,6 +293,11 @@ class _VideoChatState extends State<VideoChat>
Widget build(BuildContext context) {
final settingsStore = _chatTabsStore.settingsStore;
_scheduleSystemUiModeSync(
immersive:
context.isLandscape && !settingsStore.landscapeForceVerticalChat,
);
final player = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
child: Video(key: _videoKey, videoStore: _videoStore),
@ -415,10 +441,6 @@ class _VideoChatState extends State<VideoChat>
builder: (context) {
if (context.isLandscape &&
!settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
final landscapeChat = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
@ -539,124 +561,114 @@ class _VideoChatState extends State<VideoChat>
)
: SafeArea(
bottom: false,
left: settingsStore.landscapeCutout !=
left:
settingsStore.landscapeCutout !=
LandscapeCutoutType.left &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
right: settingsStore.landscapeCutout !=
right:
settingsStore.landscapeCutout !=
LandscapeCutoutType.right &&
settingsStore.landscapeCutout !=
LandscapeCutoutType.both,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth =
constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final chatWidth = _chatStore.expandChat
? 0.5
: settingsStore.chatWidth;
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(
milliseconds: 200,
),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
// Create the landscape chat container with proper styling
final chatContainer = AnimatedContainer(
curve: Curves.ease,
duration: _isDividerDragging
? Duration.zero
: const Duration(milliseconds: 200),
width: availableWidth * chatWidth,
color: settingsStore.fullScreen
? Colors.black.withValues(
alpha: settingsStore
.fullScreenChatOverlayOpacity,
)
: context.scaffoldColor,
child: chat,
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft: settingsStore
.landscapeChatLeftSide,
showHandle:
_videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth =
newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
final draggableDivider = Observer(
builder: (_) => DraggableDivider(
currentWidth: chatWidth,
maxWidth: 0.6,
isResizableOnLeft:
settingsStore.landscapeChatLeftSide,
showHandle: _videoStore.overlayVisible,
onDragStart: () {
setState(() {
_isDividerDragging = true;
});
},
onDrag: (newWidth) {
if (!_chatStore.expandChat) {
settingsStore.chatWidth = newWidth;
}
},
onDragEnd: () {
setState(() {
_isDividerDragging = false;
});
},
),
);
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
? [
chatContainer,
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
]
: [
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
chatContainer,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (availableWidth *
chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (availableWidth *
chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
)
return Stack(
children: [
Row(
children:
settingsStore
.landscapeChatLeftSide
? [
chatContainer,
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
]
: [
Expanded(
child:
_buildPipGestureWrapper(
child: video,
),
),
chatContainer,
],
),
Positioned(
top: 0,
bottom: 0,
left:
settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
right:
!settingsStore
.landscapeChatLeftSide
? (availableWidth * chatWidth) -
12
: null,
child: draggableDivider,
),
],
);
},
),
)
: SafeArea(child: chat),
);
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
return SafeArea(
// Keep chat-only scrolling under the blurred app bar, but
// ensure video does not bleed into the system status bar.

View file

@ -172,16 +172,16 @@ class VideoOverlay extends StatelessWidget {
if (context.isPortrait) {
// Detect physical device tilt to rotate to optimal orientation
final physicalOrientation =
await NativeDeviceOrientationCommunicator()
.orientation(useSensor: true);
await NativeDeviceOrientationCommunicator().orientation(
useSensor: true,
);
// Map native orientation to Flutter's DeviceOrientation
// iOS: native landscapeLeft = notch left, needs swap to Flutter's landscapeRight
// Android: direct mapping works correctly
final needsSwap = Platform.isIOS;
if (physicalOrientation ==
NativeDeviceOrientation.landscapeLeft) {
if (physicalOrientation == NativeDeviceOrientation.landscapeLeft) {
SystemChrome.setPreferredOrientations([
needsSwap
? DeviceOrientation.landscapeRight

View file

@ -52,13 +52,12 @@ class _HomeState extends State<Home> {
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([]);
checkAndShowReleaseNotes();
}
@override
Widget build(BuildContext context) {
SystemChrome.setPreferredOrientations([]);
final theme = Theme.of(context);
return GestureDetector(