Three related PiP gaps on Android: `handleAndroidPipChanged` lived on `NativeVideoPlayerInterface`, and `notifyPip` type-checked against it, so the WebView store never learned it was in PiP — `_isInPipMode` stayed false for the whole session and the overlay kept auto-hiding behind the PiP window. Moved to the shared `VideoPlayerInterface` and implemented in both stores. The WebView store's two JS PiP channel bodies collapse into one `_setPipActive` state machine, now shared with the Android callback. `NativeVideoStore` never called `_pip.setIsPlaying()`, so with the `mediaOnlyPause` layout the PiP window's button was stuck on whatever glyph it started with, and `onPipAction` — which toggles blind — could do the opposite of what the button showed. `VideoStore.handleAppResume()` did nothing in video mode, while the native store calls `play()`. Backgrounding can suspend the webview's playback with no way for the page to restart itself, leaving it dead until a manual refresh. Resumes now unless the user was the one who paused. Not fixed: PiP with the WebView player. Twitch's embed refuses to play below 400x300 CSS pixels and a PiP window is far under that, Android resizes the activity before firing onPictureInPictureModeChanged so any correction arrives too late, and the embed latches once tripped. The native player has no such constraint. Refs #563. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
813 lines
32 KiB
Dart
813 lines
32 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
|
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
|
import 'package:frosty/screens/channel/video/native_video.dart';
|
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
|
import 'package:frosty/screens/channel/video/video.dart';
|
|
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
|
import 'package:frosty/screens/channel/video/video_store.dart';
|
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|
import 'package:frosty/theme.dart';
|
|
import 'package:frosty/utils/context_extensions.dart';
|
|
import 'package:frosty/widgets/blurred_container.dart';
|
|
import 'package:frosty/widgets/draggable_divider.dart';
|
|
import 'package:frosty/widgets/frosty_notification.dart';
|
|
import 'package:simple_pip_mode/actions/pip_actions_layout.dart';
|
|
import 'package:simple_pip_mode/pip_widget.dart';
|
|
|
|
/// Creates a widget that shows the video stream (if live) and chat of the given user.
|
|
class VideoChat extends StatefulWidget {
|
|
static const routeName = 'VideoChat';
|
|
|
|
final String userId;
|
|
final String userName;
|
|
final String userLogin;
|
|
|
|
const VideoChat({
|
|
super.key,
|
|
required this.userId,
|
|
required this.userName,
|
|
required this.userLogin,
|
|
});
|
|
|
|
@override
|
|
State<VideoChat> createState() => _VideoChatState();
|
|
}
|
|
|
|
class _VideoChatState extends State<VideoChat>
|
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
|
final _videoKey = GlobalKey();
|
|
final _chatKey = GlobalKey();
|
|
|
|
// PiP drag state — ValueNotifier avoids full widget rebuilds during drag.
|
|
final _pipDragDistance = ValueNotifier<double>(0);
|
|
bool _isPipDragging = false;
|
|
bool _isInPipTriggerZone = false;
|
|
|
|
// Divider drag state for synchronizing animation
|
|
bool _isDividerDragging = false;
|
|
|
|
// Essential constants for good UX balance
|
|
static const double _pipTriggerDistance = 80;
|
|
static const double _pipMaxDragDistance = 150;
|
|
|
|
// Animation controller for smooth spring-back
|
|
late AnimationController _animationController;
|
|
late Animation<double> _springBackAnimation;
|
|
|
|
bool? _isImmersiveSystemUiEnabled;
|
|
|
|
late final ChatTabsStore _chatTabsStore = ChatTabsStore(
|
|
twitchApi: context.twitchApi,
|
|
bttvApi: context.bttvApi,
|
|
ffzApi: context.ffzApi,
|
|
sevenTVApi: context.sevenTVApi,
|
|
authStore: context.authStore,
|
|
settingsStore: context.settingsStore,
|
|
globalAssetsStore: context.globalAssetsStore,
|
|
primaryChannelId: widget.userId,
|
|
primaryChannelLogin: widget.userLogin,
|
|
primaryDisplayName: widget.userName,
|
|
);
|
|
|
|
late final VideoPlayerInterface _videoStore =
|
|
context.settingsStore.useNativePlayer
|
|
? NativeVideoStore(
|
|
userLogin: widget.userLogin,
|
|
userId: widget.userId,
|
|
displayName: widget.userName,
|
|
twitchApi: context.twitchApi,
|
|
twitchGqlApi: context.twitchGqlApi,
|
|
authStore: context.authStore,
|
|
settingsStore: context.settingsStore,
|
|
)
|
|
: VideoStore(
|
|
userLogin: widget.userLogin,
|
|
userId: widget.userId,
|
|
twitchApi: context.twitchApi,
|
|
authStore: context.authStore,
|
|
settingsStore: context.settingsStore,
|
|
);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Initialize animation controller for smooth drag interactions
|
|
_animationController = AnimationController(
|
|
duration: const Duration(milliseconds: 250),
|
|
vsync: this,
|
|
);
|
|
|
|
// Spring-back animation with smooth easing
|
|
_springBackAnimation = Tween<double>(begin: 0, end: 0).animate(
|
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
|
);
|
|
|
|
// Drive the ValueNotifier from the spring-back animation so the
|
|
// AnimatedBuilder rebuilds without a full setState.
|
|
_animationController.addListener(() {
|
|
_pipDragDistance.value = _springBackAnimation.value;
|
|
});
|
|
|
|
// Register as observer for app lifecycle events
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
|
|
if (state == AppLifecycleState.resumed) {
|
|
_videoStore.handleAppResume();
|
|
}
|
|
}
|
|
|
|
void _handlePipDragStart(DragStartDetails details) {
|
|
if (_videoStore.isInPipMode || _videoStore.paused || _videoStore.loading) {
|
|
return;
|
|
}
|
|
|
|
_animationController.stop();
|
|
_pipDragDistance.value = 0;
|
|
_isInPipTriggerZone = false;
|
|
setState(() {
|
|
_isPipDragging = true;
|
|
});
|
|
}
|
|
|
|
void _handlePipDragUpdate(DragUpdateDetails details) {
|
|
if (!_isPipDragging ||
|
|
_videoStore.isInPipMode ||
|
|
_videoStore.paused ||
|
|
_videoStore.loading) {
|
|
return;
|
|
}
|
|
|
|
// Update the ValueNotifier directly — only the ListenableBuilder rebuilds,
|
|
// not the entire channel scaffold.
|
|
_pipDragDistance.value =
|
|
(_pipDragDistance.value + details.delta.dy).clamp(0, _pipMaxDragDistance);
|
|
|
|
final wasInTriggerZone = _isInPipTriggerZone;
|
|
_isInPipTriggerZone = _pipDragDistance.value >= _pipTriggerDistance;
|
|
|
|
if (!wasInTriggerZone && _isInPipTriggerZone) {
|
|
HapticFeedback.mediumImpact();
|
|
} else if (wasInTriggerZone && !_isInPipTriggerZone) {
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
}
|
|
|
|
void _handlePipDragEnd(DragEndDetails details) {
|
|
if (!_isPipDragging ||
|
|
_videoStore.isInPipMode ||
|
|
_videoStore.paused ||
|
|
_videoStore.loading) {
|
|
return;
|
|
}
|
|
|
|
final velocity = details.velocity.pixelsPerSecond.dy;
|
|
final shouldTriggerPip =
|
|
_pipDragDistance.value >= _pipTriggerDistance || velocity > 600;
|
|
|
|
if (shouldTriggerPip) {
|
|
HapticFeedback.mediumImpact();
|
|
_videoStore.requestPictureInPicture();
|
|
_resetDragState();
|
|
} else {
|
|
_animateSpringBack();
|
|
}
|
|
}
|
|
|
|
void _handlePipDragCancel() {
|
|
if (!_isPipDragging) return;
|
|
_animateSpringBack();
|
|
}
|
|
|
|
void _resetDragState() {
|
|
_pipDragDistance.value = 0;
|
|
_isInPipTriggerZone = false;
|
|
setState(() {
|
|
_isPipDragging = false;
|
|
});
|
|
}
|
|
|
|
void _animateSpringBack() {
|
|
_springBackAnimation =
|
|
Tween<double>(begin: _pipDragDistance.value, end: 0).animate(
|
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
|
);
|
|
|
|
_animationController.reset();
|
|
_animationController.forward().then((_) {
|
|
_resetDragState();
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
// Listen to the ValueNotifier (drag) and AnimationController (spring-back)
|
|
// so only this subtree rebuilds during the gesture — not the full scaffold.
|
|
return ListenableBuilder(
|
|
listenable: _pipDragDistance,
|
|
child: wrappedChild,
|
|
builder: (context, animatedChild) {
|
|
final currentDragDistance = _pipDragDistance.value;
|
|
|
|
final scaleFactor =
|
|
1.0 - (currentDragDistance / _pipMaxDragDistance * 0.1);
|
|
|
|
return Transform.translate(
|
|
offset: Offset(0, currentDragDistance),
|
|
child: Transform.scale(
|
|
scale: scaleFactor.clamp(0.9, 1.0),
|
|
child: Stack(
|
|
children: [
|
|
GestureDetector(
|
|
onPanStart: _handlePipDragStart,
|
|
onPanUpdate: _handlePipDragUpdate,
|
|
onPanEnd: _handlePipDragEnd,
|
|
onPanCancel: _handlePipDragCancel,
|
|
child: animatedChild,
|
|
),
|
|
if (!_videoStore.isInPipMode)
|
|
Positioned.fill(
|
|
child: IgnorePointer(
|
|
ignoring:
|
|
!(_isPipDragging && currentDragDistance > 0),
|
|
child: AnimatedOpacity(
|
|
opacity:
|
|
(_isPipDragging && currentDragDistance > 0)
|
|
? 1.0
|
|
: 0.0,
|
|
duration: const Duration(milliseconds: 150),
|
|
curve: Curves.easeOut,
|
|
child: Container(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
child: const Center(
|
|
child: Text(
|
|
'Swipe down to enter picture-in-picture',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Convenience getter for the currently active chat store.
|
|
ChatStore get _chatStore => _chatTabsStore.activeChatStore;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settingsStore = _chatTabsStore.settingsStore;
|
|
|
|
final player = GestureDetector(
|
|
onLongPress: _videoStore.handleToggleOverlay,
|
|
child: _videoStore is NativeVideoStore
|
|
? NativeVideo(
|
|
// GlobalKey so the native platform view is *reparented* (not
|
|
// rebuilt) across the portrait↔landscape layout swap — without
|
|
// it, rotating destroys and recreates the player, forcing a
|
|
// reload. Mirrors the WebView Video below.
|
|
key: _videoKey,
|
|
nativeVideoStore: _videoStore,
|
|
)
|
|
: Video(
|
|
key: _videoKey,
|
|
videoStore: _videoStore as VideoStore,
|
|
),
|
|
);
|
|
|
|
final overlay = GestureDetector(
|
|
onLongPress: _videoStore.handleToggleOverlay,
|
|
onDoubleTap: context.isLandscape
|
|
? () => settingsStore.fullScreen = !settingsStore.fullScreen
|
|
: null,
|
|
onTap: () {
|
|
if (_chatStore.assetsStore.showEmoteMenu) {
|
|
_chatStore.assetsStore.showEmoteMenu = false;
|
|
} else {
|
|
if (_chatStore.textFieldFocusNode.hasFocus) {
|
|
_chatStore.unfocusInput();
|
|
} else {
|
|
_videoStore.handleVideoTap();
|
|
}
|
|
}
|
|
},
|
|
child: Observer(
|
|
builder: (_) {
|
|
final videoOverlay = VideoOverlay(
|
|
videoStore: _videoStore,
|
|
chatStore: _chatStore,
|
|
settingsStore: settingsStore,
|
|
);
|
|
|
|
if (_videoStore.paused ||
|
|
_videoStore.streamInfo == null ||
|
|
_videoStore.loading) {
|
|
return videoOverlay;
|
|
}
|
|
|
|
return AnimatedOpacity(
|
|
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
|
|
curve: Curves.ease,
|
|
duration: const Duration(milliseconds: 200),
|
|
child: ColoredBox(
|
|
color: Colors.transparent,
|
|
child: IgnorePointer(
|
|
ignoring: !_videoStore.overlayVisible,
|
|
child: videoOverlay,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
final video = Stack(
|
|
children: [
|
|
player,
|
|
Positioned.fill(
|
|
child: Observer(
|
|
builder: (_) => AnimatedOpacity(
|
|
opacity: _videoStore.loading ? 1.0 : 0.0,
|
|
// Appear instantly to cover native player placeholders during
|
|
// recovery; fade out smoothly when playback resumes.
|
|
duration: _videoStore.loading
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
// TickerMode stops the spinner's repeating animation while
|
|
// hidden — opacity 0 skips painting but would otherwise keep
|
|
// scheduling frames for the whole video session.
|
|
child: IgnorePointer(
|
|
child: TickerMode(
|
|
enabled: _videoStore.loading,
|
|
child: const ColoredBox(
|
|
color: Colors.black,
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
color: Colors.white38,
|
|
strokeWidth: 2,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Observer(
|
|
builder: (_) => _videoStore.settingsStore.showOverlay
|
|
? overlay
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
);
|
|
|
|
final chat = Observer(
|
|
builder: (context) {
|
|
final bool chatOnly = !settingsStore.showVideo;
|
|
|
|
return Stack(
|
|
children: [
|
|
ChatTabs(
|
|
key: _chatKey,
|
|
chatTabsStore: _chatTabsStore,
|
|
// In chat-only mode, account for the blurred AppBar height
|
|
listPadding: chatOnly
|
|
? EdgeInsets.only(top: context.safePaddingTop)
|
|
: null,
|
|
),
|
|
Observer(
|
|
builder: (_) => AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 200),
|
|
switchInCurve: Curves.easeOut,
|
|
switchOutCurve: Curves.easeIn,
|
|
child: _chatStore.notification != null
|
|
? Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: chatOnly ? context.safePaddingTop : 0,
|
|
),
|
|
child: FrostyNotification(
|
|
message: _chatStore.notification!,
|
|
onDismissed: _chatStore.clearNotification,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
final videoChat = Observer(
|
|
builder: (context) {
|
|
_scheduleSystemUiModeSync(
|
|
immersive:
|
|
context.isLandscape && !settingsStore.landscapeForceVerticalChat,
|
|
);
|
|
|
|
// Build a blurred AppBar when in chat-only mode (no video)
|
|
PreferredSizeWidget? chatOnlyBlurredAppBar;
|
|
if (!settingsStore.showVideo) {
|
|
final streamInfo = _videoStore.streamInfo;
|
|
|
|
chatOnlyBlurredAppBar = AppBar(
|
|
centerTitle: false,
|
|
elevation: 0,
|
|
backgroundColor: Colors.transparent,
|
|
surfaceTintColor: Colors.transparent,
|
|
systemOverlayStyle: SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness:
|
|
context.theme.brightness == Brightness.dark
|
|
? Brightness.light
|
|
: Brightness.dark,
|
|
),
|
|
title: StreamInfoBar(
|
|
streamInfo: streamInfo,
|
|
offlineChannelInfo: _videoStore.offlineChannelInfo,
|
|
displayName: _chatStore.displayName,
|
|
isCompact: true,
|
|
isOffline: streamInfo == null,
|
|
isInSharedChatMode: _chatStore.isInSharedChatMode,
|
|
showTextShadows: false,
|
|
),
|
|
flexibleSpace: BlurredContainer(
|
|
gradientDirection: GradientDirection.up,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
extendBody: true,
|
|
extendBodyBehindAppBar: true,
|
|
appBar: chatOnlyBlurredAppBar,
|
|
body: Observer(
|
|
builder: (context) {
|
|
if (context.isLandscape &&
|
|
!settingsStore.landscapeForceVerticalChat) {
|
|
final landscapeChat = AnimatedContainer(
|
|
curve: Curves.ease,
|
|
duration: _isDividerDragging
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 200),
|
|
width: _chatStore.expandChat
|
|
? context.screenWidth / 2
|
|
: context.screenWidth * settingsStore.chatWidth,
|
|
color: settingsStore.fullScreen
|
|
? Colors.black.withValues(
|
|
alpha: settingsStore.fullScreenChatOverlayOpacity,
|
|
)
|
|
: context.scaffoldColor,
|
|
child: chat,
|
|
);
|
|
|
|
final overlayChat = Visibility(
|
|
visible: settingsStore.fullScreenChatOverlay,
|
|
maintainState: true,
|
|
child: Theme(
|
|
data: FrostyThemes(
|
|
colorSchemeSeed: Color(settingsStore.accentColor),
|
|
).dark,
|
|
child: DefaultTextStyle(
|
|
style: context.defaultTextStyle.copyWith(
|
|
color: context.frostyColors.overlayOnSurface,
|
|
),
|
|
child: landscapeChat,
|
|
),
|
|
),
|
|
);
|
|
|
|
return ColoredBox(
|
|
color: settingsStore.showVideo
|
|
? Colors.black
|
|
: context.scaffoldColor,
|
|
child: settingsStore.showVideo
|
|
? settingsStore.fullScreen
|
|
? Stack(
|
|
children: [
|
|
_buildPipGestureWrapper(child: player),
|
|
if (settingsStore.showOverlay)
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final totalWidth = constraints.maxWidth;
|
|
final chatWidth = _chatStore.expandChat
|
|
? 0.5
|
|
: settingsStore.chatWidth;
|
|
|
|
final draggableDivider = Observer(
|
|
builder: (_) => DraggableDivider(
|
|
currentWidth: chatWidth,
|
|
maxWidth: 0.6,
|
|
isResizableOnLeft: settingsStore
|
|
.landscapeChatLeftSide,
|
|
showHandle:
|
|
_videoStore.overlayVisible &&
|
|
settingsStore
|
|
.fullScreenChatOverlay,
|
|
onDragStart: () {
|
|
setState(() {
|
|
_isDividerDragging = true;
|
|
});
|
|
},
|
|
onDrag: (newWidth) {
|
|
if (!_chatStore.expandChat) {
|
|
settingsStore.chatWidth =
|
|
newWidth;
|
|
}
|
|
},
|
|
onDragEnd: () {
|
|
setState(() {
|
|
_isDividerDragging = false;
|
|
});
|
|
},
|
|
),
|
|
);
|
|
|
|
return Stack(
|
|
children: [
|
|
Row(
|
|
children:
|
|
settingsStore
|
|
.landscapeChatLeftSide
|
|
? [
|
|
overlayChat,
|
|
Expanded(child: overlay),
|
|
]
|
|
: [
|
|
Expanded(child: overlay),
|
|
overlayChat,
|
|
],
|
|
),
|
|
if (settingsStore
|
|
.fullScreenChatOverlay)
|
|
Positioned(
|
|
top: 0,
|
|
bottom: 0,
|
|
left:
|
|
settingsStore
|
|
.landscapeChatLeftSide
|
|
? (totalWidth * chatWidth) -
|
|
12
|
|
: null,
|
|
right:
|
|
!settingsStore
|
|
.landscapeChatLeftSide
|
|
? (totalWidth * chatWidth) -
|
|
12
|
|
: null,
|
|
child: draggableDivider,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
)
|
|
: SafeArea(
|
|
bottom: false,
|
|
left:
|
|
settingsStore.landscapeCutout !=
|
|
LandscapeCutoutType.left &&
|
|
settingsStore.landscapeCutout !=
|
|
LandscapeCutoutType.both,
|
|
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;
|
|
|
|
// 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;
|
|
});
|
|
},
|
|
),
|
|
);
|
|
|
|
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),
|
|
);
|
|
}
|
|
return SafeArea(
|
|
// Keep chat-only scrolling under the blurred app bar, but
|
|
// ensure video does not bleed into the system status bar.
|
|
top: settingsStore.showVideo,
|
|
bottom: false,
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
if (settingsStore.showVideo) ...[
|
|
AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: SizedBox(),
|
|
),
|
|
],
|
|
Expanded(child: chat),
|
|
],
|
|
),
|
|
if (settingsStore.showVideo)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: _buildPipGestureWrapper(
|
|
child: video,
|
|
aspectRatio: 16 / 9,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
// If on Android, use PiPSwitcher to enable PiP functionality.
|
|
if (Platform.isAndroid) {
|
|
// Both players need this — the WebView store used to be skipped by a
|
|
// NativeVideoPlayerInterface type check, so it never learned it was in
|
|
// PiP on Android.
|
|
void notifyPip(bool isInPip) => _videoStore.handleAndroidPipChanged(isInPip);
|
|
|
|
return PipWidget(
|
|
pipLayout: PipActionsLayout.mediaOnlyPause,
|
|
onPipAction: (_) => _videoStore.handlePausePlay(),
|
|
onPipEntered: () => notifyPip(true),
|
|
onPipExited: () => notifyPip(false),
|
|
pipChild: player,
|
|
child: videoChat,
|
|
);
|
|
}
|
|
|
|
return videoChat;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Remove observer for app lifecycle events
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
|
|
_chatTabsStore.dispose();
|
|
|
|
_videoStore.dispose();
|
|
|
|
_animationController.dispose();
|
|
_pipDragDistance.dispose();
|
|
|
|
SystemChrome.setEnabledSystemUIMode(
|
|
SystemUiMode.manual,
|
|
overlays: SystemUiOverlay.values,
|
|
);
|
|
|
|
SystemChrome.setPreferredOrientations([]);
|
|
|
|
super.dispose();
|
|
}
|
|
}
|