Compare commits

...

4 Commits

18 changed files with 1341 additions and 133 deletions

View File

@@ -10,7 +10,7 @@ import (
func main() { func main() {
monitor.Init(2000) monitor.Init(2000)
monitor.Infof("Starting Hightube Server v1.0.0-Beta4.1") monitor.Infof("Starting Hightube Server v1.0.0-Beta4.7")
// Initialize Database and run auto-migrations // Initialize Database and run auto-migrations
db.InitDB() db.InitDB()

View File

@@ -48,3 +48,18 @@ func GetActiveRooms(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"active_rooms": result}) c.JSON(http.StatusOK, gin.H{"active_rooms": result})
} }
func GetRoomPlaybackOptions(c *gin.Context) {
roomID := c.Param("room_id")
qualities := []string{"source"}
if adminRTMP != nil {
if available := adminRTMP.AvailablePlaybackQualities(roomID); len(available) > 0 {
qualities = available
}
}
c.JSON(http.StatusOK, gin.H{
"room_id": roomID,
"qualities": qualities,
})
}

View File

@@ -25,6 +25,7 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
r.POST("/api/login", Login) r.POST("/api/login", Login)
r.POST("/api/admin/login", AdminLogin) r.POST("/api/admin/login", AdminLogin)
r.GET("/api/rooms/active", GetActiveRooms) r.GET("/api/rooms/active", GetActiveRooms)
r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV) r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat // WebSocket endpoint for live chat
@@ -37,6 +38,7 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
authGroup.Use(AuthMiddleware()) authGroup.Use(AuthMiddleware())
{ {
authGroup.GET("/room/my", GetMyRoom) authGroup.GET("/room/my", GetMyRoom)
authGroup.GET("/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
authGroup.POST("/user/change-password", ChangePassword) authGroup.POST("/user/change-password", ChangePassword)
adminGroup := authGroup.Group("/admin") adminGroup := authGroup.Group("/admin")

View File

@@ -1,11 +1,18 @@
package stream package stream
import ( import (
"context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/nareix/joy4/av/avutil" "github.com/nareix/joy4/av/avutil"
@@ -27,9 +34,42 @@ func init() {
// RTMPServer manages all active live streams // RTMPServer manages all active live streams
type RTMPServer struct { type RTMPServer struct {
server *rtmp.Server server *rtmp.Server
channels map[string]*pubsub.Queue channels map[string]*pubsub.Queue
mutex sync.RWMutex transcoders map[string][]*variantTranscoder
thumbnailJobs map[string]context.CancelFunc
internalPublishKey string
thumbnailDir string
mutex sync.RWMutex
}
type variantTranscoder struct {
quality string
cancel context.CancelFunc
cmd *exec.Cmd
}
type qualityProfile struct {
scale string
videoBitrate string
audioBitrate string
}
var qualityOrder = []string{"source", "720p", "480p"}
const thumbnailCaptureInterval = 12 * time.Second
var supportedQualities = map[string]qualityProfile{
"720p": {
scale: "1280:-2",
videoBitrate: "2500k",
audioBitrate: "128k",
},
"480p": {
scale: "854:-2",
videoBitrate: "1200k",
audioBitrate: "96k",
},
} }
type writeFlusher struct { type writeFlusher struct {
@@ -45,32 +85,31 @@ func (w writeFlusher) Flush() error {
// 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{
channels: make(map[string]*pubsub.Queue), channels: make(map[string]*pubsub.Queue),
server: &rtmp.Server{}, transcoders: make(map[string][]*variantTranscoder),
thumbnailJobs: make(map[string]context.CancelFunc),
internalPublishKey: generateInternalPublishKey(),
thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"),
server: &rtmp.Server{},
} }
// Triggered when a broadcaster (e.g., OBS) starts publishing // Triggered when a broadcaster (e.g., OBS) starts publishing
s.server.HandlePublish = func(conn *rtmp.Conn) { s.server.HandlePublish = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{stream_key} streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
monitor.Infof("OBS publish attempt: %s", streamPath) monitor.Infof("OBS publish attempt: %s", streamPath)
// Extract stream key from path
parts := strings.Split(streamPath, "/") parts := strings.Split(streamPath, "/")
if len(parts) < 3 || parts[1] != "live" { if len(parts) < 3 {
monitor.Warnf("Invalid publish path format: %s", streamPath) monitor.Warnf("Invalid publish path format: %s", streamPath)
return return
} }
streamKey := parts[2]
// Authenticate stream key roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
var room model.Room if !ok {
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil { monitor.Warnf("Invalid publish key/path: %s", streamPath)
monitor.Warnf("Invalid stream key: %s", streamKey) return
return // Reject connection
} }
monitor.Infof("Stream authenticated for room_id=%d", room.ID)
// 1. Get audio/video stream metadata // 1. Get audio/video stream metadata
streams, err := conn.Streams() streams, err := conn.Streams()
if err != nil { if err != nil {
@@ -78,31 +117,42 @@ func NewRTMPServer() *RTMPServer {
return return
} }
// 2. Map the active stream by Room ID so viewers can use /live/{room_id} monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
s.mutex.Lock() s.mutex.Lock()
q := pubsub.NewQueue() q := pubsub.NewQueue()
q.WriteHeader(streams) q.WriteHeader(streams)
s.channels[roomLivePath] = q s.channels[channelPath] = q
s.mutex.Unlock() s.mutex.Unlock()
// Mark room as active in DB (using map to ensure true/false is correctly updated) if isSource {
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true}) roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
}
s.startVariantTranscoders(roomID)
s.startThumbnailCapture(roomID)
}
// 3. Cleanup on end // 3. Cleanup on end
defer func() { defer func() {
s.mutex.Lock() s.mutex.Lock()
delete(s.channels, roomLivePath) delete(s.channels, channelPath)
s.mutex.Unlock() s.mutex.Unlock()
q.Close() q.Close()
// Explicitly set is_active to false using map
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false}) if isSource {
s.stopVariantTranscoders(roomID)
// Clear chat history for this room s.stopThumbnailCapture(roomID)
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID)) roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
monitor.Infof("Publishing ended for room_id=%d", room.ID) db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
}
chat.MainHub.ClearRoomHistory(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
}
}() }()
// 4. Continuously copy data packets to our broadcast queue // 4. Continuously copy data packets to our broadcast queue
@@ -158,6 +208,9 @@ func (s *RTMPServer) Start(addr string) error {
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients. // HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) { func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id")) streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
if quality := normalizeQuality(c.Query("quality")); quality != "" {
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
}
s.mutex.RLock() s.mutex.RLock()
q, ok := s.channels[streamPath] q, ok := s.channels[streamPath]
@@ -198,10 +251,249 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
} }
} }
func (s *RTMPServer) HandleThumbnail(c *gin.Context) {
thumbnailPath := s.thumbnailPath(c.Param("room_id"))
if _, err := os.Stat(thumbnailPath); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Thumbnail not found"})
return
}
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.File(thumbnailPath)
}
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
if parts[1] == "live" && len(parts) == 3 {
var room model.Room
if err := db.DB.Where("stream_key = ?", parts[2]).First(&room).Error; err != nil {
return "", "", false, false
}
roomID = fmt.Sprintf("%d", room.ID)
channelPath = fmt.Sprintf("/live/%s", roomID)
return roomID, channelPath, true, true
}
if parts[1] == "variant" && len(parts) == 5 {
roomID = parts[2]
quality := normalizeQuality(parts[3])
token := parts[4]
if quality == "" || token != s.internalPublishKey {
return "", "", false, false
}
return roomID, fmt.Sprintf("/live/%s/%s", roomID, quality), false, true
}
return "", "", false, false
}
func (s *RTMPServer) startVariantTranscoders(roomID string) {
s.stopVariantTranscoders(roomID)
launch := make([]*variantTranscoder, 0, len(supportedQualities))
for quality, profile := range supportedQualities {
ctx, cancel := context.WithCancel(context.Background())
inputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID)
outputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/variant/%s/%s/%s", roomID, quality, s.internalPublishKey)
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-nostdin",
"-loglevel", "error",
"-i", inputURL,
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
"-c:v", "libx264",
"-preset", "veryfast",
"-tune", "zerolatency",
"-g", "48",
"-keyint_min", "48",
"-sc_threshold", "0",
"-b:v", profile.videoBitrate,
"-maxrate", profile.videoBitrate,
"-bufsize", profile.videoBitrate,
"-c:a", "aac",
"-b:a", profile.audioBitrate,
"-ar", "44100",
"-ac", "2",
"-f", "flv",
outputURL,
)
transcoder := &variantTranscoder{
quality: quality,
cancel: cancel,
cmd: cmd,
}
launch = append(launch, transcoder)
go func(roomID string, tr *variantTranscoder) {
time.Sleep(2 * time.Second)
monitor.Infof("Starting transcoder room_id=%s quality=%s", roomID, tr.quality)
if err := tr.cmd.Start(); err != nil {
monitor.Errorf("Failed to start transcoder room_id=%s quality=%s: %v", roomID, tr.quality, err)
return
}
if err := tr.cmd.Wait(); err != nil && ctx.Err() == nil {
monitor.Warnf("Transcoder exited room_id=%s quality=%s: %v", roomID, tr.quality, err)
}
}(roomID, transcoder)
}
s.mutex.Lock()
s.transcoders[roomID] = launch
s.mutex.Unlock()
}
func (s *RTMPServer) stopVariantTranscoders(roomID string) {
s.mutex.Lock()
transcoders := s.transcoders[roomID]
delete(s.transcoders, roomID)
s.mutex.Unlock()
for _, transcoder := range transcoders {
transcoder.cancel()
}
}
func (s *RTMPServer) startThumbnailCapture(roomID string) {
s.stopThumbnailCapture(roomID)
if err := os.MkdirAll(s.thumbnailDir, 0o755); err != nil {
monitor.Errorf("Failed to create thumbnail directory: %v", err)
return
}
ctx, cancel := context.WithCancel(context.Background())
s.mutex.Lock()
s.thumbnailJobs[roomID] = cancel
s.mutex.Unlock()
go func() {
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
s.captureThumbnail(roomID)
ticker := time.NewTicker(thumbnailCaptureInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.captureThumbnail(roomID)
}
}
}()
}
func (s *RTMPServer) stopThumbnailCapture(roomID string) {
s.mutex.Lock()
cancel := s.thumbnailJobs[roomID]
delete(s.thumbnailJobs, roomID)
s.mutex.Unlock()
if cancel != nil {
cancel()
}
_ = os.Remove(s.thumbnailPath(roomID))
}
func (s *RTMPServer) captureThumbnail(roomID string) {
outputPath := s.thumbnailPath(roomID)
tempPath := outputPath + ".tmp.jpg"
defer os.Remove(tempPath)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-y",
"-loglevel", "error",
"-rtmp_live", "live",
"-i", fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID),
"-frames:v", "1",
"-q:v", "4",
tempPath,
)
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
monitor.Warnf("Thumbnail capture timed out for room_id=%s", roomID)
return
}
monitor.Warnf("Thumbnail capture failed for room_id=%s: %v", roomID, err)
return
}
_ = os.Remove(outputPath)
if err := os.Rename(tempPath, outputPath); err != nil {
monitor.Warnf("Failed to store thumbnail for room_id=%s: %v", roomID, err)
}
}
func (s *RTMPServer) thumbnailPath(roomID string) string {
return filepath.Join(s.thumbnailDir, fmt.Sprintf("%s.jpg", roomID))
}
func normalizeQuality(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if _, ok := supportedQualities[value]; ok {
return value
}
return ""
}
func parseRoomID(value string) uint {
var roomID uint
_, _ = fmt.Sscanf(value, "%d", &roomID)
return roomID
}
func generateInternalPublishKey() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "internal_publish_fallback_key"
}
return hex.EncodeToString(buf)
}
func (s *RTMPServer) ActiveStreamCount() int { func (s *RTMPServer) ActiveStreamCount() int {
s.mutex.RLock() s.mutex.RLock()
defer s.mutex.RUnlock() defer s.mutex.RUnlock()
return len(s.channels)
count := 0
for path := range s.channels {
if strings.Count(path, "/") == 2 {
count++
}
}
return count
}
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
s.mutex.RLock()
defer s.mutex.RUnlock()
basePath := fmt.Sprintf("/live/%s", roomID)
available := make([]string, 0, len(qualityOrder))
for _, quality := range qualityOrder {
streamPath := basePath
if quality != "source" {
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
}
if _, ok := s.channels[streamPath]; ok {
available = append(available, quality)
}
}
return available
} }
func (s *RTMPServer) ActiveStreamPaths() []string { func (s *RTMPServer) ActiveStreamPaths() []string {
@@ -210,7 +502,9 @@ func (s *RTMPServer) ActiveStreamPaths() []string {
paths := make([]string, 0, len(s.channels)) paths := make([]string, 0, len(s.channels))
for path := range s.channels { for path := range s.channels {
paths = append(paths, path) if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
} }
return paths return paths
} }

View File

@@ -11,12 +11,12 @@ android {
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_17.toString()
} }
defaultConfig { defaultConfig {

View File

@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:label="Hightube" android:label="Hightube"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -10,8 +10,10 @@ import 'player_page.dart';
import 'my_stream_page.dart'; import 'my_stream_page.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key});
@override @override
_HomePageState createState() => _HomePageState(); State<HomePage> createState() => _HomePageState();
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
@@ -21,7 +23,7 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool isWide = MediaQuery.of(context).size.width > 600; bool isWide = MediaQuery.of(context).size.width > 600;
final List<Widget> _pages = [ final List<Widget> pages = [
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)), _ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
MyStreamPage(), MyStreamPage(),
SettingsPage(), SettingsPage(),
@@ -51,7 +53,7 @@ class _HomePageState extends State<HomePage> {
), ),
], ],
), ),
Expanded(child: _pages[_selectedIndex]), Expanded(child: pages[_selectedIndex]),
], ],
), ),
bottomNavigationBar: !isWide bottomNavigationBar: !isWide
@@ -91,6 +93,8 @@ class _ExploreViewState extends State<_ExploreView> {
List<dynamic> _activeRooms = []; List<dynamic> _activeRooms = [];
bool _isLoading = false; bool _isLoading = false;
Timer? _refreshTimer; Timer? _refreshTimer;
String _thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
@override @override
void initState() { void initState() {
@@ -117,18 +121,51 @@ class _ExploreViewState extends State<_ExploreView> {
final response = await api.getActiveRooms(); final response = await api.getActiveRooms();
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []); if (mounted) {
setState(() {
_activeRooms = data['active_rooms'] ?? [];
_thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
});
}
} }
} catch (e) { } catch (e) {
if (!isAuto && mounted) if (!isAuto && mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); ).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
}
} finally { } finally {
if (!isAuto && mounted) setState(() => _isLoading = false); if (!isAuto && mounted) setState(() => _isLoading = false);
} }
} }
Future<void> _confirmLogout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Confirm Logout'),
content: const Text('Are you sure you want to log out now?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Logout'),
),
],
);
},
);
if (confirmed == true && mounted) {
await context.read<AuthProvider>().logout();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
@@ -141,10 +178,7 @@ class _ExploreViewState extends State<_ExploreView> {
icon: Icon(Icons.refresh), icon: Icon(Icons.refresh),
onPressed: () => _refreshRooms(), onPressed: () => _refreshRooms(),
), ),
IconButton( IconButton(icon: Icon(Icons.logout), onPressed: _confirmLogout),
icon: Icon(Icons.logout),
onPressed: () => context.read<AuthProvider>().logout(),
),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
@@ -198,20 +232,21 @@ class _ExploreViewState extends State<_ExploreView> {
} }
Widget _buildRoomCard(dynamic room, SettingsProvider settings) { Widget _buildRoomCard(dynamic room, SettingsProvider settings) {
final roomId = room['room_id'].toString();
return Card( return Card(
elevation: 4, elevation: 4,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
final playbackUrl = settings.playbackUrl(room['room_id'].toString()); final playbackUrl = settings.playbackUrl(roomId);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PlayerPage( builder: (_) => PlayerPage(
title: room['title'], title: room['title'],
playbackUrl: playbackUrl, playbackUrl: playbackUrl,
roomId: room['room_id'].toString(), roomId: roomId,
), ),
), ),
); );
@@ -224,16 +259,37 @@ class _ExploreViewState extends State<_ExploreView> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Container( settings.livePreviewThumbnailsEnabled
color: Theme.of(context).colorScheme.primaryContainer, ? Image.network(
child: Center( settings.thumbnailUrl(
child: Icon( roomId,
Icons.live_tv, cacheBuster: _thumbnailCacheBuster,
size: 50, ),
color: Theme.of(context).colorScheme.primary, fit: BoxFit.cover,
), errorBuilder: (context, error, stackTrace) =>
), _buildRoomPreviewFallback(),
), loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Stack(
fit: StackFit.expand,
children: [
_buildRoomPreviewFallback(),
const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
],
);
},
)
: _buildRoomPreviewFallback(),
Positioned( Positioned(
top: 8, top: 8,
left: 8, left: 8,
@@ -306,4 +362,17 @@ class _ExploreViewState extends State<_ExploreView> {
), ),
); );
} }
Widget _buildRoomPreviewFallback() {
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
} }

