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)
809 lines
32 KiB
Dart
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();
|
|
}
|
|
}
|