frosty/lib/screens/channel/channel.dart
copilot 9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00

822 lines
33 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/channel_info_header.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(),
),
ChannelInfoHeader(
videoStore: _videoStore,
authStore: context.authStore,
userId: widget.userId,
displayName: widget.userName,
isInSharedChatMode:
_chatStore.isInSharedChatMode,
),
],
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();
}
}