Compare commits

...

3 Commits

Author SHA1 Message Date
6eb0baf16e Add multi-resolution playback support 2026-04-15 11:42:13 +08:00
146f05388e Support Enter to submit login 2026-04-15 11:20:30 +08:00
98666ab1ea Rework admin console authentication and UI 2026-04-15 11:10:52 +08:00
14 changed files with 1006 additions and 323 deletions

View File

@@ -10,7 +10,7 @@ import (
func main() {
monitor.Init(2000)
monitor.Infof("Starting Hightube Server v1.0.0-Beta3.7")
monitor.Infof("Starting Hightube Server v1.0.0-Beta4.1")
// Initialize Database and run auto-migrations
db.InitDB()
@@ -24,6 +24,7 @@ func main() {
go func() {
r := api.SetupRouter(srv)
monitor.Infof("API server listening on :8080")
monitor.Infof("Web console listening on :8080/admin")
if err := r.Run(":8080"); err != nil {
monitor.Errorf("Failed to start API server: %v", err)
}

View File

@@ -22,6 +22,64 @@ func BindAdminDependencies(rtmpSrv *stream.RTMPServer) {
adminRTMP = rtmpSrv
}
func AdminLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user model.User
if err := db.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
if !utils.CheckPasswordHash(req.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
if !user.Enabled {
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
return
}
if user.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"})
return
}
c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true)
monitor.Auditf("admin=%s signed in", user.Username)
c.JSON(http.StatusOK, gin.H{
"username": user.Username,
"role": user.Role,
})
}
func AdminLogout(c *gin.Context) {
operator, _ := c.Get("username")
c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true)
monitor.Auditf("admin=%v signed out", operator)
c.JSON(http.StatusOK, gin.H{"message": "signed out"})
}
func GetAdminSession(c *gin.Context) {
username, _ := c.Get("username")
role, _ := c.Get("role")
c.JSON(http.StatusOK, gin.H{
"username": username,
"role": role,
})
}
func GetAdminOverview(c *gin.Context) {
stats := monitor.GetSnapshot()
chatStats := chat.StatsSnapshot{}
@@ -83,10 +141,6 @@ func ListAdminLogs(c *gin.Context) {
}
func StreamAdminLogs(c *gin.Context) {
if !authorizeAdminTokenFromQuery(c) {
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
@@ -114,39 +168,6 @@ func StreamAdminLogs(c *gin.Context) {
}
}
func authorizeAdminTokenFromQuery(c *gin.Context) bool {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token is required"})
return false
}
claims, err := utils.ParseToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return false
}
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token subject"})
return false
}
var user model.User
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return false
}
if !user.Enabled || user.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return false
}
return true
}
type updateRoleRequest struct {
Role string `json:"role" binding:"required"`
}

View File

@@ -2,6 +2,8 @@ package api
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
@@ -32,6 +34,12 @@ func Register(c *gin.Context) {
return
}
req.Username = strings.TrimSpace(req.Username)
if strings.EqualFold(req.Username, bootstrapAdminUsername()) {
c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"})
return
}
// Check if user exists
var existingUser model.User
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
@@ -146,3 +154,11 @@ func ChangePassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}
func bootstrapAdminUsername() string {
adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER"))
if adminUsername == "" {
return "admin"
}
return adminUsername
}

View File

