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

@@ -18,9 +18,11 @@ func main() {
// Initialize Chat WebSocket Hub // Initialize Chat WebSocket Hub
chat.InitChat() chat.InitChat()
srv := stream.NewRTMPServer()
// Start the API server in a goroutine so it doesn't block the RTMP server // Start the API server in a goroutine so it doesn't block the RTMP server
go func() { go func() {
r := api.SetupRouter() r := api.SetupRouter(srv)
log.Println("[INFO] API Server is listening on :8080...") log.Println("[INFO] API Server is listening on :8080...")
if err := r.Run(":8080"); err != nil { if err := r.Run(":8080"); err != nil {
log.Fatalf("Failed to start API server: %v", err) log.Fatalf("Failed to start API server: %v", err)
@@ -29,7 +31,6 @@ func main() {
// Setup and start the RTMP server // Setup and start the RTMP server
log.Println("[INFO] Ready to receive RTMP streams from OBS.") log.Println("[INFO] Ready to receive RTMP streams from OBS.")
srv := stream.NewRTMPServer()
if err := srv.Start(":1935"); err != nil { if err := srv.Start(":1935"); err != nil {
log.Fatalf("Failed to start RTMP server: %v", err) log.Fatalf("Failed to start RTMP server: %v", err)
} }

View File

@@ -2,10 +2,12 @@ package api
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hightube/internal/stream"
) )
// SetupRouter configures the Gin router and defines API endpoints // SetupRouter configures the Gin router and defines API endpoints
func SetupRouter() *gin.Engine { func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
// 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告 // 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -21,6 +23,7 @@ func SetupRouter() *gin.Engine {
r.POST("/api/register", Register) r.POST("/api/register", Register)
r.POST("/api/login", Login) r.POST("/api/login", Login)
r.GET("/api/rooms/active", GetActiveRooms) r.GET("/api/rooms/active", GetActiveRooms)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat // WebSocket endpoint for live chat
r.GET("/api/ws/room/:room_id", WSHandler) r.GET("/api/ws/room/:room_id", WSHandler)

View File

@@ -3,12 +3,15 @@ package stream
import ( import (
"fmt" "fmt"
"io" "io"
"net/http"
"strings" "strings"
"sync" "sync"
"github.com/gin-gonic/gin"
"github.com/nareix/joy4/av/avutil" "github.com/nareix/joy4/av/avutil"
"github.com/nareix/joy4/av/pubsub" "github.com/nareix/joy4/av/pubsub"
"github.com/nareix/joy4/format" "github.com/nareix/joy4/format"
"github.com/nareix/joy4/format/flv"
"github.com/nareix/joy4/format/rtmp" "github.com/nareix/joy4/format/rtmp"
"hightube/internal/chat" "hightube/internal/chat"
@@ -28,6 +31,16 @@ type RTMPServer struct {
mutex sync.RWMutex mutex sync.RWMutex
} }
type writeFlusher struct {
httpFlusher http.Flusher
io.Writer
}
func (w writeFlusher) Flush() error {
w.httpFlusher.Flush()
return nil
}
// NewRTMPServer creates and initializes a new media server // NewRTMPServer creates and initializes a new media server
func NewRTMPServer() *RTMPServer { func NewRTMPServer() *RTMPServer {
s := &RTMPServer{ s := &RTMPServer{
@@ -140,3 +153,46 @@ func (s *RTMPServer) Start(addr string) error {
fmt.Printf("[INFO] RTMP Server is listening on %s...\n", addr) fmt.Printf("[INFO] RTMP Server is listening on %s...\n", addr)
return s.server.ListenAndServe() return s.server.ListenAndServe()
} }
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
s.mutex.RLock()
q, ok := s.channels[streamPath]
s.mutex.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Stream not found or inactive"})
return
}
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming is not supported by the current server"})
return
}
c.Header("Content-Type", "video/x-flv")
c.Header("Transfer-Encoding", "chunked")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
c.Status(http.StatusOK)
flusher.Flush()
muxer := flv.NewMuxerWriteFlusher(writeFlusher{
httpFlusher: flusher,
Writer: c.Writer,
})
cursor := q.Latest()
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
fmt.Printf("[INFO] HTTP-FLV viewer disconnected normally: %s\n", streamPath)
return
}
fmt.Printf("[ERROR] HTTP-FLV playback error on %s: %v\n", streamPath, err)
}
}

View File

@@ -33,12 +33,22 @@ class _HomePageState extends State<HomePage> {
if (isWide) if (isWide)
NavigationRail( NavigationRail(
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => setState(() => _selectedIndex = index), onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: const [
NavigationRailDestination(icon: Icon(Icons.explore), label: Text('Explore')), NavigationRailDestination(
NavigationRailDestination(icon: Icon(Icons.videocam), label: Text('Console')), icon: Icon(Icons.explore),
NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')), label: Text('Explore'),
),
NavigationRailDestination(
icon: Icon(Icons.videocam),
label: Text('Console'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
], ],
), ),
Expanded(child: _pages[_selectedIndex]), Expanded(child: _pages[_selectedIndex]),
@@ -47,11 +57,21 @@ class _HomePageState extends State<HomePage> {
bottomNavigationBar: !isWide bottomNavigationBar: !isWide
? NavigationBar( ? NavigationBar(
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => setState(() => _selectedIndex = index), onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
destinations: const [ destinations: const [
NavigationDestination(icon: Icon(Icons.explore), label: 'Explore'), NavigationDestination(
NavigationDestination(icon: Icon(Icons.videocam), label: 'Console'), icon: Icon(Icons.explore),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), label: 'Explore',
),
NavigationDestination(
icon: Icon(Icons.videocam),
label: 'Console',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
),
], ],
) )
: null, : null,
@@ -100,7 +120,10 @@ class _ExploreViewState extends State<_ExploreView> {
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []); if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []);
} }
} catch (e) { } 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 { } finally {
if (!isAuto && mounted) setState(() => _isLoading = false); if (!isAuto && mounted) setState(() => _isLoading = false);
} }
@@ -114,8 +137,14 @@ class _ExploreViewState extends State<_ExploreView> {
appBar: AppBar( appBar: AppBar(
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)), title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
actions: [ actions: [
IconButton(icon: Icon(Icons.refresh), onPressed: () => _refreshRooms()), IconButton(
IconButton(icon: Icon(Icons.logout), onPressed: () => context.read<AuthProvider>().logout()), icon: Icon(Icons.refresh),
onPressed: () => _refreshRooms(),
),
IconButton(
icon: Icon(Icons.logout),
onPressed: () => context.read<AuthProvider>().logout(),
),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
@@ -128,33 +157,42 @@ class _ExploreViewState extends State<_ExploreView> {
child: _isLoading && _activeRooms.isEmpty child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator()) ? Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty : _activeRooms.isEmpty
? ListView(children: [ ? ListView(
Padding( children: [
padding: EdgeInsets.only(top: 100), Padding(
child: Column( padding: EdgeInsets.only(top: 100),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Icon(Icons.live_tv_outlined, size: 80, color: Colors.grey), children: [
SizedBox(height: 16), Icon(
Text("No active rooms. Be the first!", style: TextStyle(color: Colors.grey, fontSize: 16)), Icons.live_tv_outlined,
], size: 80,
), color: Colors.grey,
) ),
]) SizedBox(height: 16),
: GridView.builder( Text(
padding: EdgeInsets.all(12), "No active rooms. Be the first!",
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( style: TextStyle(color: Colors.grey, fontSize: 16),
maxCrossAxisExtent: 400, ),
childAspectRatio: 1.2, ],
crossAxisSpacing: 12,
mainAxisSpacing: 12,
), ),
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)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}"; final playbackUrl = settings.playbackUrl(room['room_id'].toString());
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PlayerPage( builder: (_) => PlayerPage(
title: room['title'], title: room['title'],
rtmpUrl: rtmpUrl, playbackUrl: playbackUrl,
roomId: room['room_id'].toString(), roomId: room['room_id'].toString(),
), ),
), ),
@@ -188,18 +226,37 @@ class _ExploreViewState extends State<_ExploreView> {
children: [ children: [
Container( Container(
color: Theme.of(context).colorScheme.primaryContainer, 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( Positioned(
top: 8, left: 8, top: 8,
left: 8,
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)), decoration: BoxDecoration(
child: Row(children: [ color: Colors.red,
Icon(Icons.circle, size: 8, color: Colors.white), borderRadius: BorderRadius.circular(4),
SizedBox(width: 4), ),
Text("LIVE", style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), 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( Expanded(
flex: 1, flex: 1,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row( child: Row(
children: [ 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), SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(room['title'], maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), Text(
Text("Host ID: ${room['user_id']}", style: TextStyle(fontSize: 12, color: Colors.grey)), 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/chat_service.dart'; import '../services/chat_service.dart';
import '../widgets/web_stream_player.dart';
class PlayerPage extends StatefulWidget { class PlayerPage extends StatefulWidget {
final String title; final String title;
final String rtmpUrl; final String playbackUrl;
final String roomId; final String roomId;
const PlayerPage({ const PlayerPage({
Key? key, Key? key,
required this.title, required this.title,
required this.rtmpUrl, required this.playbackUrl,
required this.roomId, required this.roomId,
}) : super(key: key); }) : super(key: key);
@@ -23,37 +24,45 @@ class PlayerPage extends StatefulWidget {
} }
class _PlayerPageState extends State<PlayerPage> { class _PlayerPageState extends State<PlayerPage> {
late VideoPlayerController _controller; VideoPlayerController? _controller;
final ChatService _chatService = ChatService(); final ChatService _chatService = ChatService();
final TextEditingController _msgController = TextEditingController(); final TextEditingController _msgController = TextEditingController();
final List<ChatMessage> _messages = []; final List<ChatMessage> _messages = [];
final List<Widget> _danmakus = []; final List<Widget> _danmakus = [];
bool _isError = false; bool _isError = false;
String? _errorMessage; String? _errorMessage;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializePlayer(); if (!kIsWeb) {
_initializePlayer();
}
_initializeChat(); _initializeChat();
} }
void _initializePlayer() async { void _initializePlayer() async {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.rtmpUrl)); _controller = VideoPlayerController.networkUrl(
Uri.parse(widget.playbackUrl),
);
try { try {
await _controller.initialize(); await _controller!.initialize();
_controller.play(); _controller!.play();
if (mounted) setState(() {}); if (mounted) setState(() {});
} catch (e) { } catch (e) {
if (mounted) setState(() { _isError = true; _errorMessage = e.toString(); }); if (mounted)
setState(() {
_isError = true;
_errorMessage = e.toString();
});
} }
} }
void _initializeChat() { void _initializeChat() {
final settings = context.read<SettingsProvider>(); final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
// 使用真实用户名建立连接 // 使用真实用户名建立连接
final currentUsername = auth.username ?? "Guest_${widget.roomId}"; final currentUsername = auth.username ?? "Guest_${widget.roomId}";
_chatService.connect(settings.baseUrl, widget.roomId, currentUsername); _chatService.connect(settings.baseUrl, widget.roomId, currentUsername);
@@ -72,8 +81,8 @@ class _PlayerPageState extends State<PlayerPage> {
void _addDanmaku(String text) { void _addDanmaku(String text) {
final key = UniqueKey(); 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( final danmaku = _DanmakuItem(
key: key, key: key,
text: text, text: text,
@@ -89,14 +98,18 @@ class _PlayerPageState extends State<PlayerPage> {
void _sendMsg() { void _sendMsg() {
if (_msgController.text.isNotEmpty) { if (_msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>(); 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(); _msgController.clear();
} }
} }
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller?.dispose();
_chatService.dispose(); _chatService.dispose();
_msgController.dispose(); _msgController.dispose();
super.dispose(); super.dispose();
@@ -129,7 +142,9 @@ class _PlayerPageState extends State<PlayerPage> {
Container( Container(
width: 350, width: 350,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border(left: BorderSide(color: Theme.of(context).dividerColor)), border: Border(
left: BorderSide(color: Theme.of(context).dividerColor),
),
), ),
child: _buildChatSection(), child: _buildChatSection(),
), ),
@@ -160,18 +175,21 @@ class _PlayerPageState extends State<PlayerPage> {
children: [ children: [
Center( Center(
child: _isError child: _isError
? Text("Error: $_errorMessage", style: TextStyle(color: Colors.white)) ? Text(
: _controller.value.isInitialized "Error: $_errorMessage",
? AspectRatio( style: TextStyle(color: Colors.white),
aspectRatio: _controller.value.aspectRatio, )
child: VideoPlayer(_controller), : kIsWeb
) ? WebStreamPlayer(streamUrl: widget.playbackUrl)
: CircularProgressIndicator(), : _controller != null && _controller!.value.isInitialized
? AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!),
)
: CircularProgressIndicator(),
), ),
// 弹幕层使用 ClipRect 裁剪,防止飘出视频区域 // 弹幕层使用 ClipRect 裁剪,防止飘出视频区域
ClipRect( ClipRect(child: Stack(children: _danmakus)),
child: Stack(children: _danmakus),
),
], ],
); );
} }
@@ -202,11 +220,18 @@ class _PlayerPageState extends State<PlayerPage> {
padding: const EdgeInsets.symmetric(vertical: 4.0), padding: const EdgeInsets.symmetric(vertical: 4.0),
child: RichText( child: RichText(
text: TextSpan( text: TextSpan(
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), style: TextStyle(
color: Theme.of(context).textTheme.bodyMedium?.color,
),
children: [ children: [
TextSpan( TextSpan(
text: "${m.username}: ", 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), TextSpan(text: m.content),
], ],
@@ -226,13 +251,24 @@ class _PlayerPageState extends State<PlayerPage> {
controller: _msgController, controller: _msgController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Send a message...", hintText: "Send a message...",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)), border: OutlineInputBorder(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), borderRadius: BorderRadius.circular(20),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
), ),
onSubmitted: (_) => _sendMsg(), 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 double top;
final VoidCallback onFinished; 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 @override
__DanmakuItemState createState() => __DanmakuItemState(); __DanmakuItemState createState() => __DanmakuItemState();
} }
class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderStateMixin { class __DanmakuItemState extends State<_DanmakuItem>
with SingleTickerProviderStateMixin {
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _animation; late Animation<double> _animation;
@override @override
void initState() { void initState() {
super.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()); _animationController.forward().then((_) => widget.onFinished());
} }
@@ -288,7 +336,13 @@ class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderSt
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 18, fontSize: 18,
shadows: [Shadow(blurRadius: 4, color: Colors.black, offset: Offset(1, 1))], shadows: [
Shadow(
blurRadius: 4,
color: Colors.black,
offset: Offset(1, 1),
),
],
), ),
), ),
); );

View File

@@ -4,8 +4,9 @@ import 'package:flutter/foundation.dart';
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
// Use 10.0.2.2 for Android emulator to access host's localhost // Use 10.0.2.2 for Android emulator to access host's localhost
static String get _defaultUrl => (defaultTargetPlatform == TargetPlatform.android && !kIsWeb) static String get _defaultUrl =>
? "http://10.0.2.2:8080" (defaultTargetPlatform == TargetPlatform.android && !kIsWeb)
? "http://10.0.2.2:8080"
: "http://localhost:8080"; : "http://localhost:8080";
String _baseUrl = _defaultUrl; String _baseUrl = _defaultUrl;
@@ -47,4 +48,12 @@ class SettingsProvider with ChangeNotifier {
final uri = Uri.parse(_baseUrl); final uri = Uri.parse(_baseUrl);
return "rtmp://${uri.host}:1935/live"; return "rtmp://${uri.host}:1935/live";
} }
String playbackUrl(String roomId) {
final uri = Uri.parse(_baseUrl);
if (kIsWeb) {
return uri.replace(path: '/live/$roomId').toString();
}
return "$rtmpUrl/$roomId";
}
} }

View File

@@ -0,0 +1,2 @@
export 'web_stream_player_stub.dart'
if (dart.library.html) 'web_stream_player_web.dart';

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
class WebStreamPlayer extends StatelessWidget {
final String streamUrl;
const WebStreamPlayer({super.key, required this.streamUrl});
@override
Widget build(BuildContext context) {
return const Text(
'Web playback is unavailable on this platform.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
);
}
}

View File

@@ -0,0 +1,38 @@
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
class WebStreamPlayer extends StatefulWidget {
final String streamUrl;
const WebStreamPlayer({super.key, required this.streamUrl});
@override
State<WebStreamPlayer> createState() => _WebStreamPlayerState();
}
class _WebStreamPlayerState extends State<WebStreamPlayer> {
late final String _viewType;
@override
void initState() {
super.initState();
_viewType = 'flv-player-${DateTime.now().microsecondsSinceEpoch}';
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement()
..src = 'flv_player.html?src=${Uri.encodeComponent(widget.streamUrl)}'
..style.border = '0'
..style.width = '100%'
..style.height = '100%'
..allow = 'autoplay; fullscreen';
return iframe;
});
}
@override
Widget build(BuildContext context) {
return HtmlElementView(viewType: _viewType);
}
}

10
frontend/web/flv.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Hightube FLV Player</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
color: #fff;
}
#player,
#message {
width: 100%;
height: 100%;
}
#player {
display: none;
object-fit: contain;
background: #000;
}
#message {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
}
</style>
<script src="flv.min.js"></script>
</head>
<body>
<video id="player" controls autoplay muted playsinline></video>
<div id="message">Loading live stream...</div>
<script>
const params = new URLSearchParams(window.location.search);
const streamUrl = params.get('src');
const video = document.getElementById('player');
const message = document.getElementById('message');
function showMessage(text) {
video.style.display = 'none';
message.style.display = 'flex';
message.textContent = text;
}
if (!streamUrl) {
showMessage('Missing stream URL.');
} else if (typeof flvjs === 'undefined') {
showMessage('flv.js failed to load. Check network access and reload.');
} else if (!flvjs.isSupported()) {
showMessage('This browser does not support FLV playback.');
} else {
const player = flvjs.createPlayer({
type: 'flv',
url: streamUrl,
isLive: true,
}, {
enableWorker: false,
stashInitialSize: 128,
});
player.attachMediaElement(video);
player.load();
player.play().catch(() => {});
player.on(flvjs.Events.ERROR, function(errorType, detail, info) {
const parts = ['Live stream failed to load.'];
if (errorType) parts.push('type=' + errorType);
if (detail) parts.push('detail=' + detail);
if (info && info.msg) parts.push('msg=' + info.msg);
showMessage(parts.join(' '));
});
video.style.display = 'block';
message.style.display = 'none';
window.addEventListener('beforeunload', function() {
player.destroy();
});
}
</script>
</body>
</html>