frosty/lib/screens/channel/channel.dart
Tommy Chow f2b932bc93
feat(video,chat): native player, ad-break awareness, chat send/delay reliability, and tab redesign (#567)
Squash-merge commit. Covers four areas: native video player, chat reliability, multi-tab, and hardening.

## Native video player
- Direct HLS playback via `better_native_video_player` (forked from `plug-and-pay/flutter-native-video-player`, patched on `frosty-patches` branch)
- Twitch GQL playback token fetching with platform cookie extraction (iOS Swift + Android Kotlin plugins) for authenticated streams
- Quality selection (source, 720p, 480p, 360p, 160p, audio-only) persisted per channel
- Picture-in-picture: iOS automatic inline PiP, Android auto-PiP on API 31+ guarded against older devices
- Latency polling, chat latency sync, stall detection/recovery, startup auto-pause recovery, watchdog timers
- **Twitch stitched-ad break detection** (HLS `EXT-X-DATERANGE`) on both platforms, emitting `adBreakChanged` so the host suspends latency/stall recovery and chat sync during ads (program-date-time is unreliable then). Matches `CLASS="twitch-stitched-ad"` / `ID="stitched-ad-"` **and** the `X-TV-TWITCH-AD-*` attribute prefix (the durable signal streamlink/yt-dlp use)
- Shared `VideoPlayerInterface` so WebView and native paths are interchangeable via settings toggle
- `PlaybackAccessToken` model, `TwitchGqlApi` client

## Chat delay & send reliability
- **Optimistic send-finalize**: Twitch acks a sent message with a per-PRIVMSG USERSTATE (not an echo), which it can intermittently drop — more so under a long chat delay. A missing ack no longer shows a false "message may not have been sent" error; the send is finalized optimistically (local echo rendered), and only genuine rejection NOTICEs surface an error. Ack timeout scaled by the chat delay.
- **Per-line chat-delay bypass**: control/state lines (USERSTATE/ROOMSTATE/GLOBALUSERSTATE), rejection NOTICEs, the reconnect notice, and untagged server lines bypass the delay so send-acks and PONGs aren't stalled; only displayable chat content is delayed, staying in sync with delayed video.
- **Measured chat bottom-bar height**: the message list reflows to the composer's real measured height, fixing the input overlapping messages when a long message expands it (replaces a fixed-constant estimate).
- IRC tag parsing handles valueless and escaped tags per the IRCv3 message-tags spec (fixes a recent-messages RangeError on certain tags).

## Chat / multi-tab
- Redesigned tab chips: unread dot indicator, chips dim until WebSocket connects
- Animated tab options popover replacing `MenuAnchor` (no animation hook in Flutter 3.44)
- Simplified `ChatTabs` from `StatefulWidget` to `StatelessWidget`
- Focus-current-channel fade bump to 0.55; section-header alpha lifted to match the muted-label tier
- `addTab` now calls `setActiveTab` to snapshot outgoing tab's `lastSeenMessageId` — fixes spurious unread dots after adding a tab
- `onActivateSourceTab` threaded into `ChatUserModal` so Reply in merged mode switches to the correct source tab's composer

## Bug fixes and hardening
- **Reverted the experimental iOS low-latency mode** (5s live-edge join + 1.05x catch-up): AVPlayer can't sustain <~18s on Twitch (declared `TARGETDURATION:6` paces playlist refreshes), and the offset starved the buffer into pause-on-launch. Default join distance restored.
- Fork (Android): **cap `BEHIND_LIVE_WINDOW` recovery** so a stream repeatedly falling off the live window can't silently re-prepare forever; emits `error` on exhaustion so the host recovery ladder takes over
- Guard all Android `setAutoPipMode` calls with `_autoPipAvailable` — prevents `PlatformException` on Android 8–11; check `_disposed` after `isAutoPipAvailable` await to prevent post-dispose auto-PiP re-enable
- Remove `_isInPipMode` guard from `_onPaused` so `_paused` tracks actual state, fixing PiP play/pause toggle getting stuck
- Tighten iOS cookie domain check to exact `twitch.tv` / `.twitch.tv` suffix
- Invalidate the stored web session token when Twitch rejects it with a 401 (profile card flips back to unlinked; future loads skip the doomed authenticated request), and re-extract the cookie on launch when logged in without a linked session
- Send `play_session_id` and `player_backend=mediaplayer` to Usher, matching the web client / streamlink
- Fork (iOS): restore program-date-time latency measurement (a prior rev measured distance to the manifest edge, reading ~0s and silently disabling high-latency recovery / chat-delay auto-sync); release platform views on dispose (per-stream view + AVPlayerViewController leak), track the observed AVPlayerItem for KVO removal (sibling-view crash), main-thread seek results, resolve superseded load futures
- Fork (Android): clean up method handlers orphaned by PiP-time view disposal (leaked audio-focus listeners), drop the live-offset re-anchoring seekTo on metadata updates (latency creep), restore pre-duck volume, respect user pause in the stall watchdog, resolve superseded load futures
- Deep review pass: expire the quality-switch flag so stall recovery isn't disabled for the session, cap latency-driven recovery (clock-skew loop), generation-guard the error overlay, two-strike offline confirmation, controller null-safety on quality switch
- Tighten `/me` local-echo match (no longer renders `/meow ...` as an action); clear pending send text on disconnect

## Web / docs
- Remove stale Cloudflare Images binding from `wrangler.jsonc` (covered by `images.unoptimized: true`)
- Correct `web/AGENTS.md` Cache Components claim (feature not enabled)
- Repo and agent config cleanup

## Test plan
- [x] `flutter analyze` — clean (frosty + plugin lib)
- [x] `flutter test` — 319/319 pass (adds `ChatLatencySync`, `StreamInfoPoller`, video timing invariants, `PlaybackAccessToken`, IRC tag parsing)
- [x] Android plugin Kotlin compiles against the bumped fork rev
- [ ] Native player plays live streams on iOS and Android
- [ ] Quality switching works and persists across channel reopens
- [ ] PiP enters/exits correctly; pause/resume toggle in PiP window works
- [ ] Audio-only mode does not crash on Android < API 31
- [ ] Sending under a long synced chat delay no longer shows a false "may not have been sent" error
- [ ] Long composer text reflows the message list (no input/message overlap)
- [ ] Multi-tab: adding a tab does not mark previous tab as unread
- [ ] Merged chat: Reply from user modal targets the correct tab's composer

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-13 17:06:24 -04:00

809 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_player_interface.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(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) {
void notifyPip(bool isInPip) {
final store = _videoStore;
if (store is NativeVideoPlayerInterface) {
store.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();
}
}