Add multi-resolution playback support

This commit is contained in:
2026-04-15 11:42:13 +08:00
parent 146f05388e
commit 6eb0baf16e
7 changed files with 337 additions and 48 deletions

View File

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

View File

@@ -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")

View File

@@ -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
} }

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -8,6 +9,7 @@ import 'package:video_player/video_player.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../services/chat_service.dart'; import '../services/chat_service.dart';
import '../widgets/web_stream_player.dart'; import '../widgets/web_stream_player.dart';
@@ -24,7 +26,7 @@ class PlayerPage extends StatefulWidget {
}); });
@override @override
_PlayerPageState createState() => _PlayerPageState(); State<PlayerPage> createState() => _PlayerPageState();
} }
class _PlayerPageState extends State<PlayerPage> { class _PlayerPageState extends State<PlayerPage> {
@@ -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(

View File

@@ -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),

View File

@@ -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) {

View File

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