@@ -1,6 +1,7 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -13,47 +14,30 @@ import (
"hightube/internal/utils"
)
const adminSessionCookieName = "hightube_admin_session"
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
c.Abort()
return
}
tokenStr := parts[1]
claims, err := utils.ParseToken(tokenStr)
user, err := authenticateRequest(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
switch {
case errors.Is(err, errMissingToken):
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
case errors.Is(err, errInvalidToken):
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
case errors.Is(err, errUserNotFound):
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
case errors.Is(err, errDisabledAccount):
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
default:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
}
c.Abort()
return
}
userID, _ := strconv.ParseUint(claims.Subject, 10, 32)
var user model.User
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
c.Abort()
return
}
if !user.Enabled {
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
c.Abort()
return
}
c.Set("user_id", uint(userID))
c.Set("user_id", user.ID)
c.Set("username", user.Username)
c.Set("role", user.Role)
c.Next()
@@ -82,6 +66,58 @@ func RequestMetricsMiddleware() gin.HandlerFunc {
}
}
var (
errMissingToken = errors.New("missing token")
errInvalidToken = errors.New("invalid token")
errUserNotFound = errors.New("user not found")
errDisabledAccount = errors.New("disabled account")
)
func authenticateRequest(c *gin.Context) (*model.User, error) {
tokenStr := extractToken(c)
if tokenStr == "" {
return nil, errMissingToken
}
claims, err := utils.ParseToken(tokenStr)
if err != nil {
return nil, errInvalidToken
}
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
if err != nil {
return nil, errInvalidToken
}
var user model.User
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
return nil, errUserNotFound
}
if !user.Enabled {
return nil, errDisabledAccount
}
return &user, nil
}
func extractToken(c *gin.Context) string {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if authHeader != "" {
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
return strings.TrimSpace(parts[1])
}
}
cookieToken, err := c.Cookie(adminSessionCookieName)
if err == nil {
return strings.TrimSpace(cookieToken)
}
return ""
}
// CORSMiddleware handles cross-origin requests from web clients
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -48,3 +48,18 @@ func GetActiveRooms(c *gin.Context) {
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

@@ -23,27 +23,31 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
// Public routes
r.POST("/api/register", Register)
r.POST("/api/login", Login)
r.POST("/api/admin/login", AdminLogin)
r.GET("/api/rooms/active", GetActiveRooms)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat
r.GET("/api/ws/room/:room_id", WSHandler)
r.GET("/admin", AdminPage)
r.GET("/api/admin/logs/stream", StreamAdminLogs)
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
// Protected routes (require JWT)
authGroup := r.Group("/api")
authGroup.Use(AuthMiddleware())
{
authGroup.GET("/room/my", GetMyRoom)
authGroup.GET("/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
authGroup.POST("/user/change-password", ChangePassword)
adminGroup := authGroup.Group("/admin")
adminGroup.Use(AdminMiddleware())
{
adminGroup.GET("/session", GetAdminSession)
adminGroup.GET("/overview", GetAdminOverview)
adminGroup.GET("/health", GetAdminHealth)
adminGroup.GET("/logs", ListAdminLogs)
adminGroup.POST("/logout", AdminLogout)
adminGroup.GET("/users", ListUsers)
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -9,217 +9,431 @@
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0f1c2e;
--bg2: #102944;
--card: #f7f6f3;
--ink: #12263d;
--accent: #ff6b35;
--accent2: #0ea5a3;
--bg: #eef4fb;
--bg-accent: #dbe9f7;
--surface: #ffffff;
--surface-soft: #f6f9fc;
--ink: #19324d;
--muted: #5a718a;
--line: #d7e1eb;
--primary: #2f6fed;
--primary-strong: #1d4ed8;
--primary-soft: #dce8ff;
--danger: #dc2626;
--ok: #15803d;
--line: #d9d5cc;
--danger-soft: #fee2e2;
--success: #15803d;
--success-soft: #dcfce7;
--warning: #b45309;
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(1200px 600px at -10% -10%, #2a4b70 0%, transparent 65%),
radial-gradient(1000px 500px at 110% 0%, #1f4f6d 0%, transparent 65%),
linear-gradient(140deg, var(--bg) 0%, var(--bg2) 100%);
min-height: 100vh;
padding: 20px;
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
padding: 24px;
}
.layout {
.shell {
max-width: 1280px;
margin: 0 auto;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.brand h1 {
margin: 0;
font-size: clamp(28px, 5vw, 42px);
letter-spacing: 0.03em;
}
.brand p {
margin: 8px 0 0;
color: var(--muted);
max-width: 620px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.78);
border: 1px solid rgba(47,111,237,0.12);
box-shadow: var(--shadow);
color: var(--primary-strong);
font-weight: 700;
}
.panel-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(12, 1fr);
animation: appear 500ms ease-out;
}
@keyframes appear {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
gap: 16px;
}
.card {
background: var(--card);
background: rgba(255,255,255,0.88);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: 0 12px 30px rgba(0,0,0,0.22);
padding: 14px;
border-radius: 20px;
padding: 18px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.login-card {
grid-column: 4 / span 6;
padding: 22px;
}
.full { grid-column: 1 / -1; }
.col4 { grid-column: span 4; }
.col6 { grid-column: span 6; }
.col5 { grid-column: span 5; }
.col7 { grid-column: span 7; }
.col8 { grid-column: span 8; }
h1 { margin: 0; color: #f8fafc; letter-spacing: .02em; }
h2 { margin: 0 0 10px; font-size: 18px; }
.topbar {
grid-column: 1 / -1;
h2 {
margin: 0 0 12px;
font-size: 19px;
}
.muted {
color: var(--muted);
}
.hidden {
display: none !important;
}
.stack {
display: grid;
gap: 12px;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.row.between {
justify-content: space-between;
margin-bottom: 6px;
}
.tokenbox {
display: flex;
gap: 8px;
width: min(800px, 100%);
}
input, select, button {
border-radius: 10px;
border: 1px solid #c8c2b7;
padding: 8px 10px;
border-radius: 14px;
border: 1px solid var(--line);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
input { width: 100%; }
input, select {
width: 100%;
background: var(--surface);
color: var(--ink);
}
button {
cursor: pointer;
color: #fff;
background: linear-gradient(120deg, var(--accent), #f97316);
border: none;
color: white;
background: linear-gradient(120deg, var(--primary), #5994ff);
font-weight: 700;
transition: transform 140ms ease, opacity 140ms ease;
}
button:hover {
transform: translateY(-1px);
}
button.secondary {
background: #184f77;
background: linear-gradient(120deg, #7ea7ff, #4b7df2);
}
button.subtle {
color: var(--ink);
background: var(--surface-soft);
border: 1px solid var(--line);
}
button.danger {
background: var(--danger);
background: linear-gradient(120deg, #ef4444, #dc2626);
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.metric {
border: 1px dashed #c9c2b4;
padding: 8px;
border-radius: 12px;
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
}
.metric .k { font-size: 12px; opacity: .75; }
.metric .v { font-size: 22px; font-weight: 700; }
.mono { font-family: "IBM Plex Mono", monospace; font-size: 12px; }
#logs {
height: 280px;
overflow: auto;
background: #0e1624;
color: #d9f6ea;
border-radius: 10px;
padding: 10px;
white-space: pre-wrap;
.metric .k {
font-size: 12px;
color: var(--muted);
}
.metric .v {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
}
.health-strip {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.health-chip {
min-width: 120px;
padding: 10px 12px;
border-radius: 14px;
background: var(--surface-soft);
border: 1px solid var(--line);
}
.health-chip .k {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
}
.mono {
font-family: "IBM Plex Mono", monospace;
border: 1px solid #1f3552;
font-size: 12px;
}
#logs {
height: 300px;
overflow: auto;
background: #f5f9ff;
color: #17304d;
border-radius: 16px;
padding: 12px;
white-space: pre-wrap;
border: 1px solid var(--line);
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 8px 6px;
padding: 10px 8px;
vertical-align: top;
}
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pill {
display: inline-block;
padding: 2px 8px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.pill.ok { background: #dcfce7; color: var(--ok); }
.pill.off { background: #fee2e2; color: var(--danger); }
.pill.admin { background: #dbeafe; color: #1d4ed8; }
.pill.user { background: #e2e8f0; color: #334155; }
.pill.ok {
background: var(--success-soft);
color: var(--success);
}
.pill.off {
background: var(--danger-soft);
color: var(--danger);
}
.pill.admin {
background: var(--primary-soft);
color: var(--primary-strong);
}
.pill.user {
background: #edf2f7;
color: #475569;
}
.notice {
padding: 12px 14px;
border-radius: 14px;
background: #fff7ed;
border: 1px solid #fed7aa;
color: var(--warning);
font-size: 13px;
}
.session-info {
color: var(--muted);
font-size: 13px;
}
@media (max-width: 960px) {
.col4, .col6, .col8 { grid-column: 1 / -1; }
.stats { grid-template-columns: repeat(2, 1fr); }
.topbar { flex-direction: column; align-items: stretch; }
.login-card, .col4, .col5, .col7, .col8 {
grid-column: 1 / -1;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.health-strip {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.hero {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="layout">
<div class="topbar">
<h1>Hightube Admin Console</h1>
<div class="tokenbox">
<input id="token" placeholder="粘贴 admin JWT token来自 /api/login" />
<button onclick="connectAll()">连接</button>
<div class="shell">
<div class="hero">
<div class="brand">
<div class="badge">Admin Panel</div>
<h1>Hightube Control Console</h1>
<p>Lightweight operations dashboard for stream status, runtime health, audit logs, and account management.</p>
</div>
<div class="session-info" id="sessionInfo">Not signed in</div>
</div>
<div class="panel-grid" id="loginView">
<div class="card login-card">
<h2>Admin Sign In</h2>
<p class="muted">Use the administrator account to access monitoring, logs, and user controls. Default bootstrap credentials are <b>admin / admin</b> unless changed by environment variables.</p>
<div class="stack">
<input id="loginUsername" placeholder="Admin username" value="admin" />
<input id="loginPassword" type="password" placeholder="Password" value="admin" />
<div class="row">
<button onclick="login()">Sign In</button>
</div>
<div class="notice">
Change the default admin password immediately after first login.
</div>
</div>
</div>
</div>
<div class="card col8">
<h2>系统状态</h2>
<div class="stats" id="stats"></div>
<div class="mono" id="health"></div>
</div>
<div class="card col4">
<h2>在线状态</h2>
<div id="online"></div>
</div>
<div class="card col8">
<h2>实时日志</h2>
<div style="display:flex; gap:8px; margin-bottom:8px;">
<button class="secondary" onclick="loadHistory()">加载历史日志</button>
<select id="logLevel">
<option value="">全部级别</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
<option value="audit">audit</option>
</select>
<input id="logKeyword" placeholder="关键词过滤" />
<div class="panel-grid hidden" id="appView">
<div class="card full">
<div class="row between">
<div>
<h2 style="margin-bottom:6px;">Session</h2>
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
</div>
<div class="row">
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
<button class="danger" onclick="logout()">Sign Out</button>
</div>
</div>
</div>
<div id="logs"></div>
</div>
<div class="card col4">
<h2>操作说明</h2>
<ul>
<li>先在上方输入 admin token 并点击连接。</li>
<li>用户管理支持搜索、角色切换、启用禁用、重置密码、删除。</li>
<li>日志区会持续接收后端实时事件。</li>
</ul>
</div>
<div class="card full">
<h2>用户管理</h2>
<div style="display:flex; gap:8px; margin-bottom:8px;">
<input id="userKeyword" placeholder="按用户名搜索" />
<button class="secondary" onclick="loadUsers()">查询</button>
<div class="card col7">
<h2>System Overview</h2>
<div class="stats" id="stats"></div>
</div>
<div class="card col5">
<h2>Admin Password</h2>
<div class="stack">
<input id="oldPassword" type="password" placeholder="Current password" />
<input id="newPassword" type="password" placeholder="New password" />
<div class="row">
<button onclick="changePassword()">Update Password</button>
</div>
</div>
</div>
<div class="card col4">
<h2>Live Status</h2>
<div id="online"></div>
<div class="health-strip" id="health" style="margin-top:14px;"></div>
</div>
<div class="card col8">
<div class="row between" style="margin-bottom:10px;">
<h2 style="margin:0;">Audit and Runtime Logs</h2>
<div class="row">
<select id="logLevel">
<option value="">All levels</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
<option value="audit">audit</option>
</select>
<input id="logKeyword" placeholder="Filter keyword" style="width:220px;" />
<button class="secondary" onclick="loadHistory()">Load History</button>
</div>
</div>
<div id="logs"></div>
</div>
<div class="card full">
<div class="row between" style="margin-bottom:10px;">
<h2 style="margin:0;">User Management</h2>
<div class="row">
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
<button class="secondary" onclick="loadUsers()">Search</button>
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</div>
<script>
let evt = null;
let overviewTimer = null;
let healthTimer = null;
let currentAdmin = null;
function authHeaders() {
const token = document.getElementById('token').value.trim();
return {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
};
function setSessionText(text) {
document.getElementById('sessionInfo').textContent = text;
}
function addLogLine(text) {
@@ -228,62 +442,144 @@
box.scrollTop = box.scrollHeight;
}
async function connectAll() {
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
});
let data = null;
const text = await response.text();
if (text) {
try {
data = JSON.parse(text);
} catch (_) {
data = { raw: text };
}
}
if (!response.ok) {
const message = (data && (data.error || data.message)) || `Request failed (${response.status})`;
throw new Error(message);
}
return data;
}
function clearPolling() {
if (overviewTimer) clearInterval(overviewTimer);
if (healthTimer) clearInterval(healthTimer);
overviewTimer = null;
healthTimer = null;
}
function disconnectLogs() {
if (evt) {
evt.close();
evt = null;
}
}
function showLogin() {
clearPolling();
disconnectLogs();
currentAdmin = null;
setSessionText('Not signed in');
document.getElementById('loginView').classList.remove('hidden');
document.getElementById('appView').classList.add('hidden');
}
function showApp(session) {
currentAdmin = session;
setSessionText(`Signed in as ${session.username}`);
document.getElementById('loginView').classList.add('hidden');
document.getElementById('appView').classList.remove('hidden');
}
async function ensureSession() {
try {
const session = await api('/api/admin/session');
showApp(session);
await refreshAll();
connectLiveLogs();
clearPolling();
overviewTimer = setInterval(loadOverview, 1000);
healthTimer = setInterval(loadHealth, 1000);
} catch (_) {
showLogin();
}
}
async function login() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
try {
await api('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
document.getElementById('loginPassword').value = '';
await ensureSession();
} catch (error) {
alert(error.message);
}
}
async function logout() {
try {
await api('/api/admin/logout', { method: 'POST' });
} catch (_) {
} finally {
showLogin();
}
}
async function refreshAll() {
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
connectLiveLogs();
setInterval(loadOverview, 1000);
setInterval(loadHealth, 1000);
}
async function loadOverview() {
const resp = await fetch('/api/admin/overview', { headers: authHeaders() });
if (!resp.ok) {
addLogLine('[error] 概览拉取失败,请检查 token 是否为 admin。');
return;
}
const data = await resp.json();
const data = await api('/api/admin/overview');
const sys = data.system || {};
const stream = data.stream || {};
const chat = data.chat || {};
document.getElementById('stats').innerHTML = `
<div class="metric"><div class="k">运行时长(秒)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
<div class="metric"><div class="k">请求总量</div><div class="v">${sys.requests_total ?? '-'}</div></div>
<div class="metric"><div class="k">错误总量</div><div class="v">${sys.errors_total ?? '-'}</div></div>
<div class="metric"><div class="k">Uptime (s)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
<div class="metric"><div class="k">Requests</div><div class="v">${sys.requests_total ?? '-'}</div></div>
<div class="metric"><div class="k">Errors</div><div class="v">${sys.errors_total ?? '-'}</div></div>
<div class="metric"><div class="k">Goroutines</div><div class="v">${sys.goroutines ?? '-'}</div></div>
<div class="metric"><div class="k">内存Alloc(MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">内存Sys(MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">CPU核心数</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
<div class="metric"><div class="k">磁盘剩余/总量(GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">Alloc (MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">System Mem (MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">CPU Cores</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
<div class="metric"><div class="k">Disk Free / Total (GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
`;
document.getElementById('online').innerHTML = `
<p>活跃流数量:<b>${stream.active_stream_count ?? 0}</b></p>
<p>活跃聊天室:<b>${chat.room_count ?? 0}</b></p>
<p>在线聊天连接:<b>${chat.total_connected_client ?? 0}</b></p>
<div class="mono">流路径: ${(stream.active_stream_paths || []).join(', ') || ''}</div>
<p>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
`;
}
async function loadHealth() {
const resp = await fetch('/api/admin/health', { headers: authHeaders() });
if (!resp.ok) return;
const h = await resp.json();
const h = await api('/api/admin/health');
const dbOk = h.db && h.db.ok;
document.getElementById('health').innerHTML =
`API: <span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span> ` +
`RTMP: <span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span> ` +
`DB: <span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span>`;
`<div class="health-chip"><div class="k">API</div><span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span></div>` +
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
}
async function loadHistory() {
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
const resp = await fetch(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`, {
headers: authHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
const items = data.items || [];
const box = document.getElementById('logs');
box.textContent = '';
@@ -291,32 +587,24 @@
}
function connectLiveLogs() {
if (evt) evt.close();
const token = document.getElementById('token').value.trim();
evt = new EventSource('/api/admin/logs/stream?token=' + encodeURIComponent(token));
disconnectLogs();
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
evt.onmessage = () => {};
evt.addEventListener('log', (e) => {
const item = JSON.parse(e.data);
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
});
evt.onerror = () => {
addLogLine('[warn] 实时日志连接断开,稍后可重连。');
addLogLine('[warn] Live log stream disconnected.');
};
}
async function loadUsers() {
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
const resp = await fetch(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`, {
headers: authHeaders()
});
if (!resp.ok) {
addLogLine('[error] 用户列表拉取失败。');
return;
}
const data = await resp.json();
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
const body = document.getElementById('usersBody');
body.innerHTML = '';
(data.items || []).forEach(u => {
(data.items || []).forEach((u) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id}</td>
@@ -325,10 +613,10 @@
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
<td>${new Date(u.created_at).toLocaleString()}</td>
<td class="actions">
<button class="secondary" onclick="toggleRole(${u.id}, '${u.role}')">切换角色</button>
<button class="secondary" onclick="toggleEnabled(${u.id}, ${u.enabled})">启用/禁用</button>
<button class="secondary" onclick="resetPwd(${u.id})">重置密码</button>
<button class="danger" onclick="deleteUser(${u.id})">删除</button>
<button class="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
</td>
`;
body.appendChild(tr);
@@ -337,42 +625,53 @@
async function toggleRole(id, role) {
const next = role === 'admin' ? 'user' : 'admin';
await fetch(`/api/admin/users/${id}/role`, {
await api(`/api/admin/users/${id}/role`, {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify({ role: next })
body: JSON.stringify({ role: next }),
});
loadUsers();
await loadUsers();
}
async function toggleEnabled(id, enabled) {
await fetch(`/api/admin/users/${id}/enabled`, {
await api(`/api/admin/users/${id}/enabled`, {
method: 'PATCH',
headers: authHeaders(),
body: JSON.stringify({ enabled: !enabled })
body: JSON.stringify({ enabled: !enabled }),
});
loadUsers();
await loadUsers();
}
async function resetPwd(id) {
const newPwd = prompt('输入新密码(至少 6 位)');
const newPwd = prompt('Enter a new password for this user');
if (!newPwd) return;
await fetch(`/api/admin/users/${id}/reset-password`, {
await api(`/api/admin/users/${id}/reset-password`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ new_password: newPwd })
body: JSON.stringify({ new_password: newPwd }),
});
addLogLine(`[audit] user ${id} password reset requested`);
addLogLine(`[audit] password reset requested for user ${id}`);
}
async function deleteUser(id) {
if (!confirm('确认删除该用户?')) return;
await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
headers: authHeaders()
});
loadUsers();
if (!confirm('Delete this user account?')) return;
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
await loadUsers();
}
async function changePassword() {
const oldPassword = document.getElementById('oldPassword').value;
const newPassword = document.getElementById('newPassword').value;
await api('/api/user/change-password', {
method: 'POST',
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
}),
});
document.getElementById('oldPassword').value = '';
document.getElementById('newPassword').value = '';
alert('Password updated successfully.');
}
window.addEventListener('load', ensureSession);
</script>
</body>
</html>

View File

@@ -59,7 +59,7 @@ func ensureAdminUser() {
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
if adminPassword == "" {
adminPassword = "admin123456"
adminPassword = "admin"
}
var user model.User
@@ -106,5 +106,5 @@ func ensureAdminUser() {
monitor.Warnf("Failed to create default admin room: %v", roomErr)
}
monitor.Warnf("Default admin created: username=%s password=%s", adminUsername, adminPassword)
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
}

View File

@@ -1,11 +1,16 @@
package stream
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nareix/joy4/av/avutil"
@@ -27,9 +32,38 @@ func init() {
// RTMPServer manages all active live streams
type RTMPServer struct {
server *rtmp.Server
channels map[string]*pubsub.Queue
mutex sync.RWMutex
server *rtmp.Server
channels map[string]*pubsub.Queue
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 {
@@ -45,32 +79,29 @@ func (w writeFlusher) Flush() error {
// NewRTMPServer creates and initializes a new media server
func NewRTMPServer() *RTMPServer {
s := &RTMPServer{
channels: make(map[string]*pubsub.Queue),
server: &rtmp.Server{},
channels: make(map[string]*pubsub.Queue),
transcoders: make(map[string][]*variantTranscoder),
internalPublishKey: generateInternalPublishKey(),
server: &rtmp.Server{},
}
// Triggered when a broadcaster (e.g., OBS) starts publishing
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)
// Extract stream key from path
parts := strings.Split(streamPath, "/")
if len(parts) < 3 || parts[1] != "live" {
if len(parts) < 3 {
monitor.Warnf("Invalid publish path format: %s", streamPath)
return
}
streamKey := parts[2]
// Authenticate stream key
var room model.Room
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
monitor.Warnf("Invalid stream key: %s", streamKey)
return // Reject connection
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
if !ok {
monitor.Warnf("Invalid publish key/path: %s", streamPath)
return
}
monitor.Infof("Stream authenticated for room_id=%d", room.ID)
// 1. Get audio/video stream metadata
streams, err := conn.Streams()
if err != nil {
@@ -78,31 +109,40 @@ func NewRTMPServer() *RTMPServer {
return
}
// 2. Map the active stream by Room ID so viewers can use /live/{room_id}
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
s.mutex.Lock()
q := pubsub.NewQueue()
q.WriteHeader(streams)
s.channels[roomLivePath] = q
s.channels[channelPath] = q
s.mutex.Unlock()
// Mark room as active in DB (using map to ensure true/false is correctly updated)
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true})
if isSource {
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
defer func() {
s.mutex.Lock()
delete(s.channels, roomLivePath)
delete(s.channels, channelPath)
s.mutex.Unlock()
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
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
monitor.Infof("Publishing ended for room_id=%d", room.ID)
if isSource {
s.stopVariantTranscoders(roomID)
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
@@ -158,6 +198,9 @@ func (s *RTMPServer) Start(addr string) error {
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
if quality := normalizeQuality(c.Query("quality")); quality != "" {
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
}
s.mutex.RLock()
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 {
s.mutex.RLock()
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 {
@@ -210,7 +392,9 @@ func (s *RTMPServer) ActiveStreamPaths() []string {
paths := make([]string, 0, len(s.channels))
for path := range s.channels {
paths = append(paths, path)
if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
}
return paths
}

View File

@@ -17,8 +17,17 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordFocusNode = FocusNode();
bool _isLoading = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
void _handleLogin() async {
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(
@@ -108,6 +117,8 @@ class _LoginPageState extends State<LoginPage> {
// Fields
TextField(
controller: _usernameController,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: "Username",
prefixIcon: Icon(Icons.person),
@@ -119,7 +130,14 @@ class _LoginPageState extends State<LoginPage> {
SizedBox(height: 16),
TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!_isLoading) {
_handleLogin();
}
},
decoration: InputDecoration(
labelText: "Password",
prefixIcon: Icon(Icons.lock),

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -8,6 +9,7 @@ import 'package:video_player/video_player.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../services/chat_service.dart';
import '../widgets/web_stream_player.dart';
@@ -24,7 +26,7 @@ class PlayerPage extends StatefulWidget {
});
@override
_PlayerPageState createState() => _PlayerPageState();
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
@@ -42,11 +44,13 @@ class _PlayerPageState extends State<PlayerPage> {
bool _controlsVisible = true;
int _playerVersion = 0;
String _selectedResolution = 'Source';
List<String> _availableResolutions = const ['Source'];
Timer? _controlsHideTimer;
@override
void initState() {
super.initState();
_loadPlaybackOptions();
if (!kIsWeb) {
_initializePlayer();
}
@@ -55,9 +59,8 @@ class _PlayerPageState extends State<PlayerPage> {
}
Future<void> _initializePlayer() async {
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.playbackUrl),
);
final playbackUrl = _currentPlaybackUrl();
_controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
try {
await _controller!.initialize();
_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() {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
@@ -141,6 +193,8 @@ class _PlayerPageState extends State<PlayerPage> {
return;
}
await _loadPlaybackOptions();
setState(() {
_isRefreshing = true;
_isError = false;
@@ -223,22 +277,30 @@ class _PlayerPageState extends State<PlayerPage> {
Future<void> _selectResolution() async {
_showControls();
await _loadPlaybackOptions();
if (!mounted) {
return;
}
final nextResolution = await showModalBottomSheet<String>(
context: context,
builder: (context) {
const options = ['Source', '720p', '480p'];
final available = _availableResolutions.toSet();
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ListTile(
ListTile(
title: Text('Playback Resolution'),
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) {
final enabled = option == 'Source';
final enabled = available.contains(option);
return ListTile(
enabled: enabled,
leading: Icon(
@@ -249,7 +311,7 @@ class _PlayerPageState extends State<PlayerPage> {
title: Text(option),
subtitle: enabled
? const Text('Available now')
: const Text('Requires backend transcoding support'),
: const Text('Waiting for backend transcoding output'),
onTap: enabled ? () => Navigator.pop(context, option) : null,
);
}),
@@ -376,7 +438,7 @@ class _PlayerPageState extends State<PlayerPage> {
: kIsWeb
? WebStreamPlayer(
key: ValueKey('web-player-$_playerVersion'),
streamUrl: widget.playbackUrl,
streamUrl: _currentPlaybackUrl(),
)
: _controller != null && _controller!.value.isInitialized
? AspectRatio(

View File

@@ -307,11 +307,11 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Text(
"Version: 1.0.0-beta3.5",
"Version: 1.0.0-beta4.1",
style: TextStyle(color: Colors.grey),
),
Text(
"Author: Highground-Soft & Minimax",
"Author: Highground-Soft",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 20),

View File

@@ -62,12 +62,26 @@ class SettingsProvider with ChangeNotifier {
return "rtmp://${uri.host}:1935/live";
}
String playbackUrl(String roomId) {
String playbackUrl(String roomId, {String? quality}) {
final uri = Uri.parse(_baseUrl);
final normalizedQuality = quality?.trim().toLowerCase();
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) {

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(
Uri.parse("${settings.baseUrl}/api/user/change-password"),
headers: _headers,
body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}),
body: jsonEncode({
"old_password": oldPassword,
"new_password": newPassword,
}),
);
}
}