Compare commits
4 Commits
146f05388e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c5b7451fc6 | |||
| b07f243c88 | |||
| 425ea363f8 | |||
| 6eb0baf16e |
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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})
|
|
||||||
|
|
||||||
// Clear chat history for this room
|
if isSource {
|
||||||
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
|
s.stopVariantTranscoders(roomID)
|
||||||
|
s.stopThumbnailCapture(roomID)
|
||||||
monitor.Infof("Publishing ended for room_id=%d", room.ID)
|
roomIDUint := parseRoomID(roomID)
|
||||||
|
if roomIDUint != 0 {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
470
frontend/lib/widgets/android_quick_stream_panel.dart
Normal file
470
frontend/lib/widgets/android_quick_stream_panel.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user