Add multi-resolution playback support
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,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,16 @@
|
|||||||
package stream
|
package stream
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"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 +32,38 @@ 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
|
||||||
|
internalPublishKey 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"}
|
||||||
|
|
||||||
|
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 +79,29 @@ 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),
|
||||||
|
internalPublishKey: generateInternalPublishKey(),
|
||||||
|
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 +109,40 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
roomIDUint := parseRoomID(roomID)
|
||||||
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
|
if roomIDUint != 0 {
|
||||||
|
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
|
||||||
monitor.Infof("Publishing ended for room_id=%d", room.ID)
|
}
|
||||||
|
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 +198,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 +241,149 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 +392,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
@@ -42,11 +44,13 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
bool _controlsVisible = true;
|
bool _controlsVisible = true;
|
||||||
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,9 +59,8 @@ 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();
|
||||||
_controller!.play();
|
_controller!.play();
|
||||||
@@ -81,6 +84,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 +193,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _loadPlaybackOptions();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRefreshing = true;
|
_isRefreshing = true;
|
||||||
_isError = false;
|
_isError = false;
|
||||||
@@ -223,22 +277,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 +311,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 +438,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
: kIsWeb
|
: kIsWeb
|
||||||
? WebStreamPlayer(
|
? WebStreamPlayer(
|
||||||
key: ValueKey('web-player-$_playerVersion'),
|
key: ValueKey('web-player-$_playerVersion'),
|
||||||
streamUrl: widget.playbackUrl,
|
streamUrl: _currentPlaybackUrl(),
|
||||||
)
|
)
|
||||||
: _controller != null && _controller!.value.isInitialized
|
: _controller != null && _controller!.value.isInitialized
|
||||||
? AspectRatio(
|
? AspectRatio(
|
||||||
|
|||||||
@@ -307,11 +307,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),
|
||||||
|
|||||||
@@ -62,12 +62,26 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user