frosty/lib/screens/channel/channel.dart
2026-01-09 01:04:09 -05:00

729 lines
30 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/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_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:provider/provider.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 {
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 - essential only
double _pipDragDistance = 0;
bool _isPipDragging = false;
bool _isInPipTriggerZone =
false; // Track when in trigger zone for haptic feedback
// 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;
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 VideoStore _videoStore = 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),
)..addListener(() {
setState(() {
_pipDragDistance = _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) {
// Disable drag gesture when already in PiP mode or video is not playing
if (_videoStore.isInPipMode || _videoStore.paused) return;
_animationController.stop(); // Stop any ongoing animation
setState(() {
_isPipDragging = true;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _handlePipDragUpdate(DragUpdateDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
return;
}
setState(() {
_pipDragDistance += details.delta.dy;
_pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance);
// Check if we've entered or exited the trigger zone for haptic feedback
final wasInTriggerZone = _isInPipTriggerZone;
_isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance;
// Provide haptic feedback when entering the trigger zone
if (!wasInTriggerZone && _isInPipTriggerZone) {
HapticFeedback.mediumImpact(); // Entering trigger zone
}
// Provide subtle haptic feedback when exiting the trigger zone
else if (wasInTriggerZone && !_isInPipTriggerZone) {
HapticFeedback.lightImpact(); // Exiting trigger zone
}
});
}
void _handlePipDragEnd(DragEndDetails details) {
if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) {
return;
}
final velocity = details.velocity.pixelsPerSecond.dy;
final shouldTriggerPip =
_pipDragDistance >= _pipTriggerDistance ||
velocity > 600; // Simple velocity threshold
if (shouldTriggerPip) {
// Simple haptic feedback on success
HapticFeedback.mediumImpact();
_videoStore.requestPictureInPicture();
_resetDragState();
} else {
// Animate back to original position
_animateSpringBack();
}
}
void _handlePipDragCancel() {
if (!_isPipDragging) return;
_animateSpringBack();
}
void _resetDragState() {
setState(() {
_isPipDragging = false;
_pipDragDistance = 0;
_isInPipTriggerZone = false;
});
}
void _animateSpringBack() {
_springBackAnimation = Tween<double>(begin: _pipDragDistance, end: 0)
.animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_animationController.reset();
_animationController.forward().then((_) {
_resetDragState();
});
}
/// 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}) {
return AnimatedBuilder(
animation: Listenable.merge([_animationController, _springBackAnimation]),
builder: (context, _) {
final currentDragDistance = _isPipDragging
? _pipDragDistance
: _springBackAnimation.value;
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(
scale: scaleFactor.clamp(0.9, 1.0),
child: Stack(
children: [
GestureDetector(
onPanStart: _handlePipDragStart,
onPanUpdate: _handlePipDragUpdate,
onPanEnd: _handlePipDragEnd,
onPanCancel: _handlePipDragCancel,
child: content,
),
if (!_videoStore.isInPipMode)
Positioned.fill(
child: IgnorePointer(
ignoring: !(_isPipDragging && _pipDragDistance > 0),
child: AnimatedOpacity(
opacity: (_isPipDragging && _pipDragDistance > 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: Video(key: _videoKey, videoStore: _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) {
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 = Observer(
builder: (context) {
if (!_videoStore.settingsStore.showOverlay) return player;
return Stack(children: [player, overlay]);
},
);
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) {
// 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) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
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
.watch<FrostyThemes>()
.dark
.colorScheme
.onSurface,
),
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),
);
}
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.
top: settingsStore.showVideo,
bottom: false,
child: Stack(
children: [
Column(
children: [
if (settingsStore.showVideo) ...[
AspectRatio(
aspectRatio: 16 / 9,
child: Container(), // Placeholder for video space
),
],
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) {
return PipWidget(
pipLayout: PipActionsLayout.mediaOnlyPause,
onPipAction: (_) => _videoStore.handlePausePlay(),
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();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
SystemChrome.setPreferredOrientations([]);
super.dispose();
}
}