import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../providers/auth_provider.dart'; import '../providers/settings_provider.dart'; import '../services/api_service.dart'; import '../services/chat_service.dart'; import '../widgets/web_stream_player.dart'; class PlayerPage extends StatefulWidget { final String title; final String playbackUrl; final String roomId; const PlayerPage({ super.key, required this.title, required this.playbackUrl, required this.roomId, }); @override State createState() => _PlayerPageState(); } class _PlayerPageState extends State { VideoPlayerController? _controller; final ChatService _chatService = ChatService(); final TextEditingController _msgController = TextEditingController(); final List _messages = []; final List<_DanmakuEntry> _danmakus = []; bool _isError = false; String? _errorMessage; bool _showDanmaku = true; bool _isRefreshing = false; bool _isFullscreen = false; bool _controlsVisible = true; int _playerVersion = 0; String _selectedResolution = 'Source'; List _availableResolutions = const ['Source']; Timer? _controlsHideTimer; @override void initState() { super.initState(); _loadPlaybackOptions(); if (!kIsWeb) { _initializePlayer(); } _initializeChat(); _showControls(); } Future _initializePlayer() async { final playbackUrl = _currentPlaybackUrl(); _controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl)); try { await _controller!.initialize(); _controller!.play(); if (mounted) setState(() {}); } catch (e) { if (mounted) { setState(() { _isError = true; _errorMessage = e.toString(); _isRefreshing = false; }); } return; } if (mounted) { setState(() { _isError = false; _errorMessage = null; _isRefreshing = false; }); } } String _currentPlaybackUrl() { final settings = context.read(); final quality = _selectedResolution == 'Source' ? null : _selectedResolution.toLowerCase(); return settings.playbackUrl(widget.roomId, quality: quality); } Future _loadPlaybackOptions() async { final settings = context.read(); final auth = context.read(); final api = ApiService(settings, auth.token); try { final response = await api.getPlaybackOptions(widget.roomId); if (response.statusCode != 200) { return; } final data = jsonDecode(response.body) as Map; final rawQualities = (data['qualities'] as List? ?? const ['source']) .map((item) => item.toString().trim().toLowerCase()) .where((item) => item.isNotEmpty) .toList(); final normalized = ['Source']; for (final quality in rawQualities) { if (quality == 'source') { continue; } normalized.add(quality); } if (!mounted) { return; } setState(() { _availableResolutions = normalized.toSet().toList(); if (!_availableResolutions.contains(_selectedResolution)) { _selectedResolution = 'Source'; } }); } catch (_) { // Keep source-only playback when the capability probe fails. } } void _initializeChat() { final settings = context.read(); final auth = context.read(); // 使用真实用户名建立连接 final currentUsername = auth.username ?? "Guest_${widget.roomId}"; _chatService.connect(settings.baseUrl, widget.roomId, currentUsername); _chatService.messages.listen((msg) { if (mounted) { setState(() { _messages.insert(0, msg); if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) { if (_showDanmaku) { _addDanmaku(msg.content); } } }); } }); } void _addDanmaku(String text) { final key = UniqueKey(); final lane = DateTime.now().millisecondsSinceEpoch % 8; final topFactor = 0.06 + lane * 0.045; setState(() { _danmakus.add( _DanmakuEntry( key: key, text: text, topFactor: topFactor, onFinished: () { if (mounted) { setState(() => _danmakus.removeWhere((w) => w.key == key)); } }, ), ); }); } void _sendMsg() { if (_msgController.text.isNotEmpty) { final auth = context.read(); _chatService.sendMessage( _msgController.text, auth.username ?? "Anonymous", widget.roomId, ); _msgController.clear(); } } Future _refreshPlayer() async { if (_isRefreshing) { return; } await _loadPlaybackOptions(); setState(() { _isRefreshing = true; _isError = false; _errorMessage = null; _danmakus.clear(); _playerVersion++; }); _showControls(); if (kIsWeb) { await Future.delayed(const Duration(milliseconds: 150)); if (mounted) { setState(() => _isRefreshing = false); } return; } if (_controller != null) { await _controller!.dispose(); } _controller = null; if (mounted) { setState(() {}); } await _initializePlayer(); } Future _toggleFullscreen() async { final nextValue = !_isFullscreen; if (!kIsWeb) { await SystemChrome.setEnabledSystemUIMode( nextValue ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, ); await SystemChrome.setPreferredOrientations( nextValue ? const [ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ] : DeviceOrientation.values, ); } if (mounted) { setState(() => _isFullscreen = nextValue); } _showControls(); } void _toggleDanmaku() { setState(() { _showDanmaku = !_showDanmaku; if (!_showDanmaku) { _danmakus.clear(); } }); _showControls(); } void _showControls() { _controlsHideTimer?.cancel(); if (mounted) { setState(() => _controlsVisible = true); } _controlsHideTimer = Timer(const Duration(seconds: 3), () { if (mounted) { setState(() => _controlsVisible = false); } }); } void _toggleControlsVisibility() { if (_controlsVisible) { _controlsHideTimer?.cancel(); setState(() => _controlsVisible = false); } else { _showControls(); } } Future _selectResolution() async { _showControls(); await _loadPlaybackOptions(); if (!mounted) { return; } final nextResolution = await showModalBottomSheet( context: context, builder: (context) { const options = ['Source', '720p', '480p']; final available = _availableResolutions.toSet(); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: Text('Playback Resolution'), subtitle: Text( available.length > 1 ? 'Select an available transcoded stream.' : 'Only the source stream is available right now.', ), ), ...options.map((option) { final enabled = available.contains(option); return ListTile( enabled: enabled, leading: Icon( option == _selectedResolution ? Icons.radio_button_checked : Icons.radio_button_off, ), title: Text(option), subtitle: enabled ? const Text('Available now') : const Text('Waiting for backend transcoding output'), onTap: enabled ? () => Navigator.pop(context, option) : null, ); }), ], ), ); }, ); if (nextResolution == null || nextResolution == _selectedResolution) { return; } setState(() => _selectedResolution = nextResolution); await _refreshPlayer(); } @override void dispose() { if (!kIsWeb && _isFullscreen) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setPreferredOrientations(DeviceOrientation.values); } _controlsHideTimer?.cancel(); _controller?.dispose(); _chatService.dispose(); _msgController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { bool isWide = MediaQuery.of(context).size.width > 900; return Scaffold( backgroundColor: _isFullscreen ? Colors.black : Theme.of(context).colorScheme.surface, appBar: _isFullscreen ? null : AppBar(title: Text(widget.title)), body: _isFullscreen ? _buildFullscreenLayout() : isWide ? _buildWideLayout() : _buildMobileLayout(), ); } Widget _buildFullscreenLayout() { return SizedBox.expand(child: _buildVideoPanel()); } // 宽屏布局:左右分栏 Widget _buildWideLayout() { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 左侧视频区 (占比 75%) Expanded(flex: 3, child: _buildVideoPanel()), // 右侧聊天区 (占比 25%) Container( width: 350, decoration: BoxDecoration( border: Border( left: BorderSide(color: Theme.of(context).dividerColor), ), ), child: _buildChatSection(), ), ], ); } // 移动端布局:上下堆叠 Widget _buildMobileLayout() { return Column( children: [ SizedBox( height: 310, width: double.infinity, child: _buildVideoPanel(), ), Expanded(child: _buildChatSection()), ], ); } Widget _buildVideoPanel() { return MouseRegion( onHover: (_) => _showControls(), onEnter: (_) => _showControls(), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggleControlsVisibility, onDoubleTap: _toggleFullscreen, child: Container( color: Colors.black, child: Stack( children: [ Positioned.fill(child: _buildVideoWithDanmaku()), Positioned( left: 0, right: 0, bottom: 0, child: _buildPlaybackControls(), ), ], ), ), ), ); } Widget _buildVideoWithDanmaku() { return LayoutBuilder( builder: (context, constraints) { return Stack( children: [ Center( child: _isError ? Text( "Error: $_errorMessage", style: TextStyle(color: Colors.white), ) : kIsWeb ? WebStreamPlayer( key: ValueKey('web-player-$_playerVersion'), streamUrl: _currentPlaybackUrl(), ) : _controller != null && _controller!.value.isInitialized ? AspectRatio( aspectRatio: _controller!.value.aspectRatio, child: VideoPlayer(_controller!), ) : CircularProgressIndicator(), ), if (_showDanmaku) ClipRect( child: Stack( children: _danmakus .map( (item) => _DanmakuItem( key: item.key, text: item.text, topFactor: item.topFactor, containerWidth: constraints.maxWidth, containerHeight: constraints.maxHeight, onFinished: item.onFinished, ), ) .toList(), ), ), if (_isRefreshing) const Positioned( top: 16, right: 16, child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ], ); }, ); } Color _usernameColor(String username, String type) { if (type == "system") { return Colors.blue; } final normalized = username.trim().toLowerCase(); var hash = 5381; for (final codeUnit in normalized.codeUnits) { hash = ((hash << 5) + hash) ^ codeUnit; } final hue = (hash.abs() % 360).toDouble(); return HSLColor.fromAHSL(1, hue, 0.72, 0.68).toColor(); } Widget _buildMessageItem(ChatMessage message) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: RichText( text: TextSpan( style: TextStyle( color: Theme.of(context).textTheme.bodyMedium?.color, ), children: [ TextSpan( text: "${message.username}: ", style: TextStyle( fontWeight: FontWeight.bold, color: _usernameColor(message.username, message.type), ), ), TextSpan(text: message.content), ], ), ), ); } Widget _buildPlaybackControls() { return IgnorePointer( ignoring: !_controlsVisible, child: AnimatedOpacity( opacity: _controlsVisible ? 1 : 0, duration: const Duration(milliseconds: 220), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withValues(alpha: 0.9), Colors.black.withValues(alpha: 0.55), Colors.transparent, ], ), ), child: SafeArea( top: false, child: Align( alignment: Alignment.bottomCenter, child: Wrap( spacing: 10, runSpacing: 10, alignment: WrapAlignment.center, children: [ _buildControlButton( icon: Icons.refresh, label: "Refresh", onPressed: _refreshPlayer, ), _buildControlButton( icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off, label: _showDanmaku ? "Danmaku On" : "Danmaku Off", onPressed: _toggleDanmaku, ), _buildControlButton( icon: _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen", onPressed: _toggleFullscreen, ), _buildControlButton( icon: Icons.high_quality, label: _selectedResolution, onPressed: _selectResolution, ), ], ), ), ), ), ), ); } Widget _buildControlButton({ required IconData icon, required String label, required FutureOr Function() onPressed, }) { return FilledButton.tonalIcon( onPressed: () async { _showControls(); await onPressed(); }, icon: Icon(icon, size: 18), label: Text(label), style: FilledButton.styleFrom( foregroundColor: Colors.white, backgroundColor: Colors.white.withValues(alpha: 0.12), ), ); } // 抽离聊天区域组件 Widget _buildChatSection() { return Column( children: [ Container( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Row( children: [ Icon(Icons.chat_bubble_outline, size: 16), SizedBox(width: 8), Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)), ], ), ), Expanded( child: ListView.builder( reverse: true, padding: EdgeInsets.all(8), itemCount: _messages.length, itemBuilder: (context, index) { final m = _messages[index]; return _buildMessageItem(m); }, ), ), Divider(height: 1), Padding( padding: const EdgeInsets.all(8.0), child: Row( children: [ Expanded( child: TextField( controller: _msgController, decoration: InputDecoration( hintText: "Send a message...", border: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), contentPadding: EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), ), onSubmitted: (_) => _sendMsg(), ), ), IconButton( icon: Icon( Icons.send, color: Theme.of(context).colorScheme.primary, ), onPressed: _sendMsg, ), ], ), ), ], ); } } class _DanmakuEntry { final Key key; final String text; final double topFactor; final VoidCallback onFinished; const _DanmakuEntry({ required this.key, required this.text, required this.topFactor, required this.onFinished, }); } class _DanmakuItem extends StatefulWidget { final String text; final double topFactor; final double containerWidth; final double containerHeight; final VoidCallback onFinished; const _DanmakuItem({ super.key, required this.text, required this.topFactor, required this.containerWidth, required this.containerHeight, required this.onFinished, }); @override __DanmakuItemState createState() => __DanmakuItemState(); } class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation _animation; @override void initState() { super.initState(); _animationController = AnimationController( duration: const Duration(seconds: 10), vsync: this, ); // 使用相对位置:从右向左 _animation = Tween( begin: 1.0, end: -0.5, ).animate(_animationController); _animationController.forward().then((_) => widget.onFinished()); } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return Positioned( top: widget.containerHeight * widget.topFactor, left: widget.containerWidth * _animation.value, child: Text( widget.text, style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18, shadows: [ Shadow( blurRadius: 4, color: Colors.black, offset: Offset(1, 1), ), ], ), ), ); }, ); } }