diff --git a/frontend/lib/pages/player_page.dart b/frontend/lib/pages/player_page.dart index 83f327b..15dd36e 100644 --- a/frontend/lib/pages/player_page.dart +++ b/frontend/lib/pages/player_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,14 +32,17 @@ class _PlayerPageState extends State { final ChatService _chatService = ChatService(); final TextEditingController _msgController = TextEditingController(); final List _messages = []; - final List _danmakus = []; + 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'; + Timer? _controlsHideTimer; @override void initState() { @@ -46,6 +51,7 @@ class _PlayerPageState extends State { _initializePlayer(); } _initializeChat(); + _showControls(); } Future _initializePlayer() async { @@ -99,18 +105,23 @@ class _PlayerPageState extends State { void _addDanmaku(String text) { final key = UniqueKey(); - final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0; + final lane = DateTime.now().millisecondsSinceEpoch % 8; + final topFactor = 0.06 + lane * 0.045; - final danmaku = _DanmakuItem( - key: key, - text: text, - top: top, - onFinished: () { - if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == key)); - }, - ); - - setState(() => _danmakus.add(danmaku)); + setState(() { + _danmakus.add( + _DanmakuEntry( + key: key, + text: text, + topFactor: topFactor, + onFinished: () { + if (mounted) { + setState(() => _danmakus.removeWhere((w) => w.key == key)); + } + }, + ), + ); + }); } void _sendMsg() { @@ -137,6 +148,7 @@ class _PlayerPageState extends State { _danmakus.clear(); _playerVersion++; }); + _showControls(); if (kIsWeb) { await Future.delayed(const Duration(milliseconds: 150)); @@ -175,6 +187,7 @@ class _PlayerPageState extends State { if (mounted) { setState(() => _isFullscreen = nextValue); } + _showControls(); } void _toggleDanmaku() { @@ -184,6 +197,74 @@ class _PlayerPageState extends State { _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(); + final nextResolution = await showModalBottomSheet( + context: context, + builder: (context) { + const options = ['Source', '720p', '480p']; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile( + title: Text('Playback Resolution'), + subtitle: Text( + 'Current backend only provides the source stream. Lower resolutions are reserved for future multi-bitrate output.', + ), + ), + ...options.map((option) { + final enabled = option == 'Source'; + 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('Requires backend transcoding support'), + onTap: enabled ? () => Navigator.pop(context, option) : null, + ); + }), + ], + ), + ); + }, + ); + + if (nextResolution == null || nextResolution == _selectedResolution) { + return; + } + + setState(() => _selectedResolution = nextResolution); + await _refreshPlayer(); } @override @@ -192,6 +273,7 @@ class _PlayerPageState extends State { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setPreferredOrientations(DeviceOrientation.values); } + _controlsHideTimer?.cancel(); _controller?.dispose(); _chatService.dispose(); _msgController.dispose(); @@ -203,11 +285,22 @@ class _PlayerPageState extends State { bool isWide = MediaQuery.of(context).size.width > 900; return Scaffold( - appBar: AppBar(title: Text(widget.title)), - body: isWide ? _buildWideLayout() : _buildMobileLayout(), + 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( @@ -244,96 +337,179 @@ class _PlayerPageState extends State { } Widget _buildVideoPanel() { - return Container( - color: Colors.black, - child: Column( - children: [ - Expanded(child: _buildVideoWithDanmaku()), - _buildPlaybackControls(), - ], + 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 Stack( - children: [ - Center( - child: _isError - ? Text( - "Error: $_errorMessage", - style: TextStyle(color: Colors.white), - ) - : kIsWeb - ? WebStreamPlayer( - key: ValueKey('web-player-$_playerVersion'), - streamUrl: widget.playbackUrl, - ) - : _controller != null && _controller!.value.isInitialized - ? AspectRatio( - aspectRatio: _controller!.value.aspectRatio, - child: VideoPlayer(_controller!), - ) - : CircularProgressIndicator(), - ), - if (_showDanmaku) ClipRect(child: Stack(children: _danmakus)), - if (_isRefreshing) - const Positioned( - top: 16, - right: 16, - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + 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: widget.playbackUrl, + ) + : _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 Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.92), - border: Border( - top: BorderSide(color: Colors.white.withValues(alpha: 0.08)), - ), - ), - child: Wrap( - spacing: 10, - runSpacing: 10, - children: [ - _buildControlButton( - icon: Icons.refresh, - label: "Refresh", - onPressed: _refreshPlayer, + 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, + ], + ), ), - _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: "Resolution", - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Resolution switching is planned for a later update.", + 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, + ), + ], + ), + ), ), - ], + ), ), ); } @@ -341,10 +517,13 @@ class _PlayerPageState extends State { Widget _buildControlButton({ required IconData icon, required String label, - required VoidCallback onPressed, + required FutureOr Function() onPressed, }) { return FilledButton.tonalIcon( - onPressed: onPressed, + onPressed: () async { + _showControls(); + await onPressed(); + }, icon: Icon(icon, size: 18), label: Text(label), style: FilledButton.styleFrom( @@ -376,28 +555,7 @@ class _PlayerPageState extends State { itemCount: _messages.length, itemBuilder: (context, index) { final m = _messages[index]; - 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: "${m.username}: ", - style: TextStyle( - fontWeight: FontWeight.bold, - color: m.type == "system" - ? Colors.blue - : Theme.of(context).colorScheme.primary, - ), - ), - TextSpan(text: m.content), - ], - ), - ), - ); + return _buildMessageItem(m); }, ), ), @@ -437,15 +595,33 @@ class _PlayerPageState extends State { } } +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 top; + final double topFactor; + final double containerWidth; + final double containerHeight; final VoidCallback onFinished; const _DanmakuItem({ super.key, required this.text, - required this.top, + required this.topFactor, + required this.containerWidth, + required this.containerHeight, required this.onFinished, }); @@ -487,9 +663,8 @@ class __DanmakuItemState extends State<_DanmakuItem> animation: _animation, builder: (context, child) { return Positioned( - top: widget.top, - // left 使用 MediaQuery 获取屏幕宽度进行动态计算 - left: MediaQuery.of(context).size.width * _animation.value, + top: widget.containerHeight * widget.topFactor, + left: widget.containerWidth * _animation.value, child: Text( widget.text, style: TextStyle( diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index c731046..1ad5034 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0-beta3.7 +version: 1.0.0-beta4.1 environment: sdk: ^3.11.1