Add web HTTP-FLV playback path

This commit is contained in:
2026-04-01 11:30:52 +08:00
parent 01b25883e1
commit 48dc6c7b26
11 changed files with 455 additions and 93 deletions

View File

@@ -33,12 +33,22 @@ class _HomePageState extends State<HomePage> {
if (isWide)
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => setState(() => _selectedIndex = index),
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.explore), label: Text('Explore')),
NavigationRailDestination(icon: Icon(Icons.videocam), label: Text('Console')),
NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')),
NavigationRailDestination(
icon: Icon(Icons.explore),
label: Text('Explore'),
),
NavigationRailDestination(
icon: Icon(Icons.videocam),
label: Text('Console'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
],
),
Expanded(child: _pages[_selectedIndex]),
@@ -47,11 +57,21 @@ class _HomePageState extends State<HomePage> {
bottomNavigationBar: !isWide
? NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => setState(() => _selectedIndex = index),
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
destinations: const [
NavigationDestination(icon: Icon(Icons.explore), label: 'Explore'),
NavigationDestination(icon: Icon(Icons.videocam), label: 'Console'),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
NavigationDestination(
icon: Icon(Icons.explore),
label: 'Explore',
),
NavigationDestination(
icon: Icon(Icons.videocam),
label: 'Console',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
)
: null,
@@ -100,7 +120,10 @@ class _ExploreViewState extends State<_ExploreView> {
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []);
}
} catch (e) {
if (!isAuto && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
if (!isAuto && mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
} finally {
if (!isAuto && mounted) setState(() => _isLoading = false);
}
@@ -114,8 +137,14 @@ class _ExploreViewState extends State<_ExploreView> {
appBar: AppBar(
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(icon: Icon(Icons.refresh), onPressed: () => _refreshRooms()),
IconButton(icon: Icon(Icons.logout), onPressed: () => context.read<AuthProvider>().logout()),
IconButton(
icon: Icon(Icons.refresh),
onPressed: () => _refreshRooms(),
),
IconButton(
icon: Icon(Icons.logout),
onPressed: () => context.read<AuthProvider>().logout(),
),
],
),
floatingActionButton: FloatingActionButton.extended(
@@ -128,33 +157,42 @@ class _ExploreViewState extends State<_ExploreView> {
child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty
? ListView(children: [
Padding(
padding: EdgeInsets.only(top: 100),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.live_tv_outlined, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text("No active rooms. Be the first!", style: TextStyle(color: Colors.grey, fontSize: 16)),
],
),
)
])
: GridView.builder(
padding: EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
? ListView(
children: [
Padding(
padding: EdgeInsets.only(top: 100),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.live_tv_outlined,
size: 80,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
"No active rooms. Be the first!",
style: TextStyle(color: Colors.grey, fontSize: 16),
),
],
),
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings);
},
),
],
)
: GridView.builder(
padding: EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings);
},
),
),
);
}
@@ -166,13 +204,13 @@ class _ExploreViewState extends State<_ExploreView> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () {
final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}";
final playbackUrl = settings.playbackUrl(room['room_id'].toString());
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerPage(
title: room['title'],
rtmpUrl: rtmpUrl,
playbackUrl: playbackUrl,
roomId: room['room_id'].toString(),
),
),
@@ -188,18 +226,37 @@ class _ExploreViewState extends State<_ExploreView> {
children: [
Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(child: Icon(Icons.live_tv, size: 50, color: Theme.of(context).colorScheme.primary)),
child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
),
),
Positioned(
top: 8, left: 8,
top: 8,
left: 8,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)),
child: Row(children: [
Icon(Icons.circle, size: 8, color: Colors.white),
SizedBox(width: 4),
Text("LIVE", style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
]),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Icon(Icons.circle, size: 8, color: Colors.white),
SizedBox(width: 4),
Text(
"LIVE",
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
@@ -208,18 +265,35 @@ class _ExploreViewState extends State<_ExploreView> {
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
CircleAvatar(radius: 16, child: Text(room['user_id'].toString().substring(0, 1))),
CircleAvatar(
radius: 16,
child: Text(room['user_id'].toString().substring(0, 1)),
),
SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(room['title'], maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
Text("Host ID: ${room['user_id']}", style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(
room['title'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
"Host ID: ${room['user_id']}",
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),

View File

@@ -1,20 +1,21 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/chat_service.dart';
import '../widgets/web_stream_player.dart';
class PlayerPage extends StatefulWidget {
final String title;
final String rtmpUrl;
final String playbackUrl;
final String roomId;
const PlayerPage({
Key? key,
required this.title,
required this.rtmpUrl,
required this.playbackUrl,
required this.roomId,
}) : super(key: key);
@@ -23,37 +24,45 @@ class PlayerPage extends StatefulWidget {
}
class _PlayerPageState extends State<PlayerPage> {
late VideoPlayerController _controller;
VideoPlayerController? _controller;
final ChatService _chatService = ChatService();
final TextEditingController _msgController = TextEditingController();
final List<ChatMessage> _messages = [];
final List<Widget> _danmakus = [];
bool _isError = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializePlayer();
if (!kIsWeb) {
_initializePlayer();
}
_initializeChat();
}
void _initializePlayer() async {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.rtmpUrl));
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.playbackUrl),
);
try {
await _controller.initialize();
_controller.play();
await _controller!.initialize();
_controller!.play();
if (mounted) setState(() {});
} catch (e) {
if (mounted) setState(() { _isError = true; _errorMessage = e.toString(); });
if (mounted)
setState(() {
_isError = true;
_errorMessage = e.toString();
});
}
}
void _initializeChat() {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
// 使用真实用户名建立连接
final currentUsername = auth.username ?? "Guest_${widget.roomId}";
_chatService.connect(settings.baseUrl, widget.roomId, currentUsername);
@@ -72,8 +81,8 @@ class _PlayerPageState extends State<PlayerPage> {
void _addDanmaku(String text) {
final key = UniqueKey();
final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0;
final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0;
final danmaku = _DanmakuItem(
key: key,
text: text,
@@ -89,14 +98,18 @@ class _PlayerPageState extends State<PlayerPage> {
void _sendMsg() {
if (_msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>();
_chatService.sendMessage(_msgController.text, auth.username ?? "Anonymous", widget.roomId);
_chatService.sendMessage(
_msgController.text,
auth.username ?? "Anonymous",
widget.roomId,
);
_msgController.clear();
}
}
@override
void dispose() {
_controller.dispose();
_controller?.dispose();
_chatService.dispose();
_msgController.dispose();
super.dispose();
@@ -129,7 +142,9 @@ class _PlayerPageState extends State<PlayerPage> {
Container(
width: 350,
decoration: BoxDecoration(
border: Border(left: BorderSide(color: Theme.of(context).dividerColor)),
border: Border(
left: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: _buildChatSection(),
),
@@ -160,18 +175,21 @@ class _PlayerPageState extends State<PlayerPage> {
children: [
Center(
child: _isError
? Text("Error: $_errorMessage", style: TextStyle(color: Colors.white))
: _controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: CircularProgressIndicator(),
? Text(
"Error: $_errorMessage",
style: TextStyle(color: Colors.white),
)
: kIsWeb
? WebStreamPlayer(streamUrl: widget.playbackUrl)
: _controller != null && _controller!.value.isInitialized
? AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!),
)
: CircularProgressIndicator(),
),
// 弹幕层使用 ClipRect 裁剪,防止飘出视频区域
ClipRect(
child: Stack(children: _danmakus),
),
ClipRect(child: Stack(children: _danmakus)),
],
);
}
@@ -202,11 +220,18 @@ class _PlayerPageState extends State<PlayerPage> {
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: RichText(
text: TextSpan(
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color),
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),
style: TextStyle(
fontWeight: FontWeight.bold,
color: m.type == "system"
? Colors.blue
: Theme.of(context).colorScheme.primary,
),
),
TextSpan(text: m.content),
],
@@ -226,13 +251,24 @@ class _PlayerPageState extends State<PlayerPage> {
controller: _msgController,
decoration: InputDecoration(
hintText: "Send a message...",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
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),
IconButton(
icon: Icon(
Icons.send,
color: Theme.of(context).colorScheme.primary,
),
onPressed: _sendMsg,
),
],
),
),
@@ -246,24 +282,36 @@ class _DanmakuItem extends StatefulWidget {
final double top;
final VoidCallback onFinished;
const _DanmakuItem({Key? key, required this.text, required this.top, required this.onFinished}) : super(key: key);
const _DanmakuItem({
Key? key,
required this.text,
required this.top,
required this.onFinished,
}) : super(key: key);
@override
__DanmakuItemState createState() => __DanmakuItemState();
}
class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderStateMixin {
class __DanmakuItemState extends State<_DanmakuItem>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(duration: const Duration(seconds: 10), vsync: this);
_animationController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
);
// 使用相对位置:从右向左
_animation = Tween<double>(begin: 1.0, end: -0.5).animate(_animationController);
_animation = Tween<double>(
begin: 1.0,
end: -0.5,
).animate(_animationController);
_animationController.forward().then((_) => widget.onFinished());
}
@@ -288,7 +336,13 @@ class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderSt
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
shadows: [Shadow(blurRadius: 4, color: Colors.black, offset: Offset(1, 1))],
shadows: [
Shadow(
blurRadius: 4,
color: Colors.black,
offset: Offset(1, 1),
),
],
),
),
);