Compare commits

...

7 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
Z
1cce5634b1 监控网页实现 2026-04-09 00:14:57 +08:00
6b1c7242c7 Fix player overlay and danmaku rendering 2026-04-08 11:13:52 +08:00
fa86c849ca gitignore updated 2026-04-01 18:06:14 +08:00
f97195d640 Improve settings and playback controls 2026-04-01 18:04:37 +08:00
28 changed files with 2644 additions and 279 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.idea/ .idea/
.vscode/ .vscode/
docs/ docs/
.codex
# --- Backend (Go) --- # --- Backend (Go) ---
backend/hightube.db backend/hightube.db

View File

@@ -1,16 +1,16 @@
package main package main
import ( import (
"log"
"hightube/internal/api" "hightube/internal/api"
"hightube/internal/chat" "hightube/internal/chat"
"hightube/internal/db" "hightube/internal/db"
"hightube/internal/monitor"
"hightube/internal/stream" "hightube/internal/stream"
) )
func main() { func main() {
log.Println("Starting Hightube Server (Phase 4)...") monitor.Init(2000)
monitor.Infof("Starting Hightube Server v1.0.0-Beta4.1")
// Initialize Database and run auto-migrations // Initialize Database and run auto-migrations
db.InitDB() db.InitDB()
@@ -23,15 +23,16 @@ func main() {
// Start the API server in a goroutine so it doesn't block the RTMP server // Start the API server in a goroutine so it doesn't block the RTMP server
go func() { go func() {
r := api.SetupRouter(srv) r := api.SetupRouter(srv)
log.Println("[INFO] API Server is listening on :8080...") monitor.Infof("API server listening on :8080")
monitor.Infof("Web console listening on :8080/admin")
if err := r.Run(":8080"); err != nil { if err := r.Run(":8080"); err != nil {
log.Fatalf("Failed to start API server: %v", err) monitor.Errorf("Failed to start API server: %v", err)
} }
}() }()
// Setup and start the RTMP server // Setup and start the RTMP server
log.Println("[INFO] Ready to receive RTMP streams from OBS.") monitor.Infof("Ready to receive RTMP streams from OBS")
if err := srv.Start(":1935"); err != nil { if err := srv.Start(":1935"); err != nil {
log.Fatalf("Failed to start RTMP server: %v", err) monitor.Errorf("Failed to start RTMP server: %v", err)
} }
} }

View File

@@ -0,0 +1,354 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"hightube/internal/chat"
"hightube/internal/db"
"hightube/internal/model"
"hightube/internal/monitor"
"hightube/internal/stream"
"hightube/internal/utils"
)
var adminRTMP *stream.RTMPServer
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{}
if chat.MainHub != nil {
chatStats = chat.MainHub.GetStatsSnapshot()
}
activeCount := 0
activePaths := []string{}
if adminRTMP != nil {
activeCount = adminRTMP.ActiveStreamCount()
activePaths = adminRTMP.ActiveStreamPaths()
}
c.JSON(http.StatusOK, gin.H{
"system": stats,
"stream": gin.H{
"active_stream_count": activeCount,
"active_stream_paths": activePaths,
},
"chat": chatStats,
})
}
func GetAdminHealth(c *gin.Context) {
type dbHealth struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
health := gin.H{
"api": true,
"rtmp": adminRTMP != nil,
}
dbOK := dbHealth{OK: true}
if err := db.DB.Exec("SELECT 1").Error; err != nil {
dbOK.OK = false
dbOK.Error = err.Error()
}
health["db"] = dbOK
c.JSON(http.StatusOK, health)
}
func ListAdminLogs(c *gin.Context) {
level := c.Query("level")
keyword := c.Query("keyword")
page := parseIntWithDefault(c.Query("page"), 1)
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
items, total := monitor.Query(level, keyword, page, pageSize)
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
})
}
func StreamAdminLogs(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
return
}
ch := monitor.Subscribe()
defer monitor.Unsubscribe(ch)
for {
select {
case entry := <-ch:
payload, _ := json.Marshal(entry)
_, _ = c.Writer.Write([]byte("event: log\n"))
_, _ = c.Writer.Write([]byte("data: " + string(payload) + "\n\n"))
flusher.Flush()
case <-c.Request.Context().Done():
return
}
}
}
type updateRoleRequest struct {
Role string `json:"role" binding:"required"`
}
func UpdateUserRole(c *gin.Context) {
userID, ok := parsePathUint(c.Param("id"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
return
}
var req updateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be admin or user"})
return
}
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("role", role).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
return
}
operator, _ := c.Get("username")
monitor.Auditf("admin=%v updated user_id=%d role=%s", operator, userID, role)
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
}
type updateEnabledRequest struct {
Enabled bool `json:"enabled"`
}
func UpdateUserEnabled(c *gin.Context) {
userID, ok := parsePathUint(c.Param("id"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
return
}
var req updateEnabledRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("enabled", req.Enabled).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled status"})
return
}
operator, _ := c.Get("username")
monitor.Auditf("admin=%v updated user_id=%d enabled=%v", operator, userID, req.Enabled)
c.JSON(http.StatusOK, gin.H{"message": "enabled status updated"})
}
type resetPasswordRequest struct {
NewPassword string `json:"new_password" binding:"required"`
}
func ResetUserPassword(c *gin.Context) {
userID, ok := parsePathUint(c.Param("id"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
return
}
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := utils.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("password", hash).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
return
}
operator, _ := c.Get("username")
monitor.Auditf("admin=%v reset password for user_id=%d", operator, userID)
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
}
func DeleteUser(c *gin.Context) {
userID, ok := parsePathUint(c.Param("id"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
return
}
operatorID, _ := c.Get("user_id")
if opID, ok := operatorID.(uint); ok && opID == userID {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete current admin account"})
return
}
if err := db.DB.Where("user_id = ?", userID).Delete(&model.Room{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete rooms"})
return
}
if err := db.DB.Delete(&model.User{}, userID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
operator, _ := c.Get("username")
monitor.Auditf("admin=%v deleted user_id=%d", operator, userID)
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
func ListUsers(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
page := parseIntWithDefault(c.Query("page"), 1)
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
if pageSize > 200 {
pageSize = 200
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
query := db.DB.Model(&model.User{})
if keyword != "" {
query = query.Where("username LIKE ?", "%"+keyword+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count users"})
return
}
var users []model.User
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query users"})
return
}
items := make([]gin.H, 0, len(users))
for _, u := range users {
items = append(items, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
"enabled": u.Enabled,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
})
}
func parseIntWithDefault(v string, def int) int {
i, err := strconv.Atoi(v)
if err != nil || i <= 0 {
return def
}
return i
}
func parsePathUint(v string) (uint, bool) {
u64, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return 0, false
}
return uint(u64), true
}

View File

@@ -0,0 +1,21 @@
package api
import (
"embed"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed static/admin/*
var adminFS embed.FS
func AdminPage(c *gin.Context) {
content, err := fs.ReadFile(adminFS, "static/admin/index.html")
if err != nil {
c.String(http.StatusInternalServerError, "failed to load admin page")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
}

View File

@@ -2,6 +2,8 @@ package api
import ( import (
"net/http" "net/http"
"os"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -32,6 +34,12 @@ func Register(c *gin.Context) {
return 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 // Check if user exists
var existingUser model.User var existingUser model.User
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil { if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
@@ -50,6 +58,8 @@ func Register(c *gin.Context) {
user := model.User{ user := model.User{
Username: req.Username, Username: req.Username,
Password: hashedPassword, Password: hashedPassword,
Role: "user",
Enabled: true,
} }
if err := db.DB.Create(&user).Error; err != nil { if err := db.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -89,7 +99,12 @@ func Login(c *gin.Context) {
return return
} }
token, err := utils.GenerateToken(user.ID) if !user.Enabled {
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
return
}
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return return
@@ -98,6 +113,8 @@ func Login(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"username": user.Username, "username": user.Username,
"role": user.Role,
"enabled": user.Enabled,
}) })
} }
@@ -137,3 +154,11 @@ func ChangePassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) 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

@@ -8,6 +8,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"hightube/internal/chat" "hightube/internal/chat"
"hightube/internal/monitor"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@@ -23,7 +24,7 @@ func WSHandler(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
fmt.Printf("[WS ERROR] Failed to upgrade: %v\n", err) monitor.Errorf("WebSocket upgrade failed: %v", err)
return return
} }
@@ -48,4 +49,6 @@ func WSHandler(c *gin.Context) {
Content: fmt.Sprintf("%s joined the room", username), Content: fmt.Sprintf("%s joined the room", username),
RoomID: roomID, RoomID: roomID,
}) })
monitor.Infof("WebSocket client joined room_id=%s username=%s", roomID, username)
} }

View File

@@ -1,46 +1,123 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hightube/internal/db"
"hightube/internal/model"
"hightube/internal/monitor"
"hightube/internal/utils" "hightube/internal/utils"
) )
const adminSessionCookieName = "hightube_admin_session"
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context // AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") user, err := authenticateRequest(c)
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]
userIDStr, err := utils.ParseToken(tokenStr)
if err != nil { if err != nil {
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"}) 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() c.Abort()
return return
} }
userID, _ := strconv.ParseUint(userIDStr, 10, 32) c.Set("user_id", user.ID)
c.Set("user_id", uint(userID)) c.Set("username", user.Username)
c.Set("role", user.Role)
c.Next() c.Next()
} }
} }
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
role, ok := c.Get("role")
if !ok || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
c.Abort()
return
}
c.Next()
}
}
func RequestMetricsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
monitor.IncrementRequestCount()
c.Next()
if c.Writer.Status() >= http.StatusBadRequest {
monitor.IncrementErrorCount()
}
}
}
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 // CORSMiddleware handles cross-origin requests from web clients
func CORSMiddleware() gin.HandlerFunc { func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { 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}) 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

@@ -12,9 +12,10 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.Default() r := gin.Default()
BindAdminDependencies(streamServer)
// Use CORS middleware to allow web access // Use CORS middleware to allow web access
r.Use(CORSMiddleware()) r.Use(CORSMiddleware(), RequestMetricsMiddleware())
// 清除代理信任警告 "[WARNING] You trusted all proxies" // 清除代理信任警告 "[WARNING] You trusted all proxies"
r.SetTrustedProxies(nil) r.SetTrustedProxies(nil)
@@ -22,18 +23,37 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
// Public routes // Public routes
r.POST("/api/register", Register) r.POST("/api/register", Register)
r.POST("/api/login", Login) r.POST("/api/login", Login)
r.POST("/api/admin/login", AdminLogin)
r.GET("/api/rooms/active", GetActiveRooms) r.GET("/api/rooms/active", GetActiveRooms)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV) r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat // WebSocket endpoint for live chat
r.GET("/api/ws/room/:room_id", WSHandler) r.GET("/api/ws/room/:room_id", WSHandler)
r.GET("/admin", AdminPage)
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
// Protected routes (require JWT) // Protected routes (require JWT)
authGroup := r.Group("/api") authGroup := r.Group("/api")
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.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)
adminGroup.POST("/users/:id/reset-password", ResetUserPassword)
adminGroup.DELETE("/users/:id", DeleteUser)
}
} }
return r return r

View File

@@ -0,0 +1,677 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hightube Admin Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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: #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;
--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(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;
}
.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;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
.card {
background: rgba(255,255,255,0.88);
border: 1px solid var(--line);
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; }
.col5 { grid-column: span 5; }
.col7 { grid-column: span 7; }
.col8 { grid-column: span 8; }
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;
}
input, select, button {
border-radius: 14px;
border: 1px solid var(--line);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
input, select {
width: 100%;
background: var(--surface);
color: var(--ink);
}
button {
cursor: pointer;
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: linear-gradient(120deg, #7ea7ff, #4b7df2);
}
button.subtle {
color: var(--ink);
background: var(--surface-soft);
border: 1px solid var(--line);
}
button.danger {
background: linear-gradient(120deg, #ef4444, #dc2626);
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.metric {
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
}
.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;
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: 10px 8px;
vertical-align: top;
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pill {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.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) {
.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="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="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 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>
</div>
</div>
<script>
let evt = null;
let overviewTimer = null;
let healthTimer = null;
let currentAdmin = null;
function setSessionText(text) {
document.getElementById('sessionInfo').textContent = text;
}
function addLogLine(text) {
const box = document.getElementById('logs');
box.textContent += text + '\n';
box.scrollTop = box.scrollHeight;
}
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()]);
}
async function loadOverview() {
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">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">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>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 h = await api('/api/admin/health');
const dbOk = h.db && h.db.ok;
document.getElementById('health').innerHTML =
`<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 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 = '';
items.forEach(it => addLogLine(`[${it.time}] [${it.level}] ${it.message}`));
}
function connectLiveLogs() {
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] Live log stream disconnected.');
};
}
async function loadUsers() {
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
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) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id}</td>
<td>${u.username}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'user'}">${u.role}</span></td>
<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="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);
});
}
async function toggleRole(id, role) {
const next = role === 'admin' ? 'user' : 'admin';
await api(`/api/admin/users/${id}/role`, {
method: 'PATCH',
body: JSON.stringify({ role: next }),
});
await loadUsers();
}
async function toggleEnabled(id, enabled) {
await api(`/api/admin/users/${id}/enabled`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !enabled }),
});
await loadUsers();
}
async function resetPwd(id) {
const newPwd = prompt('Enter a new password for this user');
if (!newPwd) return;
await api(`/api/admin/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPwd }),
});
addLogLine(`[audit] password reset requested for user ${id}`);
}
async function deleteUser(id) {
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

@@ -40,6 +40,12 @@ type Hub struct {
mutex sync.RWMutex mutex sync.RWMutex
} }
type StatsSnapshot struct {
RoomCount int `json:"room_count"`
TotalConnectedClient int `json:"total_connected_client"`
RoomClients map[string]int `json:"room_clients"`
}
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
broadcast: make(chan Message), broadcast: make(chan Message),
@@ -195,3 +201,22 @@ func InitChat() {
MainHub = NewHub() MainHub = NewHub()
go MainHub.Run() go MainHub.Run()
} }
func (h *Hub) GetStatsSnapshot() StatsSnapshot {
h.mutex.RLock()
defer h.mutex.RUnlock()
roomClients := make(map[string]int, len(h.rooms))
totalClients := 0
for roomID, clients := range h.rooms {
count := len(clients)
roomClients[roomID] = count
totalClients += count
}
return StatsSnapshot{
RoomCount: len(h.rooms),
TotalConnectedClient: totalClients,
RoomClients: roomClients,
}
}

View File

@@ -10,6 +10,8 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"hightube/internal/model" "hightube/internal/model"
"hightube/internal/monitor"
"hightube/internal/utils"
) )
var DB *gorm.DB var DB *gorm.DB
@@ -44,5 +46,65 @@ func InitDB() {
// Phase 3.5 Fix: Reset all rooms to inactive on startup using explicit map to ensure false is updated // Phase 3.5 Fix: Reset all rooms to inactive on startup using explicit map to ensure false is updated
DB.Model(&model.Room{}).Where("1 = 1").Updates(map[string]interface{}{"is_active": false}) DB.Model(&model.Room{}).Where("1 = 1").Updates(map[string]interface{}{"is_active": false})
log.Println("Database initialized successfully.") ensureAdminUser()
monitor.Infof("Database initialized successfully")
}
func ensureAdminUser() {
adminUsername := os.Getenv("HIGHTUBE_ADMIN_USER")
if adminUsername == "" {
adminUsername = "admin"
}
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
if adminPassword == "" {
adminPassword = "admin"
}
var user model.User
err := DB.Where("username = ?", adminUsername).First(&user).Error
if err == nil {
updates := map[string]interface{}{}
if user.Role != "admin" {
updates["role"] = "admin"
}
if !user.Enabled {
updates["enabled"] = true
}
if len(updates) > 0 {
DB.Model(&user).Updates(updates)
monitor.Warnf("Admin account normalized for username=%s", adminUsername)
}
return
}
hash, hashErr := utils.HashPassword(adminPassword)
if hashErr != nil {
monitor.Errorf("Failed to hash default admin password: %v", hashErr)
return
}
newAdmin := model.User{
Username: adminUsername,
Password: hash,
Role: "admin",
Enabled: true,
}
if createErr := DB.Create(&newAdmin).Error; createErr != nil {
monitor.Errorf("Failed to create admin account: %v", createErr)
return
}
room := model.Room{
UserID: newAdmin.ID,
Title: newAdmin.Username + "'s Live Room",
StreamKey: utils.GenerateStreamKey(),
IsActive: false,
}
if roomErr := DB.Create(&room).Error; roomErr != nil {
monitor.Warnf("Failed to create default admin room: %v", roomErr)
}
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
} }

View File

@@ -9,4 +9,6 @@ type User struct {
gorm.Model gorm.Model
Username string `gorm:"uniqueIndex;not null"` Username string `gorm:"uniqueIndex;not null"`
Password string `gorm:"not null"` // Hashed password Password string `gorm:"not null"` // Hashed password
Role string `gorm:"type:varchar(20);not null;default:user"`
Enabled bool `gorm:"not null;default:true"`
} }

View File

@@ -0,0 +1,7 @@
//go:build !windows
package monitor
func getDiskSpaceGB() (float64, float64) {
return 0, 0
}

View File

@@ -0,0 +1,38 @@
//go:build windows
package monitor
import (
"os"
"path/filepath"
"golang.org/x/sys/windows"
)
func getDiskSpaceGB() (float64, float64) {
wd, err := os.Getwd()
if err != nil {
return 0, 0
}
vol := filepath.VolumeName(wd)
if vol == "" {
return 0, 0
}
root := vol + `\\`
pathPtr, err := windows.UTF16PtrFromString(root)
if err != nil {
return 0, 0
}
var freeBytesAvailable uint64
var totalBytes uint64
var totalFreeBytes uint64
if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalBytes, &totalFreeBytes); err != nil {
return 0, 0
}
const gb = 1024.0 * 1024.0 * 1024.0
return float64(totalBytes) / gb, float64(totalFreeBytes) / gb
}

View File

@@ -0,0 +1,130 @@
package monitor
import (
"fmt"
"log"
"strings"
"sync"
"time"
)
type LogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
}
type logHub struct {
mutex sync.RWMutex
entries []LogEntry
maxEntries int
subscribers map[chan LogEntry]struct{}
}
var hub = &logHub{
maxEntries: 1000,
subscribers: make(map[chan LogEntry]struct{}),
}
func Init(maxEntries int) {
if maxEntries > 0 {
hub.maxEntries = maxEntries
}
}
func Infof(format string, args ...interface{}) {
appendEntry("info", fmt.Sprintf(format, args...))
}
func Warnf(format string, args ...interface{}) {
appendEntry("warn", fmt.Sprintf(format, args...))
}
func Errorf(format string, args ...interface{}) {
appendEntry("error", fmt.Sprintf(format, args...))
}
func Auditf(format string, args ...interface{}) {
appendEntry("audit", fmt.Sprintf(format, args...))
}
func appendEntry(level, message string) {
entry := LogEntry{
Time: time.Now().Format(time.RFC3339),
Level: strings.ToLower(level),
Message: message,
}
log.Printf("[%s] %s", strings.ToUpper(entry.Level), entry.Message)
hub.mutex.Lock()
hub.entries = append(hub.entries, entry)
if len(hub.entries) > hub.maxEntries {
hub.entries = hub.entries[len(hub.entries)-hub.maxEntries:]
}
for ch := range hub.subscribers {
select {
case ch <- entry:
default:
}
}
hub.mutex.Unlock()
}
func Subscribe() chan LogEntry {
ch := make(chan LogEntry, 100)
hub.mutex.Lock()
hub.subscribers[ch] = struct{}{}
hub.mutex.Unlock()
return ch
}
func Unsubscribe(ch chan LogEntry) {
hub.mutex.Lock()
if _, ok := hub.subscribers[ch]; ok {
delete(hub.subscribers, ch)
close(ch)
}
hub.mutex.Unlock()
}
func Query(level, keyword string, page, pageSize int) ([]LogEntry, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 200 {
pageSize = 200
}
level = strings.TrimSpace(strings.ToLower(level))
keyword = strings.TrimSpace(strings.ToLower(keyword))
hub.mutex.RLock()
defer hub.mutex.RUnlock()
filtered := make([]LogEntry, 0, len(hub.entries))
for _, e := range hub.entries {
if level != "" && e.Level != level {
continue
}
if keyword != "" && !strings.Contains(strings.ToLower(e.Message), keyword) {
continue
}
filtered = append(filtered, e)
}
total := len(filtered)
start := (page - 1) * pageSize
if start >= total {
return []LogEntry{}, total
}
end := start + pageSize
if end > total {
end = total
}
return filtered[start:end], total
}

View File

@@ -0,0 +1,55 @@
package monitor
import (
"runtime"
"sync/atomic"
"time"
)
var startedAt = time.Now()
var totalRequests uint64
var totalErrors uint64
type Snapshot struct {
UptimeSeconds int64 `json:"uptime_seconds"`
Goroutines int `json:"goroutines"`
MemoryAllocMB float64 `json:"memory_alloc_mb"`
MemorySysMB float64 `json:"memory_sys_mb"`
CPUCores int `json:"cpu_cores"`
DiskTotalGB float64 `json:"disk_total_gb"`
DiskFreeGB float64 `json:"disk_free_gb"`
RequestsTotal uint64 `json:"requests_total"`
ErrorsTotal uint64 `json:"errors_total"`
}
func IncrementRequestCount() {
atomic.AddUint64(&totalRequests, 1)
}
func IncrementErrorCount() {
atomic.AddUint64(&totalErrors, 1)
}
func GetSnapshot() Snapshot {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
diskTotal, diskFree := getDiskSpaceGB()
return Snapshot{
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
Goroutines: runtime.NumGoroutine(),
MemoryAllocMB: bytesToMB(mem.Alloc),
MemorySysMB: bytesToMB(mem.Sys),
CPUCores: runtime.NumCPU(),
DiskTotalGB: diskTotal,
DiskFreeGB: diskFree,
RequestsTotal: atomic.LoadUint64(&totalRequests),
ErrorsTotal: atomic.LoadUint64(&totalErrors),
}
}
func bytesToMB(v uint64) float64 {
return float64(v) / 1024.0 / 1024.0
}

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"
@@ -17,6 +22,7 @@ import (
"hightube/internal/chat" "hightube/internal/chat"
"hightube/internal/db" "hightube/internal/db"
"hightube/internal/model" "hightube/internal/model"
"hightube/internal/monitor"
) )
func init() { func init() {
@@ -28,9 +34,38 @@ func init() {
type RTMPServer struct { type RTMPServer struct {
server *rtmp.Server server *rtmp.Server
channels map[string]*pubsub.Queue channels map[string]*pubsub.Queue
transcoders map[string][]*variantTranscoder
internalPublishKey string
mutex sync.RWMutex 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 {
httpFlusher http.Flusher httpFlusher http.Flusher
io.Writer io.Writer
@@ -45,63 +80,69 @@ func (w writeFlusher) Flush() error {
func NewRTMPServer() *RTMPServer { func NewRTMPServer() *RTMPServer {
s := &RTMPServer{ s := &RTMPServer{
channels: make(map[string]*pubsub.Queue), channels: make(map[string]*pubsub.Queue),
transcoders: make(map[string][]*variantTranscoder),
internalPublishKey: generateInternalPublishKey(),
server: &rtmp.Server{}, 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}
fmt.Printf("[INFO] OBS is attempting to publish to: %s\n", 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 {
fmt.Printf("[WARN] Invalid publish path format: %s\n", 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)
fmt.Printf("[WARN] Authentication failed, invalid stream key: %s\n", streamKey) return
return // Reject connection
} }
fmt.Printf("[INFO] Stream authenticated for Room ID: %d\n", 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 {
fmt.Printf("[ERROR] Failed to parse stream headers: %v\n", err) monitor.Errorf("Failed to parse stream headers: %v", err)
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})
// Clear chat history for this room if isSource {
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID)) s.stopVariantTranscoders(roomID)
roomIDUint := parseRoomID(roomID)
fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID) if roomIDUint != 0 {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
}
chat.MainHub.ClearRoomHistory(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
}
}() }()
// 4. Continuously copy data packets to our broadcast queue // 4. Continuously copy data packets to our broadcast queue
@@ -111,7 +152,7 @@ func NewRTMPServer() *RTMPServer {
// Triggered when a viewer (e.g., VLC) requests playback // Triggered when a viewer (e.g., VLC) requests playback
s.server.HandlePlay = func(conn *rtmp.Conn) { s.server.HandlePlay = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{room_id} streamPath := conn.URL.Path // Expected format: /live/{room_id}
fmt.Printf("[INFO] VLC is pulling stream from: %s\n", streamPath) monitor.Infof("RTMP play requested: %s", streamPath)
// 1. Look for the requested room's data queue // 1. Look for the requested room's data queue
s.mutex.RLock() s.mutex.RLock()
@@ -119,7 +160,7 @@ func NewRTMPServer() *RTMPServer {
s.mutex.RUnlock() s.mutex.RUnlock()
if !ok { if !ok {
fmt.Printf("[WARN] Stream not found or inactive: %s\n", streamPath) monitor.Warnf("Stream not found or inactive: %s", streamPath)
return return
} }
@@ -129,7 +170,7 @@ func NewRTMPServer() *RTMPServer {
conn.WriteHeader(streams) conn.WriteHeader(streams)
// 3. Cleanup on end // 3. Cleanup on end
defer fmt.Printf("[INFO] Playback ended: %s\n", streamPath) defer monitor.Infof("Playback ended: %s", streamPath)
// 4. Continuously copy data packets to the viewer // 4. Continuously copy data packets to the viewer
err := avutil.CopyPackets(conn, cursor) err := avutil.CopyPackets(conn, cursor)
@@ -137,9 +178,9 @@ func NewRTMPServer() *RTMPServer {
// 如果是客户端主动断开连接引起的错误,不将其作为严重错误打印 // 如果是客户端主动断开连接引起的错误,不将其作为严重错误打印
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") { if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
fmt.Printf("[INFO] Viewer disconnected normally: %s\n", streamPath) monitor.Infof("Viewer disconnected: %s", streamPath)
} else { } else {
fmt.Printf("[ERROR] Error occurred during playback: %v\n", err) monitor.Errorf("Playback error on %s: %v", streamPath, err)
} }
} }
} }
@@ -150,13 +191,16 @@ func NewRTMPServer() *RTMPServer {
// Start launches the RTMP server // Start launches the RTMP server
func (s *RTMPServer) Start(addr string) error { func (s *RTMPServer) Start(addr string) error {
s.server.Addr = addr s.server.Addr = addr
fmt.Printf("[INFO] RTMP Server is listening on %s...\n", addr) monitor.Infof("RTMP server listening on %s", addr)
return s.server.ListenAndServe() return s.server.ListenAndServe()
} }
// 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]
@@ -190,9 +234,167 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF { if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") { if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
fmt.Printf("[INFO] HTTP-FLV viewer disconnected normally: %s\n", streamPath) monitor.Infof("HTTP-FLV viewer disconnected: %s", streamPath)
return return
} }
fmt.Printf("[ERROR] HTTP-FLV playback error on %s: %v\n", streamPath, err) monitor.Errorf("HTTP-FLV playback error on %s: %v", streamPath, err)
} }
} }
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()
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 {
s.mutex.RLock()
defer s.mutex.RUnlock()
paths := make([]string, 0, len(s.channels))
for path := range s.channels {
if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
}
return paths
}

View File

@@ -13,26 +13,36 @@ import (
// In production, load this from environment variables // In production, load this from environment variables
var jwtKey = []byte("hightube_super_secret_key_MVP_only") var jwtKey = []byte("hightube_super_secret_key_MVP_only")
type TokenClaims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// GenerateToken generates a JWT token for a given user ID // GenerateToken generates a JWT token for a given user ID
func GenerateToken(userID uint) (string, error) { func GenerateToken(userID uint, username, role string) (string, error) {
claims := &jwt.RegisteredClaims{ claims := &TokenClaims{
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID), Subject: fmt.Sprintf("%d", userID),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
},
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtKey) return token.SignedString(jwtKey)
} }
// ParseToken parses the JWT string and returns the user ID (Subject) // ParseToken parses the JWT string and returns the user ID (Subject)
func ParseToken(tokenStr string) (string, error) { func ParseToken(tokenStr string) (*TokenClaims, error) {
claims := &jwt.RegisteredClaims{} claims := &TokenClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return jwtKey, nil return jwtKey, nil
}) })
if err != nil || !token.Valid { if err != nil || !token.Valid {
return "", err return nil, err
} }
return claims.Subject, nil return claims, nil
} }
// HashPassword creates a bcrypt hash of the password // HashPassword creates a bcrypt hash of the password

View File

@@ -21,6 +21,8 @@ void main() {
} }
class HightubeApp extends StatelessWidget { class HightubeApp extends StatelessWidget {
const HightubeApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>(); final auth = context.watch<AuthProvider>();
@@ -42,7 +44,7 @@ class HightubeApp extends StatelessWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
), ),
themeMode: ThemeMode.system, // 跟随系统切换深浅色 themeMode: settings.themeMode,
home: auth.isAuthenticated ? HomePage() : LoginPage(), home: auth.isAuthenticated ? HomePage() : LoginPage(),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );

View File

@@ -8,6 +8,8 @@ import 'register_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override @override
_LoginPageState createState() => _LoginPageState(); _LoginPageState createState() => _LoginPageState();
} }
@@ -15,11 +17,22 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _passwordFocusNode = FocusNode();
bool _isLoading = false; bool _isLoading = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
void _handleLogin() async { void _handleLogin() async {
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields"))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
return; return;
} }
@@ -29,16 +42,29 @@ class _LoginPageState extends State<LoginPage> {
final api = ApiService(settings, null); final api = ApiService(settings, null);
try { try {
final response = await api.login(_usernameController.text, _passwordController.text); final response = await api.login(
_usernameController.text,
_passwordController.text,
);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
await auth.login(data['token'], data['username']); await auth.login(data['token'], data['username']);
} else { } else {
if (!mounted) {
return;
}
final error = jsonDecode(response.body)['error'] ?? "Login Failed"; final error = jsonDecode(response.body)['error'] ?? "Login Failed";
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error: Could not connect to server"))); if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Network Error: Could not connect to server")),
);
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
} }
@@ -51,7 +77,10 @@ class _LoginPageState extends State<LoginPage> {
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage())), onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
), ),
], ],
), ),
@@ -64,7 +93,11 @@ class _LoginPageState extends State<LoginPage> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Logo & Name // Logo & Name
Icon(Icons.flutter_dash, size: 80, color: Theme.of(context).colorScheme.primary), Icon(
Icons.flutter_dash,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
"HIGHTUBE", "HIGHTUBE",
@@ -75,26 +108,42 @@ class _LoginPageState extends State<LoginPage> {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
), ),
Text("Open Source Live Platform", style: TextStyle(color: Colors.grey)), Text(
"Open Source Live Platform",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 48), SizedBox(height: 48),
// Fields // Fields
TextField( TextField(
controller: _usernameController, controller: _usernameController,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Username", labelText: "Username",
prefixIcon: Icon(Icons.person), prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
), ),
SizedBox(height: 16), SizedBox(height: 16),
TextField( TextField(
controller: _passwordController, controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: true, obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!_isLoading) {
_handleLogin();
}
},
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Password", labelText: "Password",
prefixIcon: Icon(Icons.lock), prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
), ),
SizedBox(height: 32), SizedBox(height: 32),
@@ -106,16 +155,26 @@ class _LoginPageState extends State<LoginPage> {
child: ElevatedButton( child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin, onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? CircularProgressIndicator()
: Text(
"LOGIN",
style: TextStyle(fontWeight: FontWeight.bold),
), ),
child: _isLoading ? CircularProgressIndicator() : Text("LOGIN", style: TextStyle(fontWeight: FontWeight.bold)),
), ),
), ),
SizedBox(height: 16), SizedBox(height: 16),
// Register Link // Register Link
TextButton( TextButton(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => RegisterPage())), onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => RegisterPage()),
),
child: Text("Don't have an account? Create one"), child: Text("Don't have an account? Create one"),
), ),
], ],

View File

@@ -1,9 +1,15 @@
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';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart'; 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';
@@ -13,14 +19,14 @@ class PlayerPage extends StatefulWidget {
final String roomId; final String roomId;
const PlayerPage({ const PlayerPage({
Key? key, super.key,
required this.title, required this.title,
required this.playbackUrl, required this.playbackUrl,
required this.roomId, required this.roomId,
}) : super(key: key); });
@override @override
_PlayerPageState createState() => _PlayerPageState(); State<PlayerPage> createState() => _PlayerPageState();
} }
class _PlayerPageState extends State<PlayerPage> { class _PlayerPageState extends State<PlayerPage> {
@@ -28,35 +34,103 @@ class _PlayerPageState extends State<PlayerPage> {
final ChatService _chatService = ChatService(); final ChatService _chatService = ChatService();
final TextEditingController _msgController = TextEditingController(); final TextEditingController _msgController = TextEditingController();
final List<ChatMessage> _messages = []; final List<ChatMessage> _messages = [];
final List<Widget> _danmakus = []; final List<_DanmakuEntry> _danmakus = [];
bool _isError = false; bool _isError = false;
String? _errorMessage; String? _errorMessage;
bool _showDanmaku = true;
bool _isRefreshing = false;
bool _isFullscreen = false;
bool _controlsVisible = true;
int _playerVersion = 0;
String _selectedResolution = 'Source';
List<String> _availableResolutions = const ['Source'];
Timer? _controlsHideTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadPlaybackOptions();
if (!kIsWeb) { if (!kIsWeb) {
_initializePlayer(); _initializePlayer();
} }
_initializeChat(); _initializeChat();
_showControls();
} }
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();
if (mounted) setState(() {}); if (mounted) setState(() {});
} catch (e) { } catch (e) {
if (mounted) if (mounted) {
setState(() { setState(() {
_isError = true; _isError = true;
_errorMessage = e.toString(); _errorMessage = e.toString();
_isRefreshing = false;
}); });
} }
return;
}
if (mounted) {
setState(() {
_isError = false;
_errorMessage = null;
_isRefreshing = false;
});
}
}
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() {
@@ -72,8 +146,10 @@ class _PlayerPageState extends State<PlayerPage> {
setState(() { setState(() {
_messages.insert(0, msg); _messages.insert(0, msg);
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) { if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
if (_showDanmaku) {
_addDanmaku(msg.content); _addDanmaku(msg.content);
} }
}
}); });
} }
}); });
@@ -81,18 +157,23 @@ class _PlayerPageState extends State<PlayerPage> {
void _addDanmaku(String text) { void _addDanmaku(String text) {
final key = UniqueKey(); final key = UniqueKey();
final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0; final lane = DateTime.now().millisecondsSinceEpoch % 8;
final topFactor = 0.06 + lane * 0.045;
final danmaku = _DanmakuItem( setState(() {
_danmakus.add(
_DanmakuEntry(
key: key, key: key,
text: text, text: text,
top: top, topFactor: topFactor,
onFinished: () { onFinished: () {
if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == key)); if (mounted) {
setState(() => _danmakus.removeWhere((w) => w.key == key));
}
}, },
),
); );
});
setState(() => _danmakus.add(danmaku));
} }
void _sendMsg() { void _sendMsg() {
@@ -107,8 +188,154 @@ class _PlayerPageState extends State<PlayerPage> {
} }
} }
Future<void> _refreshPlayer() async {
if (_isRefreshing) {
return;
}
await _loadPlaybackOptions();
setState(() {
_isRefreshing = true;
_isError = false;
_errorMessage = null;
_danmakus.clear();
_playerVersion++;
});
_showControls();
if (kIsWeb) {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
setState(() => _isRefreshing = false);
}
return;
}
if (_controller != null) {
await _controller!.dispose();
}
_controller = null;
if (mounted) {
setState(() {});
}
await _initializePlayer();
}
Future<void> _toggleFullscreen() async {
final nextValue = !_isFullscreen;
if (!kIsWeb) {
await SystemChrome.setEnabledSystemUIMode(
nextValue ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge,
);
await SystemChrome.setPreferredOrientations(
nextValue
? const [
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]
: DeviceOrientation.values,
);
}
if (mounted) {
setState(() => _isFullscreen = nextValue);
}
_showControls();
}
void _toggleDanmaku() {
setState(() {
_showDanmaku = !_showDanmaku;
if (!_showDanmaku) {
_danmakus.clear();
}
});
_showControls();
}
void _showControls() {
_controlsHideTimer?.cancel();
if (mounted) {
setState(() => _controlsVisible = true);
}
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) {
setState(() => _controlsVisible = false);
}
});
}
void _toggleControlsVisibility() {
if (_controlsVisible) {
_controlsHideTimer?.cancel();
setState(() => _controlsVisible = false);
} else {
_showControls();
}
}
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: [
ListTile(
title: Text('Playback Resolution'),
subtitle: Text(
available.length > 1
? 'Select an available transcoded stream.'
: 'Only the source stream is available right now.',
),
),
...options.map((option) {
final enabled = available.contains(option);
return ListTile(
enabled: enabled,
leading: Icon(
option == _selectedResolution
? Icons.radio_button_checked
: Icons.radio_button_off,
),
title: Text(option),
subtitle: enabled
? const Text('Available now')
: const Text('Waiting for backend transcoding output'),
onTap: enabled ? () => Navigator.pop(context, option) : null,
);
}),
],
),
);
},
);
if (nextResolution == null || nextResolution == _selectedResolution) {
return;
}
setState(() => _selectedResolution = nextResolution);
await _refreshPlayer();
}
@override @override
void dispose() { void dispose() {
if (!kIsWeb && _isFullscreen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
_controlsHideTimer?.cancel();
_controller?.dispose(); _controller?.dispose();
_chatService.dispose(); _chatService.dispose();
_msgController.dispose(); _msgController.dispose();
@@ -120,24 +347,29 @@ class _PlayerPageState extends State<PlayerPage> {
bool isWide = MediaQuery.of(context).size.width > 900; bool isWide = MediaQuery.of(context).size.width > 900;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.title)), backgroundColor: _isFullscreen
body: isWide ? _buildWideLayout() : _buildMobileLayout(), ? Colors.black
: Theme.of(context).colorScheme.surface,
appBar: _isFullscreen ? null : AppBar(title: Text(widget.title)),
body: _isFullscreen
? _buildFullscreenLayout()
: isWide
? _buildWideLayout()
: _buildMobileLayout(),
); );
} }
Widget _buildFullscreenLayout() {
return SizedBox.expand(child: _buildVideoPanel());
}
// 宽屏布局:左右分栏 // 宽屏布局:左右分栏
Widget _buildWideLayout() { Widget _buildWideLayout() {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 左侧视频区 (占比 75%) // 左侧视频区 (占比 75%)
Expanded( Expanded(flex: 3, child: _buildVideoPanel()),
flex: 3,
child: Container(
color: Colors.black,
child: _buildVideoWithDanmaku(),
),
),
// 右侧聊天区 (占比 25%) // 右侧聊天区 (占比 25%)
Container( Container(
width: 350, width: 350,
@@ -156,21 +388,45 @@ class _PlayerPageState extends State<PlayerPage> {
Widget _buildMobileLayout() { Widget _buildMobileLayout() {
return Column( return Column(
children: [ children: [
// 上方视频区 SizedBox(
Container( height: 310,
color: Colors.black,
width: double.infinity, width: double.infinity,
height: 250, child: _buildVideoPanel(),
child: _buildVideoWithDanmaku(),
), ),
// 下方聊天区
Expanded(child: _buildChatSection()), Expanded(child: _buildChatSection()),
], ],
); );
} }
// 抽离视频播放器与弹幕组件 Widget _buildVideoPanel() {
return MouseRegion(
onHover: (_) => _showControls(),
onEnter: (_) => _showControls(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleControlsVisibility,
onDoubleTap: _toggleFullscreen,
child: Container(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(child: _buildVideoWithDanmaku()),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildPlaybackControls(),
),
],
),
),
),
);
}
Widget _buildVideoWithDanmaku() { Widget _buildVideoWithDanmaku() {
return LayoutBuilder(
builder: (context, constraints) {
return Stack( return Stack(
children: [ children: [
Center( Center(
@@ -180,7 +436,10 @@ class _PlayerPageState extends State<PlayerPage> {
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
) )
: kIsWeb : kIsWeb
? WebStreamPlayer(streamUrl: widget.playbackUrl) ? WebStreamPlayer(
key: ValueKey('web-player-$_playerVersion'),
streamUrl: _currentPlaybackUrl(),
)
: _controller != null && _controller!.value.isInitialized : _controller != null && _controller!.value.isInitialized
? AspectRatio( ? AspectRatio(
aspectRatio: _controller!.value.aspectRatio, aspectRatio: _controller!.value.aspectRatio,
@@ -188,10 +447,152 @@ class _PlayerPageState extends State<PlayerPage> {
) )
: CircularProgressIndicator(), : CircularProgressIndicator(),
), ),
// 弹幕层使用 ClipRect 裁剪,防止飘出视频区域 if (_showDanmaku)
ClipRect(child: Stack(children: _danmakus)), ClipRect(
child: Stack(
children: _danmakus
.map(
(item) => _DanmakuItem(
key: item.key,
text: item.text,
topFactor: item.topFactor,
containerWidth: constraints.maxWidth,
containerHeight: constraints.maxHeight,
onFinished: item.onFinished,
),
)
.toList(),
),
),
if (_isRefreshing)
const Positioned(
top: 16,
right: 16,
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
], ],
); );
},
);
}
Color _usernameColor(String username, String type) {
if (type == "system") {
return Colors.blue;
}
final normalized = username.trim().toLowerCase();
var hash = 5381;
for (final codeUnit in normalized.codeUnits) {
hash = ((hash << 5) + hash) ^ codeUnit;
}
final hue = (hash.abs() % 360).toDouble();
return HSLColor.fromAHSL(1, hue, 0.72, 0.68).toColor();
}
Widget _buildMessageItem(ChatMessage message) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).textTheme.bodyMedium?.color,
),
children: [
TextSpan(
text: "${message.username}: ",
style: TextStyle(
fontWeight: FontWeight.bold,
color: _usernameColor(message.username, message.type),
),
),
TextSpan(text: message.content),
],
),
),
);
}
Widget _buildPlaybackControls() {
return IgnorePointer(
ignoring: !_controlsVisible,
child: AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 220),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.9),
Colors.black.withValues(alpha: 0.55),
Colors.transparent,
],
),
),
child: SafeArea(
top: false,
child: Align(
alignment: Alignment.bottomCenter,
child: Wrap(
spacing: 10,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [
_buildControlButton(
icon: Icons.refresh,
label: "Refresh",
onPressed: _refreshPlayer,
),
_buildControlButton(
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
onPressed: _toggleDanmaku,
),
_buildControlButton(
icon: _isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
onPressed: _toggleFullscreen,
),
_buildControlButton(
icon: Icons.high_quality,
label: _selectedResolution,
onPressed: _selectResolution,
),
],
),
),
),
),
),
);
}
Widget _buildControlButton({
required IconData icon,
required String label,
required FutureOr<void> Function() onPressed,
}) {
return FilledButton.tonalIcon(
onPressed: () async {
_showControls();
await onPressed();
},
icon: Icon(icon, size: 18),
label: Text(label),
style: FilledButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.white.withValues(alpha: 0.12),
),
);
} }
// 抽离聊天区域组件 // 抽离聊天区域组件
@@ -200,7 +601,7 @@ class _PlayerPageState extends State<PlayerPage> {
children: [ children: [
Container( Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row( child: Row(
children: [ children: [
Icon(Icons.chat_bubble_outline, size: 16), Icon(Icons.chat_bubble_outline, size: 16),
@@ -216,28 +617,7 @@ class _PlayerPageState extends State<PlayerPage> {
itemCount: _messages.length, itemCount: _messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final m = _messages[index]; final m = _messages[index];
return Padding( return _buildMessageItem(m);
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).textTheme.bodyMedium?.color,
),
children: [
TextSpan(
text: "${m.username}: ",
style: TextStyle(
fontWeight: FontWeight.bold,
color: m.type == "system"
? Colors.blue
: Theme.of(context).colorScheme.primary,
),
),
TextSpan(text: m.content),
],
),
),
);
}, },
), ),
), ),
@@ -277,17 +657,35 @@ class _PlayerPageState extends State<PlayerPage> {
} }
} }
class _DanmakuEntry {
final Key key;
final String text;
final double topFactor;
final VoidCallback onFinished;
const _DanmakuEntry({
required this.key,
required this.text,
required this.topFactor,
required this.onFinished,
});
}
class _DanmakuItem extends StatefulWidget { class _DanmakuItem extends StatefulWidget {
final String text; final String text;
final double top; final double topFactor;
final double containerWidth;
final double containerHeight;
final VoidCallback onFinished; final VoidCallback onFinished;
const _DanmakuItem({ const _DanmakuItem({
Key? key, super.key,
required this.text, required this.text,
required this.top, required this.topFactor,
required this.containerWidth,
required this.containerHeight,
required this.onFinished, required this.onFinished,
}) : super(key: key); });
@override @override
__DanmakuItemState createState() => __DanmakuItemState(); __DanmakuItemState createState() => __DanmakuItemState();
@@ -327,9 +725,8 @@ class __DanmakuItemState extends State<_DanmakuItem>
animation: _animation, animation: _animation,
builder: (context, child) { builder: (context, child) {
return Positioned( return Positioned(
top: widget.top, top: widget.containerHeight * widget.topFactor,
// left 使用 MediaQuery 获取屏幕宽度进行动态计算 left: widget.containerWidth * _animation.value,
left: MediaQuery.of(context).size.width * _animation.value,
child: Text( child: Text(
widget.text, widget.text,
style: TextStyle( style: TextStyle(

View File

@@ -1,11 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override @override
_SettingsPageState createState() => _SettingsPageState(); _SettingsPageState createState() => _SettingsPageState();
} }
@@ -28,7 +32,9 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_urlController = TextEditingController(text: context.read<SettingsProvider>().baseUrl); _urlController = TextEditingController(
text: context.read<SettingsProvider>().baseUrl,
);
} }
@override @override
@@ -44,23 +50,41 @@ class _SettingsPageState extends State<SettingsPage> {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
final api = ApiService(settings, auth.token); final api = ApiService(settings, auth.token);
if (_oldPasswordController.text.isEmpty || _newPasswordController.text.isEmpty) { if (_oldPasswordController.text.isEmpty ||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in both password fields"))); _newPasswordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Please fill in both password fields")),
);
return; return;
} }
try { try {
final resp = await api.changePassword(_oldPasswordController.text, _newPasswordController.text); final resp = await api.changePassword(
_oldPasswordController.text,
_newPasswordController.text,
);
if (!mounted) {
return;
}
final data = jsonDecode(resp.body); final data = jsonDecode(resp.body);
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Password updated successfully"))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Password updated successfully")),
);
_oldPasswordController.clear(); _oldPasswordController.clear();
_newPasswordController.clear(); _newPasswordController.clear();
} else { } else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Error: ${data['error']}"))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Error: ${data['error']}")));
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to connect to server"))); if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to connect to server")));
} }
} }
@@ -68,6 +92,7 @@ class _SettingsPageState extends State<SettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>(); final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
final isAuthenticated = auth.isAuthenticated;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -79,49 +104,94 @@ class _SettingsPageState extends State<SettingsPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// User Profile Section if (isAuthenticated) ...[
_buildProfileSection(auth), _buildProfileSection(auth),
SizedBox(height: 32), const SizedBox(height: 32),
],
// Network Configuration
_buildSectionTitle("Network Configuration"), _buildSectionTitle("Network Configuration"),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _urlController, controller: _urlController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Backend Server URL", labelText: "Backend Server URL",
hintText: "http://127.0.0.1:8080", hintText: "http://127.0.0.1:8080",
prefixIcon: Icon(Icons.lan), prefixIcon: Icon(Icons.lan),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
SizedBox(height: 12), ),
const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
context.read<SettingsProvider>().setBaseUrl(_urlController.text); context.read<SettingsProvider>().setBaseUrl(
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL Updated"), behavior: SnackBarBehavior.floating)); _urlController.text,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Server URL Updated"),
behavior: SnackBarBehavior.floating,
),
);
}, },
icon: Icon(Icons.save), icon: Icon(Icons.save),
label: Text("Save Network Settings"), label: Text("Save Network Settings"),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primaryContainer, backgroundColor: Theme.of(
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, context,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),
SizedBox(height: 32), ),
const SizedBox(height: 32),
// Theme Color Section
_buildSectionTitle("Theme Customization"), _buildSectionTitle("Theme Customization"),
SizedBox(height: 16), const SizedBox(height: 16),
Text(
"Appearance Mode",
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text("System"),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text("Light"),
icon: Icon(Icons.light_mode),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text("Dark"),
icon: Icon(Icons.dark_mode),
),
],
selected: {settings.themeMode},
onSelectionChanged: (selection) {
settings.setThemeMode(selection.first);
},
),
const SizedBox(height: 20),
Text("Accent Color", style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12),
Wrap( Wrap(
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: _availableColors.map((color) { children: _availableColors.map((color) {
bool isSelected = settings.themeColor.value == color.value; final isSelected =
settings.themeColor.toARGB32() == color.toARGB32();
return GestureDetector( return GestureDetector(
onTap: () => settings.setThemeColor(color), onTap: () => settings.setThemeColor(color),
child: Container( child: Container(
@@ -130,57 +200,86 @@ class _SettingsPageState extends State<SettingsPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
shape: BoxShape.circle, shape: BoxShape.circle,
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null, border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 3,
)
: null,
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)), BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, 2),
),
], ],
), ),
child: isSelected ? Icon(Icons.check, color: Colors.white) : null, child: isSelected
? Icon(Icons.check, color: Colors.white)
: null,
), ),
); );
}).toList(), }).toList(),
), ),
SizedBox(height: 32), if (isAuthenticated) ...[
const SizedBox(height: 32),
// Security Section
_buildSectionTitle("Security"), _buildSectionTitle("Security"),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _oldPasswordController, controller: _oldPasswordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Old Password", labelText: "Old Password",
prefixIcon: Icon(Icons.lock_outline), prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
SizedBox(height: 12), ),
const SizedBox(height: 12),
TextField( TextField(
controller: _newPasswordController, controller: _newPasswordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "New Password", labelText: "New Password",
prefixIcon: Icon(Icons.lock_reset), prefixIcon: const Icon(Icons.lock_reset),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
SizedBox(height: 12), ),
const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _handleChangePassword, onPressed: _handleChangePassword,
icon: Icon(Icons.update), icon: const Icon(Icons.update),
label: Text("Change Password"), label: const Text("Change Password"),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),
SizedBox(height: 40), ),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: auth.logout,
icon: const Icon(Icons.logout),
label: const Text("Logout"),
style: FilledButton.styleFrom(
foregroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
const SizedBox(height: 40),
// About Section const Divider(),
Divider(), const SizedBox(height: 20),
SizedBox(height: 20),
Center( Center(
child: Column( child: Column(
children: [ children: [
@@ -191,18 +290,39 @@ class _SettingsPageState extends State<SettingsPage> {
color: settings.themeColor, color: settings.themeColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Center(child: Text("H", style: TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold))), child: Center(
child: Text(
"H",
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
), ),
SizedBox(height: 12), SizedBox(height: 12),
Text("Hightube", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), Text(
Text("Version: 1.0.0-beta3.5", style: TextStyle(color: Colors.grey)), "Hightube",
Text("Author: Highground-Soft & Minimax", style: TextStyle(color: Colors.grey)), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Text(
"Version: 1.0.0-beta4.1",
style: TextStyle(color: Colors.grey),
),
Text(
"Author: Highground-Soft",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 20), SizedBox(height: 20),
Text("© 2026 Hightube Project", style: TextStyle(fontSize: 12, color: Colors.grey)), Text(
"© 2026 Hightube Project",
style: TextStyle(fontSize: 12, color: Colors.grey),
),
], ],
), ),
), ),
SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
), ),
@@ -221,9 +341,9 @@ class _SettingsPageState extends State<SettingsPage> {
Widget _buildProfileSection(AuthProvider auth) { Widget _buildProfileSection(AuthProvider auth) {
return Container( return Container(
padding: EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Row( child: Row(
@@ -233,10 +353,14 @@ class _SettingsPageState extends State<SettingsPage> {
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
child: Text( child: Text(
(auth.username ?? "U")[0].toUpperCase(), (auth.username ?? "U")[0].toUpperCase(),
style: TextStyle(fontSize: 32, color: Colors.white, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 32,
color: Colors.white,
fontWeight: FontWeight.bold,
), ),
), ),
SizedBox(width: 20), ),
const SizedBox(width: 20),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -247,16 +371,13 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
Text( Text(
"Self-hosted Streamer", "Self-hosted Streamer",
style: TextStyle(color: Theme.of(context).colorScheme.outline), style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
), ),
], ],
), ),
), ),
IconButton(
onPressed: () => auth.logout(),
icon: Icon(Icons.logout, color: Colors.redAccent),
tooltip: "Logout",
),
], ],
), ),
); );

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
// Use 10.0.2.2 for Android emulator to access host's localhost // Use 10.0.2.2 for Android emulator to access host's localhost
@@ -11,9 +11,11 @@ class SettingsProvider with ChangeNotifier {
String _baseUrl = _defaultUrl; String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue; Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
Color get themeColor => _themeColor; Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode;
SettingsProvider() { SettingsProvider() {
_loadSettings(); _loadSettings();
@@ -26,6 +28,10 @@ class SettingsProvider with ChangeNotifier {
if (colorValue != null) { if (colorValue != null) {
_themeColor = Color(colorValue); _themeColor = Color(colorValue);
} }
final savedThemeMode = prefs.getString('themeMode');
if (savedThemeMode != null) {
_themeMode = _themeModeFromString(savedThemeMode);
}
notifyListeners(); notifyListeners();
} }
@@ -39,7 +45,14 @@ class SettingsProvider with ChangeNotifier {
void setThemeColor(Color color) async { void setThemeColor(Color color) async {
_themeColor = color; _themeColor = color;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeColor', color.value); await prefs.setInt('themeColor', color.toARGB32());
notifyListeners();
}
void setThemeMode(ThemeMode mode) async {
_themeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('themeMode', mode.name);
notifyListeners(); notifyListeners();
} }
@@ -49,11 +62,36 @@ 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();
} }
if (normalizedQuality == null || normalizedQuality.isEmpty) {
return "$rtmpUrl/$roomId"; return "$rtmpUrl/$roomId";
} }
return "$rtmpUrl/$roomId/$normalizedQuality";
}
ThemeMode _themeModeFromString(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
} }

View File

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

View File

@@ -2,8 +2,13 @@ import 'package:flutter/material.dart';
class WebStreamPlayer extends StatelessWidget { class WebStreamPlayer extends StatelessWidget {
final String streamUrl; final String streamUrl;
final int? refreshToken;
const WebStreamPlayer({super.key, required this.streamUrl}); const WebStreamPlayer({
super.key,
required this.streamUrl,
this.refreshToken,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -5,8 +5,13 @@ import 'package:flutter/material.dart';
class WebStreamPlayer extends StatefulWidget { class WebStreamPlayer extends StatefulWidget {
final String streamUrl; final String streamUrl;
final int? refreshToken;
const WebStreamPlayer({super.key, required this.streamUrl}); const WebStreamPlayer({
super.key,
required this.streamUrl,
this.refreshToken,
});
@override @override
State<WebStreamPlayer> createState() => _WebStreamPlayerState(); State<WebStreamPlayer> createState() => _WebStreamPlayerState();

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0-beta3.5 version: 1.0.0-beta4.1
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1