View File

@@ -5,10 +5,13 @@ import 'package:flutter/services.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
import '../widgets/android_quick_stream_panel.dart';
class MyStreamPage extends StatefulWidget { class MyStreamPage extends StatefulWidget {
const MyStreamPage({super.key});
@override @override
_MyStreamPageState createState() => _MyStreamPageState(); State<MyStreamPage> createState() => _MyStreamPageState();
} }
class _MyStreamPageState extends State<MyStreamPage> { class _MyStreamPageState extends State<MyStreamPage> {
@@ -29,13 +32,23 @@ class _MyStreamPageState extends State<MyStreamPage> {
try { try {
final response = await api.getMyRoom(); final response = await api.getMyRoom();
if (!mounted) {
return;
}
if (response.statusCode == 200) { if (response.statusCode == 200) {
setState(() => _roomInfo = jsonDecode(response.body)); setState(() => _roomInfo = jsonDecode(response.body));
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to fetch room info"))); if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to fetch room info")));
} finally { } finally {
setState(() => _isLoading = false); if (mounted) {
setState(() => _isLoading = false);
}
} }
} }
@@ -48,59 +61,76 @@ class _MyStreamPageState extends State<MyStreamPage> {
body: _isLoading body: _isLoading
? Center(child: CircularProgressIndicator()) ? Center(child: CircularProgressIndicator())
: _roomInfo == null : _roomInfo == null
? Center(child: Text("No room info found.")) ? Center(child: Text("No room info found."))
: SingleChildScrollView( : SingleChildScrollView(
padding: EdgeInsets.all(20), padding: EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoCard( _buildInfoCard(
title: "Room Title", title: "Room Title",
value: _roomInfo!['title'], value: _roomInfo!['title'],
icon: Icons.edit, icon: Icons.edit,
onTap: () { onTap: () {
// TODO: Implement title update API later // TODO: Implement title update API later
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!"))); ScaffoldMessenger.of(context).showSnackBar(
}, SnackBar(content: Text("Title editing coming soon!")),
), );
SizedBox(height: 20), },
_buildInfoCard(
title: "RTMP Server URL",
value: settings.rtmpUrl,
icon: Icons.copy,
onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL copied to clipboard")));
},
),
SizedBox(height: 20),
_buildInfoCard(
title: "Stream Key (Keep Secret!)",
value: _roomInfo!['stream_key'],
icon: Icons.copy,
isSecret: true,
onTap: () {
Clipboard.setData(ClipboardData(text: _roomInfo!['stream_key']));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Stream Key copied to clipboard")));
},
),
SizedBox(height: 30),
Center(
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(height: 8),
Text(
"Use OBS or other tools to stream to this address.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
],
), ),
), SizedBox(height: 20),
_buildInfoCard(
title: "RTMP Server URL",
value: settings.rtmpUrl,
icon: Icons.copy,
onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Server URL copied to clipboard"),
),
);
},
),
SizedBox(height: 20),
_buildInfoCard(
title: "Stream Key (Keep Secret!)",
value: _roomInfo!['stream_key'],
icon: Icons.copy,
isSecret: true,
onTap: () {
Clipboard.setData(
ClipboardData(text: _roomInfo!['stream_key']),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Stream Key copied to clipboard"),
),
);
},
),
SizedBox(height: 24),
AndroidQuickStreamPanel(
rtmpBaseUrl: settings.rtmpUrl,
streamKey: _roomInfo!['stream_key'],
),
SizedBox(height: 30),
Center(
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(height: 8),
Text(
"Use OBS or other tools to stream to this address.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
],
),
),
); );
} }

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -8,6 +9,7 @@ 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/api_service.dart';
import '../services/chat_service.dart'; import '../services/chat_service.dart';
import '../widgets/web_stream_player.dart'; import '../widgets/web_stream_player.dart';
@@ -24,7 +26,7 @@ class PlayerPage extends StatefulWidget {
}); });
@override @override
_PlayerPageState createState() => _PlayerPageState(); State<PlayerPage> createState() => _PlayerPageState();
} }
class _PlayerPageState extends State<PlayerPage> { class _PlayerPageState extends State<PlayerPage> {
@@ -40,13 +42,16 @@ class _PlayerPageState extends State<PlayerPage> {
bool _isRefreshing = false; bool _isRefreshing = false;
bool _isFullscreen = false; bool _isFullscreen = false;
bool _controlsVisible = true; bool _controlsVisible = true;
double _volume = kIsWeb ? 0.0 : 1.0;
int _playerVersion = 0; int _playerVersion = 0;
String _selectedResolution = 'Source'; String _selectedResolution = 'Source';
List<String> _availableResolutions = const ['Source'];
Timer? _controlsHideTimer; Timer? _controlsHideTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadPlaybackOptions();
if (!kIsWeb) { if (!kIsWeb) {
_initializePlayer(); _initializePlayer();
} }
@@ -55,11 +60,11 @@ class _PlayerPageState extends State<PlayerPage> {
} }
Future<void> _initializePlayer() async { Future<void> _initializePlayer() async {
_controller = VideoPlayerController.networkUrl( final playbackUrl = _currentPlaybackUrl();
Uri.parse(widget.playbackUrl), _controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
);
try { try {
await _controller!.initialize(); await _controller!.initialize();
await _controller!.setVolume(_volume);
_controller!.play(); _controller!.play();
if (mounted) setState(() {}); if (mounted) setState(() {});
} catch (e) { } catch (e) {
@@ -81,6 +86,55 @@ class _PlayerPageState extends State<PlayerPage> {
} }
} }
String _currentPlaybackUrl() {
final settings = context.read<SettingsProvider>();
final quality = _selectedResolution == 'Source'
? null
: _selectedResolution.toLowerCase();
return settings.playbackUrl(widget.roomId, quality: quality);
}
Future<void> _loadPlaybackOptions() async {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
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<String, dynamic>;
final rawQualities =
(data['qualities'] as List<dynamic>? ?? const ['source'])
.map((item) => item.toString().trim().toLowerCase())
.where((item) => item.isNotEmpty)
.toList();
final normalized = <String>['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() { void _initializeChat() {
final settings = context.read<SettingsProvider>(); final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
@@ -141,6 +195,8 @@ class _PlayerPageState extends State<PlayerPage> {
return; return;
} }
await _loadPlaybackOptions();
setState(() { setState(() {
_isRefreshing = true; _isRefreshing = true;
_isError = false; _isError = false;
@@ -200,6 +256,76 @@ class _PlayerPageState extends State<PlayerPage> {
_showControls(); _showControls();
} }
Future<void> _setVolume(double volume) async {
final nextVolume = volume.clamp(0.0, 1.0);
if (!mounted) {
return;
}
setState(() => _volume = nextVolume);
if (!kIsWeb && _controller != null) {
await _controller!.setVolume(nextVolume);
}
}
Future<void> _openVolumeSheet() async {
_showControls();
await showModalBottomSheet<void>(
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
child: StatefulBuilder(
builder: (context, setSheetState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Volume',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
Icon(
_volume == 0
? Icons.volume_off
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
),
Expanded(
child: Slider(
value: _volume,
min: 0,
max: 1,
divisions: 20,
label: '${(_volume * 100).round()}%',
onChanged: (value) {
setSheetState(() => _volume = value);
_setVolume(value);
},
),
),
SizedBox(
width: 48,
child: Text('${(_volume * 100).round()}%'),
),
],
),
],
);
},
),
),
);
},
);
}
void _showControls() { void _showControls() {
_controlsHideTimer?.cancel(); _controlsHideTimer?.cancel();
if (mounted) { if (mounted) {
@@ -223,22 +349,30 @@ class _PlayerPageState extends State<PlayerPage> {
Future<void> _selectResolution() async { Future<void> _selectResolution() async {
_showControls(); _showControls();
await _loadPlaybackOptions();
if (!mounted) {
return;
}
final nextResolution = await showModalBottomSheet<String>( final nextResolution = await showModalBottomSheet<String>(
context: context, context: context,
builder: (context) { builder: (context) {
const options = ['Source', '720p', '480p']; const options = ['Source', '720p', '480p'];
final available = _availableResolutions.toSet();
return SafeArea( return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const ListTile( ListTile(
title: Text('Playback Resolution'), title: Text('Playback Resolution'),
subtitle: Text( subtitle: Text(
'Current backend only provides the source stream. Lower resolutions are reserved for future multi-bitrate output.', available.length > 1
? 'Select an available transcoded stream.'
: 'Only the source stream is available right now.',
), ),
), ),
...options.map((option) { ...options.map((option) {
final enabled = option == 'Source'; final enabled = available.contains(option);
return ListTile( return ListTile(
enabled: enabled, enabled: enabled,
leading: Icon( leading: Icon(
@@ -249,7 +383,7 @@ class _PlayerPageState extends State<PlayerPage> {
title: Text(option), title: Text(option),
subtitle: enabled subtitle: enabled
? const Text('Available now') ? const Text('Available now')
: const Text('Requires backend transcoding support'), : const Text('Waiting for backend transcoding output'),
onTap: enabled ? () => Navigator.pop(context, option) : null, onTap: enabled ? () => Navigator.pop(context, option) : null,
); );
}), }),
@@ -376,7 +510,8 @@ class _PlayerPageState extends State<PlayerPage> {
: kIsWeb : kIsWeb
? WebStreamPlayer( ? WebStreamPlayer(
key: ValueKey('web-player-$_playerVersion'), key: ValueKey('web-player-$_playerVersion'),
streamUrl: widget.playbackUrl, streamUrl: _currentPlaybackUrl(),
volume: _volume,
) )
: _controller != null && _controller!.value.isInitialized : _controller != null && _controller!.value.isInitialized
? AspectRatio( ? AspectRatio(
@@ -488,6 +623,15 @@ class _PlayerPageState extends State<PlayerPage> {
label: "Refresh", label: "Refresh",
onPressed: _refreshPlayer, onPressed: _refreshPlayer,
), ),
_buildControlButton(
icon: _volume == 0
? Icons.volume_off
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
label: "Volume",
onPressed: _openVolumeSheet,
),
_buildControlButton( _buildControlButton(
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off, icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
label: _showDanmaku ? "Danmaku On" : "Danmaku Off", label: _showDanmaku ? "Danmaku On" : "Danmaku Off",

View File

@@ -11,7 +11,7 @@ class SettingsPage extends StatefulWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
_SettingsPageState createState() => _SettingsPageState(); State<SettingsPage> createState() => _SettingsPageState();
} }
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
@@ -88,6 +88,32 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
Future<void> _confirmLogout(AuthProvider auth) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("Confirm Logout"),
content: const Text("Are you sure you want to log out now?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("Logout"),
),
],
);
},
);
if (confirmed == true && mounted) {
await auth.logout();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>(); final auth = context.watch<AuthProvider>();
@@ -221,6 +247,18 @@ class _SettingsPageState extends State<SettingsPage> {
); );
}).toList(), }).toList(),
), ),
const SizedBox(height: 32),
_buildSectionTitle("Explore"),
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text("Live Preview Thumbnails"),
subtitle: const Text(
"Show cached snapshot covers for live rooms when available.",
),
value: settings.livePreviewThumbnailsEnabled,
onChanged: settings.setLivePreviewThumbnailsEnabled,
),
if (isAuthenticated) ...[ if (isAuthenticated) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
_buildSectionTitle("Security"), _buildSectionTitle("Security"),
@@ -266,7 +304,7 @@ class _SettingsPageState extends State<SettingsPage> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: auth.logout, onPressed: () => _confirmLogout(auth),
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
label: const Text("Logout"), label: const Text("Logout"),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -307,11 +345,11 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
Text( Text(
"Version: 1.0.0-beta3.5", "Version: 1.0.0-beta4.1",
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
Text( Text(
"Author: Highground-Soft & Minimax", "Author: Highground-Soft",
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
SizedBox(height: 20), SizedBox(height: 20),

View File

@@ -12,10 +12,12 @@ class SettingsProvider with ChangeNotifier {
String _baseUrl = _defaultUrl; String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue; Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
bool _livePreviewThumbnailsEnabled = false;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
Color get themeColor => _themeColor; Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled;
SettingsProvider() { SettingsProvider() {
_loadSettings(); _loadSettings();
@@ -32,6 +34,8 @@ class SettingsProvider with ChangeNotifier {
if (savedThemeMode != null) { if (savedThemeMode != null) {
_themeMode = _themeModeFromString(savedThemeMode); _themeMode = _themeModeFromString(savedThemeMode);
} }
_livePreviewThumbnailsEnabled =
prefs.getBool('livePreviewThumbnailsEnabled') ?? false;
notifyListeners(); notifyListeners();
} }
@@ -56,18 +60,49 @@ class SettingsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setLivePreviewThumbnailsEnabled(bool enabled) async {
_livePreviewThumbnailsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('livePreviewThumbnailsEnabled', enabled);
notifyListeners();
}
// Also provide the RTMP URL based on the same hostname // Also provide the RTMP URL based on the same hostname
String get rtmpUrl { String get rtmpUrl {
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) { String playbackUrl(String roomId, {String? quality}) {
final uri = Uri.parse(_baseUrl); final uri = Uri.parse(_baseUrl);
final normalizedQuality = quality?.trim().toLowerCase();
if (kIsWeb) { if (kIsWeb) {
return uri.replace(path: '/live/$roomId').toString(); return uri
.replace(
path: '/live/$roomId',
queryParameters:
normalizedQuality == null || normalizedQuality.isEmpty
? null
: {'quality': normalizedQuality},
)
.toString();
} }
return "$rtmpUrl/$roomId";
if (normalizedQuality == null || normalizedQuality.isEmpty) {
return "$rtmpUrl/$roomId";
}
return "$rtmpUrl/$roomId/$normalizedQuality";
}
String thumbnailUrl(String roomId, {String? cacheBuster}) {
final uri = Uri.parse(_baseUrl);
return uri
.replace(
path: '/api/rooms/$roomId/thumbnail',
queryParameters: cacheBuster == null ? null : {'t': cacheBuster},
)
.toString();
} }
ThemeMode _themeModeFromString(String value) { ThemeMode _themeModeFromString(String value) {

View File

@@ -43,11 +43,24 @@ class ApiService {
); );
} }
Future<http.Response> changePassword(String oldPassword, String newPassword) async { Future<http.Response> getPlaybackOptions(String roomId) async {
return await http.get(
Uri.parse("${settings.baseUrl}/api/rooms/$roomId/playback-options"),
headers: _headers,
);
}
Future<http.Response> changePassword(
String oldPassword,
String newPassword,
) async {
return await http.post( return await http.post(
Uri.parse("${settings.baseUrl}/api/user/change-password"), Uri.parse("${settings.baseUrl}/api/user/change-password"),
headers: _headers, headers: _headers,
body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}), body: jsonEncode({
"old_password": oldPassword,
"new_password": newPassword,
}),
); );
} }
} }

View File

@@ -0,0 +1,470 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rtmp_streaming/camera.dart';
class AndroidQuickStreamPanel extends StatefulWidget {
final String rtmpBaseUrl;
final String streamKey;
const AndroidQuickStreamPanel({
super.key,
required this.rtmpBaseUrl,
required this.streamKey,
});
@override
State<AndroidQuickStreamPanel> createState() =>
_AndroidQuickStreamPanelState();
}
class _AndroidQuickStreamPanelState extends State<AndroidQuickStreamPanel> {
final CameraController _controller = CameraController(
ResolutionPreset.medium,
enableAudio: true,
androidUseOpenGL: true,
);
List<CameraDescription> _cameras = const [];
CameraDescription? _currentCamera;
StreamStatistics? _stats;
Timer? _statsTimer;
bool _permissionsGranted = false;
bool _isPreparing = true;
bool _isBusy = false;
bool _audioEnabled = true;
String? _statusMessage;
String get _streamUrl => '${widget.rtmpBaseUrl}/${widget.streamKey}';
bool get _isInitialized => _controller.value.isInitialized ?? false;
bool get _isStreaming => _controller.value.isStreamingVideoRtmp ?? false;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChanged);
_initialize();
}
@override
void dispose() {
_statsTimer?.cancel();
_controller.removeListener(_onControllerChanged);
_controller.dispose();
super.dispose();
}
Future<void> _initialize() async {
if (!mounted) {
return;
}
setState(() {
_isPreparing = true;
_statusMessage = null;
});
final cameraStatus = await Permission.camera.request();
final micStatus = await Permission.microphone.request();
final granted = cameraStatus.isGranted && micStatus.isGranted;
if (!mounted) {
return;
}
if (!granted) {
setState(() {
_permissionsGranted = false;
_isPreparing = false;
_statusMessage =
'Camera and microphone permissions are required for quick streaming.';
});
return;
}
try {
final cameras = await availableCameras();
if (!mounted) {
return;
}
if (cameras.isEmpty) {
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = 'No available cameras were found on this device.';
});
return;
}
_cameras = cameras;
_currentCamera = cameras.first;
await _controller.initialize(_currentCamera!);
if (!mounted) {
return;
}
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = 'Ready to go live.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = e.description ?? e.code;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = 'Failed to initialize camera: $e';
});
}
}
void _onControllerChanged() {
if (!mounted) {
return;
}
final event = _controller.value.event;
final eventType = event is Map ? event['eventType']?.toString() : null;
if (eventType == 'rtmp_stopped' || eventType == 'camera_closing') {
_statsTimer?.cancel();
setState(() {
_statusMessage =
_controller.value.errorDescription ?? 'Streaming stopped.';
});
return;
}
if (eventType == 'error' || eventType == 'rtmp_retry') {
setState(() {
_statusMessage =
_controller.value.errorDescription ?? 'Streaming error occurred.';
});
return;
}
setState(() {});
}
Future<void> _startStreaming() async {
if (!_isInitialized || _isBusy) {
return;
}
setState(() {
_isBusy = true;
_statusMessage = 'Connecting to stream server...';
});
try {
await _controller.startVideoStreaming(_streamUrl, bitrate: 1500 * 1024);
if (_isStreaming && !_audioEnabled) {
await _controller.switchAudio(false);
}
_startStatsPolling();
if (!mounted) {
return;
}
setState(() => _statusMessage = 'Quick stream is live.');
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
Future<void> _stopStreaming() async {
if (!_isStreaming || _isBusy) {
return;
}
setState(() => _isBusy = true);
try {
await _controller.stopVideoStreaming();
_statsTimer?.cancel();
if (!mounted) {
return;
}
setState(() {
_stats = null;
_statusMessage = 'Quick stream stopped.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
Future<void> _switchCamera() async {
if (_cameras.length < 2 || !_isInitialized || _isBusy) {
return;
}
final currentIndex = _cameras.indexOf(_currentCamera!);
final nextCamera = _cameras[(currentIndex + 1) % _cameras.length];
final nextCameraName = nextCamera.name;
if (nextCameraName == null || nextCameraName.isEmpty) {
setState(
() => _statusMessage = 'Unable to switch camera on this device.',
);
return;
}
setState(() => _isBusy = true);
try {
await _controller.switchCamera(nextCameraName);
if (!mounted) {
return;
}
setState(() {
_currentCamera = nextCamera;
_statusMessage = 'Switched camera.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
Future<void> _toggleAudio() async {
final nextAudioEnabled = !_audioEnabled;
if (!_isStreaming) {
setState(() {
_audioEnabled = nextAudioEnabled;
_statusMessage = nextAudioEnabled
? 'Microphone will be enabled when streaming starts.'
: 'Microphone will be muted after streaming starts.';
});
return;
}
setState(() => _isBusy = true);
try {
await _controller.switchAudio(nextAudioEnabled);
if (!mounted) {
return;
}
setState(() {
_audioEnabled = nextAudioEnabled;
_statusMessage = nextAudioEnabled
? 'Microphone enabled.'
: 'Microphone muted.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
void _startStatsPolling() {
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
if (!_isStreaming) {
return;
}
try {
final stats = await _controller.getStreamStatistics();
if (!mounted) {
return;
}
setState(() => _stats = stats);
} catch (_) {
// Ignore transient stats failures while streaming starts up.
}
});
}
@override
Widget build(BuildContext context) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return const SizedBox.shrink();
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.rocket_launch,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'Quick Stream (Experimental)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Start streaming directly from your phone camera. For advanced scenes and overlays, continue using OBS.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
_buildPreview(context),
const SizedBox(height: 12),
_buildStatus(context),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: !_permissionsGranted || _isPreparing || _isBusy
? null
: _isStreaming
? _stopStreaming
: _startStreaming,
icon: Icon(_isStreaming ? Icons.stop_circle : Icons.wifi),
label: Text(_isStreaming ? 'Stop Stream' : 'Start Stream'),
),
OutlinedButton.icon(
onPressed: _cameras.length > 1 && !_isBusy && _isInitialized
? _switchCamera
: null,
icon: const Icon(Icons.cameraswitch),
label: const Text('Switch Camera'),
),
OutlinedButton.icon(
onPressed: _isBusy || !_permissionsGranted
? null
: _toggleAudio,
icon: Icon(_audioEnabled ? Icons.mic : Icons.mic_off),
label: Text(_audioEnabled ? 'Mic On' : 'Mic Off'),
),
TextButton.icon(
onPressed: _isBusy ? null : _initialize,
icon: const Icon(Icons.refresh),
label: const Text('Reinitialize'),
),
],
),
const SizedBox(height: 12),
Text(
'Target: $_streamUrl',
style: Theme.of(context).textTheme.bodySmall,
),
if (_stats != null) ...[
const SizedBox(height: 8),
Text(
'Stats: ${_stats!.width ?? '-'}x${_stats!.height ?? '-'} | ${_stats!.fps ?? '-'} fps | ${_formatKbps(_stats!.bitrate)} | dropped ${_stats!.droppedVideoFrames ?? 0} video',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
);
}
Widget _buildPreview(BuildContext context) {
return Container(
height: 220,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: _isPreparing
? const Center(child: CircularProgressIndicator())
: !_permissionsGranted
? _buildPreviewMessage('Grant camera and microphone permissions.')
: !_isInitialized
? _buildPreviewMessage('Camera is not ready yet.')
: AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: CameraPreview(_controller),
),
);
}
Widget _buildPreviewMessage(String text) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(
text,
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildStatus(BuildContext context) {
final statusText =
_statusMessage ??
(_isStreaming ? 'Quick stream is live.' : 'Waiting for camera.');
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
_isStreaming ? Icons.circle : Icons.info_outline,
size: _isStreaming ? 12 : 18,
color: _isStreaming ? Colors.red : null,
),
const SizedBox(width: 10),
Expanded(child: Text(statusText)),
],
),
);
}
String _formatKbps(int? bitrate) {
if (bitrate == null || bitrate <= 0) {
return '- kbps';
}
return '${(bitrate / 1000).toStringAsFixed(0)} kbps';
}
}

View File

@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
class WebStreamPlayer extends StatelessWidget { class WebStreamPlayer extends StatelessWidget {
final String streamUrl; final String streamUrl;
final double volume;
final int? refreshToken; final int? refreshToken;
const WebStreamPlayer({ const WebStreamPlayer({
super.key, super.key,
required this.streamUrl, required this.streamUrl,
required this.volume,
this.refreshToken, this.refreshToken,
}); });

View File

@@ -1,3 +1,5 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html; import 'dart:html' as html;
import 'dart:ui_web' as ui_web; import 'dart:ui_web' as ui_web;
@@ -5,11 +7,13 @@ import 'package:flutter/material.dart';
class WebStreamPlayer extends StatefulWidget { class WebStreamPlayer extends StatefulWidget {
final String streamUrl; final String streamUrl;
final double volume;
final int? refreshToken; final int? refreshToken;
const WebStreamPlayer({ const WebStreamPlayer({
super.key, super.key,
required this.streamUrl, required this.streamUrl,
required this.volume,
this.refreshToken, this.refreshToken,
}); });
@@ -19,6 +23,7 @@ class WebStreamPlayer extends StatefulWidget {
class _WebStreamPlayerState extends State<WebStreamPlayer> { class _WebStreamPlayerState extends State<WebStreamPlayer> {
late final String _viewType; late final String _viewType;
html.IFrameElement? _iframe;
@override @override
void initState() { void initState() {
@@ -29,15 +34,30 @@ class _WebStreamPlayerState extends State<WebStreamPlayer> {
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) { ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement() final iframe = html.IFrameElement()
..src = ..src =
'flv_player.html?v=$cacheBuster&src=${Uri.encodeComponent(widget.streamUrl)}' 'flv_player.html?v=$cacheBuster'
'&src=${Uri.encodeComponent(widget.streamUrl)}'
'&volume=${widget.volume}'
..style.border = '0' ..style.border = '0'
..style.width = '100%' ..style.width = '100%'
..style.height = '100%' ..style.height = '100%'
..style.pointerEvents = 'none'
..allow = 'autoplay; fullscreen'; ..allow = 'autoplay; fullscreen';
_iframe = iframe;
return iframe; return iframe;
}); });
} }
@override
void didUpdateWidget(covariant WebStreamPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.volume != widget.volume) {
_iframe?.contentWindow?.postMessage({
'type': 'setVolume',
'value': widget.volume,
}, '*');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return HtmlElementView(viewType: _viewType); return HtmlElementView(viewType: _viewType);

View File

@@ -368,6 +368,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -416,6 +464,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
rtmp_streaming:
dependency: "direct main"
description:
name: rtmp_streaming
sha256: f54c0c0443df65086d2936b0f3432fbb351fc35bffba69aa2b004ee7ecf45d40
url: "https://pub.dev"
source: hosted
version: "1.0.5"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0-beta4.1 version: 1.0.0-beta4.7
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1
@@ -40,6 +40,8 @@ dependencies:
video_player: ^2.11.1 video_player: ^2.11.1
fvp: ^0.35.2 fvp: ^0.35.2
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
permission_handler: ^12.0.1
rtmp_streaming: ^1.0.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -45,14 +45,21 @@
<script src="flv.min.js"></script> <script src="flv.min.js"></script>
</head> </head>
<body> <body>
<video id="player" controls autoplay muted playsinline></video> <video id="player" autoplay muted playsinline></video>
<div id="message">Loading live stream...</div> <div id="message">Loading live stream...</div>
<script> <script>
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const streamUrl = params.get('src'); const streamUrl = params.get('src');
const initialVolume = Number.parseFloat(params.get('volume') || '1');
const video = document.getElementById('player'); const video = document.getElementById('player');
const message = document.getElementById('message'); const message = document.getElementById('message');
function applyVolume(value) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1;
video.volume = normalized;
video.muted = normalized === 0;
}
function showMessage(text) { function showMessage(text) {
video.style.display = 'none'; video.style.display = 'none';
message.style.display = 'flex'; message.style.display = 'flex';
@@ -66,6 +73,8 @@
} else if (!flvjs.isSupported()) { } else if (!flvjs.isSupported()) {
showMessage('This browser does not support FLV playback.'); showMessage('This browser does not support FLV playback.');
} else { } else {
applyVolume(initialVolume);
const player = flvjs.createPlayer({ const player = flvjs.createPlayer({
type: 'flv', type: 'flv',
url: streamUrl, url: streamUrl,
@@ -90,6 +99,13 @@
video.style.display = 'block'; video.style.display = 'block';
message.style.display = 'none'; message.style.display = 'none';
window.addEventListener('message', function(event) {
const data = event.data || {};
if (data.type === 'setVolume') {
applyVolume(Number(data.value));
}
});
window.addEventListener('beforeunload', function() { window.addEventListener('beforeunload', function() {
player.destroy(); player.destroy();
}); });